Copy architecture docs, ADRs, storage domain specs, research, reviews, and 56 storage architecture tasks from the alkhub_ts monorepo. Adapt for standalone @alkdev/hub repo structure (src/ not packages/hub/). Sanitize all sensitive information: - Replace private IPs (10.0.0.1) with localhost defaults - Remove internal server hostnames (dev1, ns528096) - Replace /workspace/ private paths with npm package references - Remove hardcoded credentials from examples - Rewrite infrastructure.md without private network details Add Deno project scaffolding: deno.json (pinned deps), .gitignore, AGENTS.md, entry point. Migrate existing code stubs (crypto, config types, logger) with updated import paths.
14 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-04-20 |
Table Schemas: Identity & Auth
Account, organization, and authentication tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see table-reference.md. For design decisions, see ../../../decisions/. For the account-role-session model, see ../../agent-roles.md.
accounts
Hub-local identity records. These are NOT Gitea users — they're identities in our system. They can be linked to Gitea accounts but aren't required to be. This table is the FK target for api_keys.ownerId, audit_logs.ownerId, clients.ownerId, organizations.ownerId, and sessions.accountId.
Accounts serve as the identity layer for both humans and LLMs. An LLM that creates sessions, makes commits, or owns API keys needs its own account (typically with accessLevel: "service"). See ADR-012 for the terminology rationale.
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| text NOT NULL UNIQUE | Unique identifier. System/service accounts MAY use a deployment-configured reserved email pattern (e.g., {model}@system.example.com). The reserved pattern is a deployment concern — no specific domain is hardcoded. See D6 in storage-spec-phase1-resolutions.md. |
|
| displayName | text | Display name |
| accessLevel | text NOT NULL DEFAULT user |
admin, user, service |
| status | text NOT NULL DEFAULT 'active' | Enum: active, suspended, deactivated. See D5 in storage-spec-phase1-resolutions.md. |
| giteaUsername | text | Link to Gitea account (nullable — service/LLM accounts may or may not have one) |
| data | jsonb | Account metadata (preferences, avatar URL, etc.) |
data boundaries: Account preferences and profile metadata. Authentication credentials never go here — API keys are in api_keys, secrets are in client_secrets.
Indexes: unq_accounts_email UNIQUE on (email), idx_accounts_gitea_username on (giteaUsername), idx_accounts_display_name on (displayName) — user search/autocomplete UIs.
accessLevel semantics (renamed from role to avoid confusion with behavioral roles — see ADR-012):
admin: Can manage all resources across organizationsuser: Can manage own resources and resources in organizations they belong toservice: Automated accounts — LLM workers, spoke credentials, CI tokens. No Gitea link required.
Account lifecycle: Deactivated accounts cannot authenticate. Suspended accounts are admin-locked (e.g., security hold). Deactivated is user-initiated shutdown. Suspended/deactivated accounts can still own organizations (RESTRICT FK) and have audit entries (RESTRICT FK) but cannot authenticate.
System account email convention: Deployments may configure a reserved email domain or pattern for system-generated accounts (LLMs, bots, services). This prevents collision between human and system accounts and enables attribution in git commits and audit logs. The specific pattern is deployment-specific and should not be hardcoded in architecture documentation.
LLM accounts: An LLM worker account (e.g., with a deployment-configured system email) has accessLevel: "service". It owns sessions, API keys, and audit trail entries. The LLM fills a role (defined in the roles table or .opencode/agents/*.md) for the duration of a session. The account provides identity and accountability; the role provides behavioral constraints and permissions.
Authorization rules for accessLevel: Only admin accounts can change another account's accessLevel. Accounts cannot self-promote. service accounts cannot change accessLevel at all. user accounts cannot change accessLevel of any account. The hub operations hub.account.updateAccessLevel and hub.account.create enforce these rules at the application layer. For the admin/user/service terminology distinction (renamed from role), see ADR-012.
organizations
Top-level grouping for multi-tenancy. Organizations own projects, can scope clients, and group members. Minimal — just name + ownership. Gitea integration bridges via giteaOrgName.
ownerId semantics: This is the administrative/transferable owner of the organization. It MUST be an account that is also a member with membershipLevel: 'owner' (enforced by app logic). If the owner account needs to be changed, org.transferOwnership must be called first. RESTRICT FK prevents deleting the owner account.
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| name | text NOT NULL UNIQUE | Organization name |
| slug | text NOT NULL UNIQUE | URL-friendly identifier |
| giteaOrgName | text | Link to Gitea organization (nullable — some orgs are hub-only) |
| ownerId | text NOT NULL | FK → accounts.id — Administrative/transferable owner of the org. RESTRICT cascade prevents deleting the owner account while the org exists. |
| data | jsonb | Org metadata (billing, settings) |
Indexes: unq_organizations_name UNIQUE on (name), unq_organizations_slug UNIQUE on (slug), idx_organizations_owner_id on (ownerId), idx_organizations_gitea_org_name on (giteaOrgName).
organization_members
Who belongs to which org. Simple membership + level.
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| orgId | text NOT NULL | FK → organizations.id (cascade) |
| accountId | text NOT NULL | FK → accounts.id (cascade) |
| membershipLevel | text NOT NULL | owner, admin, member |
Unique constraint: (org_id, account_id) — one membership per account per org.
Indexes: unq_org_members_org_account UNIQUE on (orgId, accountId), idx_org_members_account_id on (accountId), idx_org_members_org_id on (orgId) — find members of an org.
membershipLevel semantics (renamed from role to avoid confusion with behavioral roles — see ADR-012): owner has full control including billing and member management. admin can manage projects and members. member can access org resources.
membershipLevel is runtime access control, separate from ownerId: membershipLevel: 'owner' grants elevated permissions within the org. This is distinct from organizations.ownerId, which is the administrative/transferable owner. The invariant is: organizations.ownerId always references an account that also has membershipLevel: 'owner' in organization_members.
Org Ownership Transfer
When an account that owns an organization needs to be removed, the organization's ownership must be transferred first (because organizations.ownerId → accounts.id has RESTRICT cascade).
The org.transferOwnership operation:
- Validates that the new owner is an account with
membershipLevel: 'owner'in the organization - Updates
organizations.ownerIdto the new owner - Optionally demotes the old owner's
membershipLevelto 'admin' or 'member'
Precondition: organizations.ownerId must always reference a member with membershipLevel: 'owner'. Transfer must happen before account deactivation or deletion of the current owner.
Error cases: If the organization has no other members with membershipLevel: 'owner', the transfer requires promoting a member first.
api_keys
API keys for hub authentication. Uses keypal (v0.1.11) for key generation, hashing, verification, and scope management. The table follows our commonCols pattern but with proper columns for high-query fields instead of keypal's default JSONB-only approach.
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| ownerId | text NOT NULL | FK → accounts.id — Key owner (maps to keypal's ownerId) |
| keyHash | text NOT NULL | SHA-256 hash of the raw key (never stores raw key) |
| name | text | Human-readable key label |
| description | text | Key purpose description |
| enabled | boolean NOT NULL DEFAULT true | Disable without revoking |
| expiresAt | timestamp with tz | When the key expires (null = never) |
| revokedAt | timestamp with tz | When the key was revoked (null = active) |
| rotatedToId | text | ID of the key this was rotated to |
| lastUsedAt | timestamp with tz | Last time the key was used to authenticate |
Indexes: idx_api_keys_owner_id on (ownerId), unq_api_keys_key_hash UNIQUE on (keyHash), idx_api_keys_enabled on (enabled) — filter enabled/disabled keys, idx_api_keys_active partial on (ownerId) WHERE revoked_at IS NULL AND enabled = true — efficiently find active keys. Note: idx_api_keys_key_hash is not listed separately because unq_api_keys_key_hash UNIQUE constraint auto-creates an index covering the same column.
Keypal integration: We implement keypal's Storage interface as a thin adapter (HubKeyStorage) that reads/writes this table. The metadata JSONB column (from commonCols) stores keypal's scope data:
metadata.scopes:string[]— global permission scopesmetadata.resources:Record<string, string[]>— resource-scoped permissions (key format:"type:id")metadata.tags:string[]— filtering tags (lowercased)
This gives us proper SQL indexing on owner_id, key_hash, enabled, expires_at, revoked_at while keeping the flexible scope model in metadata.
SHA-256 trade-off: API keys are hashed with SHA-256, not a slow KDF (bcrypt, Argon2). This is acceptable because API keys are high-entropy machine-generated strings (128-bit+), making brute-force infeasible even with a fast hash. Human passwords require slow hashes; machine keys do not. This provides O(1) verification latency at high throughput. See ADR-010.
Expiration and revocation behavior:
expiresAtis nullable — null means the key never expires. When present, the key is rejected afterexpiresAt. Theenabledfield is a separate kill switch (immediate disable regardless of expiration). A key can be: enabled+not expired (active), enabled+expired (rejected), disabled (rejected regardless of expiration).revokedAtis set whenkeypal.revoke()is called. Revoked keys are permanently disabled regardless of enabled/expiry status.- Error responses: Expired, disabled, and revoked keys all return a generic authentication failure — not a specific reason — to avoid information disclosure to attackers.
Key lifecycle:
- Create:
keys.create({ ... })→ generates raw key, hashes it, stores hash inkey_hash, returns{ key, record } - Verify:
keys.verify(token)→ hashes the token, looks up bykey_hash, checksenabled/revoked_at/expiresAt - Revoke:
keys.revoke(id)→ setsrevoked_atto now (soft delete) - Rotate:
keys.rotate(id)→ creates new key, setsrotated_to_idon old key - Scope check:
keys.hasScope(record, scope)orkeys.checkResourceScope(record, type, id, scope)
Caching: Use keypal's RedisCache with our existing Redis instance for key verification caching. Cache stores only the slim CacheRecord (id, expiresAt, revokedAt, enabled), not full metadata.
ownerId semantics: api_keys.ownerId is a FK to accounts.id. The account may be a user, admin, or service account. Service accounts (e.g., a spoke that needs its own API key) get an accounts row with accessLevel: "service". This replaces the previous opaque string model with proper referential integrity.
audit_logs
Audit trail for API key operations and security-relevant hub events.
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| action | text NOT NULL | Action type: created, revoked, rotated, enabled, disabled, login, access_denied |
| keyId | text | FK → api_keys.id (nullable — not all audit events are key-related) |
| ownerId | text NOT NULL | FK → accounts.id — The identity that performed the action. RESTRICT cascade — accounts with audit entries cannot be hard-deleted; use account deactivation (status column) instead. |
| sessionId | text | FK → sessions.id — The session in which the action occurred. Nullable — not all actions happen in a session context. onDelete: SET NULL |
| orgId | text | FK → organizations.id — The organization context for the action. Nullable — personal actions aren't org-scoped. onDelete: SET NULL |
| details | jsonb | Action-specific context (IP, user agent, scope changes, etc.) |
Indexes: idx_audit_logs_owner_id on (ownerId), idx_audit_logs_key_id on (keyId), idx_audit_logs_action on (action), idx_audit_logs_created_at on (createdAt), idx_audit_logs_session_id on (sessionId), idx_audit_logs_org_id on (orgId).
Session and org context enable filtering audit logs by session (e.g., "what did this agent session do?") and organization (e.g., "show me all actions in this org").
Keypal integration: keypal's optional audit log methods (saveLog, findLogs, countLogs) are implemented on HubKeyStorage to write to this table. Hub-native audit events (login, access denied) also write here.
action enum is extensible: The initial set of action types (created, revoked, rotated, enabled, disabled, login, access_denied) covers keypal key operations and basic auth events. Additional actions for account, membership, and organization lifecycle events (e.g., account_created, membership_added, org_created) should be added as those features are implemented. New action types must be documented here and in table-reference.md.