No description
Find a file
Micke Nordin f2245b5e23 Add multi-database support to nullable user_id migration
- SQLite: table recreation (doesn't support ALTER COLUMN)
- PostgreSQL: ALTER COLUMN DROP NOT NULL
- MySQL: MODIFY COLUMN
- Handle fresh database (create table directly)
- Handle partial migration failure (drop temp table first)
2026-01-26 11:33:21 +01:00
arch Add Arch Linux packaging and improve Docker build 2026-01-12 12:11:25 +01:00
debian Use schema traits as single source of truth for Lua API types 2026-01-08 21:01:04 +01:00
migration Add multi-database support to nullable user_id migration 2026-01-26 11:33:21 +01:00
plugins Add user-friendly error page for plugin capability errors 2026-01-26 09:59:13 +01:00
src Make group_members.user_id nullable for federated members 2026-01-26 11:11:19 +01:00
static/data Add JavaScript API for plugins with autocomplete and TypeScript tooling 2026-01-09 13:17:36 +01:00
storage/themes Auto-package and sign plugins during cargo build 2026-01-08 22:29:41 +01:00
templates Add user-friendly error page for plugin capability errors 2026-01-26 09:59:13 +01:00
themes Production readiness fixes: auth, scheduling, websockets, OCM shares 2026-01-08 16:56:05 +01:00
types Add email field to user model 2026-01-26 09:29:32 +01:00
.dockerignore Add .dockerignore to reduce Docker build context 2026-01-13 14:59:39 +01:00
.env.example Add Docker setup for containerized deployment 2026-01-11 18:07:12 +01:00
.gitignore Add Arch Linux packaging and improve Docker build 2026-01-12 12:11:25 +01:00
.luarc.json
ARCHITECTURE.md Add runtime capability enforcement and OIDC auto-detection 2026-01-25 22:47:49 +01:00
build
build.rs Apply rustfmt formatting 2026-01-15 12:21:31 +01:00
cargo
Cargo.lock Implement OIDC authentication support 2026-01-15 17:17:21 +01:00
Cargo.toml Implement OIDC authentication support 2026-01-15 17:17:21 +01:00
docker-compose.yml Add Docker setup for containerized deployment 2026-01-11 18:07:12 +01:00
Dockerfile Add Arch Linux packaging and improve Docker build 2026-01-12 12:11:25 +01:00
Makefile Add publish target to build and push Docker image to registry 2026-01-25 20:19:45 +01:00
README.md Add runtime capability enforcement and OIDC auto-detection 2026-01-25 22:47:49 +01:00
TODO.md Add runtime capability enforcement and OIDC auto-detection 2026-01-25 22:47:49 +01:00

Amity v2

A personal cloud platform with plugin support.

Building

cargo build --release

Running the Server

# Basic usage (uses default port 3000)
AMITY_DATABASE_URL="sqlite:./amity.db?mode=rwc" ./target/release/amity serve

# Custom port
AMITY_DATABASE_URL="sqlite:./amity.db?mode=rwc" ./target/release/amity serve --port 9090

# With custom bind address
AMITY_DATABASE_URL="sqlite:./amity.db?mode=rwc" ./target/release/amity serve --bind 127.0.0.1 --port 9090

Plugin System

Plugin Structure

Plugins are distributed as .tar.gz archives containing:

plugin-name/
├── manifest.toml      # Plugin metadata (required)
├── signature.txt      # Ed25519 signature (required)
├── init.lua           # Entry point (required)
├── templates/         # Tera templates (optional)
│   └── *.html
├── static/            # Static assets (optional)
│   └── css/
│       └── *.css
└── lib/               # Additional Lua modules (optional)
    └── *.lua

Important: When creating the archive, files must be at the root level (not inside a directory):

cd plugins/my-plugin
tar -czvf ../my-plugin.tar.gz manifest.toml init.lua signature.txt templates static

Creating a New Plugin

The easiest way to create a new plugin is with the amity-plugin tool:

# Create a new plugin with scaffolding
./target/release/amity-plugin init my-plugin --author "Your Name" --description "My plugin"

# This creates:
# my-plugin/
# ├── manifest.toml      # Pre-configured manifest
# ├── init.lua           # Entry point with lifecycle functions
# ├── templates/         # Example template
# ├── static/css/        # Example stylesheet
# ├── .luarc.json        # LSP configuration for editors
# ├── types/amity.lua    # Type definitions for autocompletion
# └── .gitignore

The generated plugin includes full LSP support for Lua Language Server, giving you autocompletion and documentation in editors like Neovim and VS Code.

manifest.toml

[plugin]
name = "my-plugin"
version = "1.0.0"
api_version = "v1"
description = "My awesome plugin"
author = "Your Name"

[dependencies]
# Other plugins this depends on (optional)
# other-plugin = ">=1.0.0"

[capabilities]
# Server-level capabilities (granted by admin via CLI)
declared = [
    "http:register_route=/my-plugin",
    "http:serve_static=/my-plugin/static",
    "templates",
    "navigation",
    "kv:global=my-plugin:",
]

# User-level capabilities (granted by each user via consent flow)
# These are requested at runtime when the plugin needs them
user_declared = [
    "storage:read=*",
    "storage:write=/documents",
    "kv:user",
]

[exports]
functions = ["my_function"]

Capability Types

Amity has two types of capabilities:

Server-level capabilities are granted by administrators via the CLI. They control what system resources the plugin can access:

Capability Description
http:register_route=<prefix> Register HTTP routes under a path prefix
http:external_request=<domains> Make HTTP requests to specific domains
http:serve_static=<prefix> Serve static files under a path prefix
websocket:channel=<name> Create/use WebSocket channels
events:emit=<prefix> Emit events with a specific prefix
events:listen=<prefix> Listen for events with a specific prefix
kv:global=<prefix> Access plugin-wide KV storage
kv:system Access system-tier KV storage
user:enumerate List all users
user:lookup Look up users by ID or name
group:enumerate List all groups
group:members List members of any group
schedule:jobs Run scheduled background jobs
http:modify_csp=<directives> Modify CSP headers
http:modify_headers=<headers> Add custom HTTP headers
templates Use the template system
navigation Register navigation menu items
navigation:admin_menu Register admin menu items
http_sig Use HTTP signatures
storage:admin Admin-level storage operations

User-level capabilities are granted by each user individually via a consent flow when they first access a feature that requires the capability:

Capability Description
storage:read=<folder> Read files from user's storage
storage:write=<folder> Write files to user's storage
storage:delete=<folder> Delete files from user's storage
kv:user Access user's KV storage
group:access=<types> Access user's groups
crypto:encrypt Encrypt data with user's key
crypto:decrypt Decrypt data with user's key
profile:read Read user's profile information
profile:write Modify user's profile information

Signing Plugins

Plugins must be cryptographically signed with Ed25519 keys.

1. Generate a Signing Keypair

./target/release/amity-plugin sign keygen -o plugin-signing

This creates:

  • plugin-signing.private - Keep this secure!
  • plugin-signing.public - Add to trusted keys

2. Add Public Key to Trusted Keys

sqlite3 amity.db "INSERT INTO trusted_signing_keys (name, public_key, is_active, created_at, instance_id) VALUES ('My Key', 'YOUR_PUBLIC_KEY_BASE64', 1, datetime('now'), 1);"

3. Sign a Plugin

# Create archive and sign in one step (signature is embedded automatically)
cd plugins/my-plugin
tar -czvf ../my-plugin.tar.gz manifest.toml init.lua templates static
cd ..
./target/release/amity-plugin sign archive -k plugin-signing.private my-plugin.tar.gz

4. Verify a Signature (Optional)

./target/release/amity-plugin sign verify -k plugin-signing.public my-plugin.tar.gz

5. Install Plugin

# Copy to plugin storage
mkdir -p storage/plugins
cp my-plugin.tar.gz storage/plugins/my-plugin-1.0.0.tar.gz

# Register in database
sqlite3 amity.db "INSERT INTO plugins (name, version, api_version, manifest_toml, archive_path, is_active, is_core, created_at, instance_id) VALUES ('my-plugin', '1.0.0', 'v1', 'MANIFEST_CONTENT', 'storage/plugins/my-plugin-1.0.0.tar.gz', 1, 0, datetime('now'), 1);"

amity-plugin Command Reference

# Initialize a new plugin
amity-plugin init <name> [options]
  -a, --author <author>        Plugin author
  -d, --description <desc>     Plugin description
  -v, --version <version>      Plugin version (default: 1.0.0)
  --lib                        Include lib/ directory for Lua modules
  -o, --output <dir>           Output directory

# Signing commands
amity-plugin sign keygen -o <prefix>           # Generate keypair
amity-plugin sign archive -k <key> <archive>   # Sign an archive
amity-plugin sign verify -k <key> <archive>    # Verify signature
amity-plugin sign show-public <private-key>    # Show public key from private

Plugin Management CLI

# List installed plugins
amity plugin list

# Install a plugin
amity plugin install my-plugin.tar.gz           # Install and enable
amity plugin install my-plugin.tar.gz --no-enable  # Install without enabling
amity plugin install my-plugin.tar.gz --update  # Update existing plugin

# Enable/disable plugins
amity plugin enable my-plugin
amity plugin disable my-plugin

# Remove a plugin
amity plugin remove my-plugin
amity plugin remove my-plugin --yes             # Skip confirmation

# Manage trusted signing keys
amity plugin key list                           # List trusted keys
amity plugin key add <name> <public-key>        # Add a trusted key
amity plugin key remove <key-id>                # Remove a trusted key

# Grant server capabilities
amity plugin grant list my-plugin               # List current grants
amity plugin grant all my-plugin                # Grant all declared capabilities
amity plugin grant http:register_route my-plugin /api/my-plugin
amity plugin grant kv:system my-plugin
amity plugin grant templates my-plugin
amity plugin grant navigation my-plugin

# Revoke capabilities
amity plugin revoke my-plugin <grant-id>        # Revoke specific grant
amity plugin revoke my-plugin --all             # Revoke all grants

When running amity plugin grant all, server-level capabilities from the manifest are automatically granted. User-level capabilities (like storage:read, kv:user) are skipped with a message indicating they are granted per-user via the consent flow.

Development

Database Migrations

Migrations are automatically applied on server startup.

LSP Support for Plugin Development

The amity-plugin init command sets up LSP support automatically. If you're working on an existing plugin, you can copy the LSP files from the Amity repository:

# Copy to your plugin directory
cp /path/to/amity/.luarc.json ./
cp -r /path/to/amity/types ./

This provides:

  • Autocompletion for all amity.* APIs
  • Type hints and documentation on hover
  • Parameter validation

Theming

Amity supports both built-in and custom themes. Users can select their preferred theme from the Profile page.

Built-in Themes

  • System - Follows the operating system's light/dark preference
  • Light - Light color scheme
  • Dark - Dark color scheme

Custom Themes

Custom themes are CSS files that override CSS custom properties. They can be uploaded via the Admin > Themes page.

Creating a Custom Theme

Create a CSS file that targets the theme class and overrides the CSS custom properties:

/* my-theme.css */
.theme-my-theme {
    /* Backgrounds */
    --color-bg-primary: #1a1a2e;
    --color-bg-secondary: #16213e;
    --color-bg-tertiary: #1f2937;

    /* Text colors */
    --color-fg-primary: #e4e4e7;
    --color-fg-secondary: #a1a1aa;
    --color-fg-muted: #71717a;

    /* Accent colors */
    --color-accent-primary: #60a5fa;
    --color-accent-primary-hover: #3b82f6;

    /* Borders */
    --border-color: #374151;

    /* Shadows */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);

    /* Header */
    --color-bg-header: #0f0f23;
    --color-text-header: #ffffff;
    --color-text-nav: #d4d4d8;
    --color-text-nav-muted: #a1a1aa;
}

Important: The class name must match the theme ID prefixed with theme-. For example, a theme with ID my-theme must use the selector .theme-my-theme.

Available CSS Custom Properties

Property Description
--color-bg-primary Main background color
--color-bg-secondary Card/panel background
--color-bg-tertiary Subtle background accent
--color-fg-primary Primary text color
--color-fg-secondary Secondary text color
--color-fg-muted Muted/disabled text
--color-accent-primary Primary accent (links, buttons)
--color-accent-primary-hover Accent hover state
--color-accent-success Success state color
--color-accent-warning Warning state color
--color-accent-danger Danger/error state color
--border-color Default border color
--shadow-sm Small shadow
--shadow-md Medium shadow
--shadow-lg Large shadow
--color-bg-header Header background
--color-text-header Header text
--color-text-nav Navigation text
--color-text-nav-muted Muted navigation text

Uploading a Custom Theme

  1. Go to Admin > Themes
  2. Fill in the theme details:
    • ID: Lowercase letters, numbers, hyphens, and underscores only (e.g., my-dark-theme)
    • Name: Display name shown to users
    • Description: Optional description
    • CSS: Paste the theme CSS content
  3. Click Upload Theme

Theme API

The theming plugin exposes a REST API:

GET  /theme/api/list        - List all available themes
GET  /theme/api/current     - Get current user's theme preference
POST /theme/api/set         - Set theme preference (JSON body: {"theme": "theme-id"})
GET  /theme/api/css/:id     - Get CSS for a custom theme

Lua Plugin API Reference

The amity global provides access to all plugin APIs.

Global Properties

amity.version        -- Server version string (e.g., "2.0.0")
amity.api_version    -- Plugin API version (e.g., "v1")

amity.log

Logging functions that output to the server log with plugin context.

amity.log.info(message)    -- Info level log
amity.log.warn(message)    -- Warning level log
amity.log.error(message)   -- Error level log
amity.log.debug(message)   -- Debug level log

amity.kv

Encrypted key-value storage with three tiers:

User-scoped storage (requires authenticated user):

-- Get/set values scoped to the current user
local value = amity.kv.get(key)
amity.kv.set(key, value)
amity.kv.del(key)
local keys = amity.kv.list(prefix)  -- List keys with optional prefix

Shared storage (all-users group):

-- Read from shared storage (requires authentication)
local value = amity.kv.get_shared(key)
-- Write to shared storage (admin only)
amity.kv.set_shared(key, value)

System storage (server-level, always available):

-- Available to all plugins, encrypted with server key
local value = amity.kv.get_system(key)
amity.kv.set_system(key, value)
amity.kv.del_system(key)
local keys = amity.kv.list_system(prefix)

amity.http

HTTP route registration and utilities.

Route Registration:

-- Register a route handler
amity.http.register(method, path, handler)

-- Example handler
function plugin.handle_request(req)
    -- req.method       - HTTP method
    -- req.path         - Request path
    -- req.params       - Path parameters (e.g., :id)
    -- req.query        - Query parameters
    -- req.headers      - Request headers
    -- req.body         - Raw body string
    -- req.json         - Parsed JSON body (table)
    -- req.form         - Parsed form data (table)
    -- req.user         - Authenticated user or nil
    --   req.user.id        - User ID
    --   req.user.username  - Username
    --   req.user.is_admin  - Admin flag

    return {
        status = 200,
        json = { message = "Hello" }
        -- Or use: body, headers, template, context, text
    }
end

amity.http.register("GET", "/api/hello", plugin.handle_request)
amity.http.register("POST", "/api/data/:id", function(req)
    local id = req.params.id
    return { status = 200, json = { id = id } }
end)

Response Types:

-- JSON response
return { status = 200, json = { key = "value" } }

-- Template response
return { status = 200, template = "my-template.html", context = { title = "Hello" } }

-- Plain text
return { status = 200, text = "Hello, World!" }

-- Raw body with headers
return { status = 200, body = "<html>...", headers = { ["Content-Type"] = "text/html" } }

Response Helpers:

amity.http.json(data)                    -- Create JSON response
amity.http.redirect(url, status?)        -- Create redirect (default 302)
amity.http.error(status, message)        -- Create error response

HTTP Fetch:

local response = amity.http.fetch(url, {
    method = "POST",
    headers = { ["Content-Type"] = "application/json" },
    body = '{"key": "value"}',
    timeout = 5000
})
-- response.status, response.headers, response.body

amity.nav

Navigation registration API.

-- Register a section (top-level navigation)
amity.nav.section(id, label, path, options?)

-- Register a tab within a section
amity.nav.tab(section_id, tab_id, label, path, options?)
-- Example: amity.nav.tab("admin", "theming", "Theming", "/admin/themes", { order = 60 })

-- Options:
-- {
--     icon = "home",
--     order = 100,
--     requires_auth = true,
--     requires_admin = false,
-- }

amity.template

Template rendering with Tera syntax.

-- Render a template file from the plugin's templates/ directory
local html = amity.template.render("my-template.html", { title = "Hello" })

-- Register an inline template
amity.template.add("inline-template", "<h1>{{ title }}</h1>")

-- Render a template string directly
local html = amity.template.render_string("<h1>{{ title }}</h1>", { title = "Hello" })

amity.time

Time utilities.

local timestamp = amity.time.now()           -- Current Unix timestamp (seconds)
local millis = amity.time.now_millis()       -- Current timestamp (milliseconds)
local iso = amity.time.iso8601()             -- Current time as ISO 8601 string
local iso = amity.time.iso8601(timestamp)    -- Convert timestamp to ISO 8601
local formatted = amity.time.format(timestamp, "%Y-%m-%d %H:%M:%S")
local ts = amity.time.parse("2024-01-15", "%Y-%m-%d")

amity.storage

Encrypted file storage API. Files are encrypted with group keys.

-- All storage operations require a group_id for encryption
local group_id = "group-uuid"

amity.storage.write(group_id, path, content)
local content = amity.storage.read(group_id, path)
amity.storage.delete(group_id, path)
local exists = amity.storage.exists(group_id, path)
local files = amity.storage.list(group_id, prefix)
local stat = amity.storage.stat(group_id, path)  -- Returns {path, size, is_dir, ...}
amity.storage.mkdir(group_id, path)
amity.storage.copy(group_id, from, to)
amity.storage.rename(group_id, from, to)

-- List available storage backends
local backends = amity.storage.backends()

amity.notify

User notification system.

amity.notify.send(user_id, {
    title = "Notification Title",
    body = "Notification body",
    type = "info",  -- "info", "success", "warning", "error"
    action_url = "/optional/link"
})

amity.notify.broadcast(group_id, notification)  -- Send to all group members
local notifications = amity.notify.get(limit, offset, unread_only)
local count = amity.notify.unread_count(channel?)
amity.notify.mark_read(notification_id)
amity.notify.mark_all_read()
amity.notify.dismiss(notification_id)

amity.events

Event bus for plugin communication.

-- Subscribe to events
amity.events.on(event_name, handler)
amity.events.once(event_name, handler)  -- One-time subscription

-- Emit events
amity.events.emit(event_name, data)

-- Example
amity.events.on("user:login", function(data)
    amity.log.info("User logged in: " .. data.user_id)
end)

amity.schedule

Scheduled task execution.

-- Run every N seconds
local job_id = amity.schedule.every(60, function()
    amity.log.info("Running every minute")
end)

-- Run on a cron schedule
local job_id = amity.schedule.cron("0 0 * * *", function()
    amity.log.info("Running daily at midnight")
end)

-- Run once after delay
local job_id = amity.schedule.once(300, function()
    amity.log.info("Running once after 5 minutes")
end)

-- Cancel a scheduled job
amity.schedule.cancel(job_id)

amity.capability

Plugin capability checking.

local has = amity.capability.has("http:register_route")
local granted = amity.capability.request("storage:write")

amity.websocket

WebSocket support.

amity.websocket.register("/ws/my-endpoint", {
    on_connect = function(conn_id) end,
    on_message = function(conn_id, message) end,
    on_close = function(conn_id) end
})

amity.websocket.send(conn_id, message)
amity.websocket.close(conn_id)

amity.users

User management API (requires capabilities).

-- Get current authenticated user (always available)
local me = amity.users.current()
-- Returns: { id, username, display_name, is_admin }

-- Get user by ID
local user = amity.users.get(user_id)

-- Get user by username (requires user:lookup capability)
local user = amity.users.get_by_username("alice")

-- List all users (requires user:enumerate capability)
local users = amity.users.list({ limit = 50, offset = 0 })

-- Search users by username (requires user:enumerate capability)
local users = amity.users.search("ali")

-- List user's groups (requires group:access user capability)
local groups = amity.users.groups(user_id, { type = "share" })

-- List members of a group (requires group:members server capability)
local members = amity.users.group_members(group_id)

amity.http_sig

HTTP signature support for federation protocols.

-- Sign a request using Cavage draft (Mastodon/Fediverse compatibility)
local headers = amity.http_sig.sign_cavage("POST", "https://remote.server/inbox", body)
-- Returns: { Signature, Date, Digest }

-- Sign a request using RFC 9421 (OCM/modern protocols)
local headers = amity.http_sig.sign_rfc9421("POST", "https://remote.server/shares", "application/json", body)

-- Verify a signature from an incoming request
local valid = amity.http_sig.verify(public_key_pem, key_id, method, path, headers, body)

-- Get this server's public key for federation
local pem = amity.http_sig.get_public_key_pem()
local info = amity.http_sig.get_key_info()  -- { key_id, algorithm }

amity.mls

MLS group encryption for secure data sharing.

-- Create a new encrypted group
amity.mls.create_group(group_id, "share")  -- Types: share, chat, team

-- Encrypt/decrypt data with group key
local ciphertext = amity.mls.encrypt(group_id, plaintext)
local plaintext = amity.mls.decrypt(group_id, ciphertext)

-- Group membership
local is_member = amity.mls.is_member(group_id)
local role = amity.mls.get_role(group_id)  -- "admin", "member", etc.
local members = amity.mls.list_members(group_id)

-- Manage members
amity.mls.invite_member(group_id, user_id, "member")
amity.mls.accept_invitation(group_id)
local invites = amity.mls.pending_invitations()
amity.mls.remove_member(group_id, user_id)

amity.export / amity.import

Inter-plugin function sharing.

-- In plugin A: export a function
amity.export("my_function", function(arg)
    return "result: " .. arg
end)

-- In plugin B: import and call
local fn = amity.import("plugin_a", "my_function")
if fn then
    local result = fn("test")
end

Plugin Lifecycle

plugin = {}

function plugin.init()
    -- Called when plugin is loaded
    amity.log.info("Plugin initialized")

    -- Register routes, navigation, etc.
    if amity.http then
        plugin.register_routes()
    end
end

function plugin.register_routes()
    amity.http.register("GET", "/my-plugin", plugin.handle_index)
end

function plugin.enable()
    -- Called when plugin is enabled
end

function plugin.disable()
    -- Called when plugin is disabled
end

function plugin.unload()
    -- Called when plugin is unloaded
end

return plugin

Architecture

  • src/ - Main application source
    • bin/ - Binary entry points (amity, amity-plugin)
    • db/ - Database models
    • plugin/ - Plugin loading, sandbox, and APIs
    • web/ - HTTP routes and templates
    • auth/ - Authentication services
  • plugins/ - Core plugin source files
  • storage/plugins/ - Installed plugin archives
  • templates/ - Core HTML templates
  • migration/ - SeaORM database migrations
  • types/ - Lua type definitions for LSP support