Spec: Guests and Project Grants

Purpose

This spec defines the guest principal and guest project grants system. It allows operators to grant restricted access to specific project workflows and capabilities to external collaborators, clients, or contractors without adding them to the operator's primary identity provider. Guests are system-level identities with daemon-managed credentials, and their access is scoped per project using permission sets stored in the daemon's database.

This document is normative for:

  • The SQLite database schema for guests, invites, sessions, and grants.
  • The guest credential format, password rules, and argon2id hashing parameters.
  • The invite generation, distribution, and consumption flow.
  • The guest session lifecycle, cookie attributes, and validation.
  • The rate limiting, lockout, and password recovery mechanisms.
  • The project guest grant structure, permission set schema, and composition with the project DSL.
  • The audit logging events for guest and grant lifecycles.
  • The Zod schemas and TypeScript types representing these entities.

It is not normative for:

  • The operator auth sidecar or loopback nonce validation (that's daemon.md and http-api.md).
  • The detailed HTTP API request and response payloads (that's http-api.md).
  • The guest UI layout and design (that's ui/README.md and brand-guide.md).

Constraints (from ADRs)

Constraint Source
Default storage is SQLite at a file path; Postgres opt-in via URL ADR-0005
Auth identity threads through sessions for audit and multi-operator ADR-0007
Two deployment modes: per-user and system-wide, both first-class ADR-0010
Guests are first-class identities with daemon-managed credentials ADR-0017
Guest access to projects is granted via per-project permission sets, not project DSL ADR-0018

Database Schema

The guest and grant systems are backed by four SQLite tables. These tables are managed by the @kaged/storage package.

-- System-level guest accounts
CREATE TABLE IF NOT EXISTS guests (
    user_id TEXT PRIMARY KEY,
    handle TEXT UNIQUE NOT NULL,
    display_name TEXT,
    password_hash TEXT,
    status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'disabled')),
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

-- Outstanding one-time invite tokens
CREATE TABLE IF NOT EXISTS guest_invites (
    token TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    created_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES guests (user_id) ON DELETE CASCADE
);

-- Active guest sessions
CREATE TABLE IF NOT EXISTS guest_sessions (
    session_id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    created_at TEXT NOT NULL,
    last_active_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES guests (user_id) ON DELETE CASCADE
);

-- Project-scoped guest access grants
CREATE TABLE IF NOT EXISTS project_guest_grants (
    project_id TEXT NOT NULL,
    user_id TEXT NOT NULL,
    permission_set TEXT NOT NULL,
    notes TEXT,
    granted_at TEXT NOT NULL,
    granted_by TEXT NOT NULL,
    last_modified_at TEXT NOT NULL,
    PRIMARY KEY (project_id, user_id),
    FOREIGN KEY (user_id) REFERENCES guests (user_id) ON DELETE CASCADE
);

Guest Principals and Credentials

User ID Format

Every guest is assigned a unique, immutable user ID with the prefix guest:, followed by a ULID (e.g., guest:01H7X2Z3...). This prefix allows the daemon to cheaply distinguish guest principals from operator principals in audit logs, request handlers, and policy checks. The user ID remains stable even if the guest's handle is updated.

Handle and Display Name

  • Handle: A unique, URL-friendly identifier set by the operator during creation. It must match the pattern ^[a-z0-9_-]{3,32}$.
  • Display Name: An optional, human-readable name (e.g., "Cara McGee") used for display purposes in the UI.

Password Hashing and Rules

Guest credentials are managed directly by the daemon. Passwords are hashed using the Argon2id algorithm via Bun's native hashing API (Bun.password.hash with explicit algorithm options). The Argon2id parameters are:

  • Memory cost: 64 MB (m=65536)
  • Time cost: 3 iterations (t=3)
  • Parallelism: 1 (p=1)

Passwords must be at least 8 characters long. No complex character rules are enforced to minimize friction for guests, but operators are encouraged to guide guests toward strong passphrases.

Interaction with --insecure Mode

When the daemon is run with the --insecure flag, operator authentication is bypassed to allow frictionless local access. However, guest authentication is still strictly enforced. The guest login and setup endpoints continue to validate passwords and tokens. This ensures that guest access remains secure even if the operator chooses to run the daemon in an insecure mode on a trusted local network.

Invite and Password Management

Invite Flow

Initial credential provisioning is entirely operator-driven. Outbound channels like email, SMS, or webhooks are not supported by the daemon. The invite flow operates as follows:

  1. The operator creates a guest record via the operator UI or API. This record is initialized with a status of pending and a null password_hash.
  2. The daemon generates a cryptographically secure, one-time invite token (a 32-byte hex-encoded random string).
  3. The daemon saves the token in the guest_invites table and returns the setup URL to the operator: {ui_origin}/g/setup?token={token}
  4. The operator copies this URL and distributes it to the guest out-of-band (e.g., via email, chat, or SMS).
  5. The guest visits the setup URL, enters a password, and submits the form.
  6. The daemon validates the token, hashes the password, updates the guest's status to active, and deletes the invite token from the guest_invites table.

Invite Token TTL

Invite tokens have a default Time-To-Live (TTL) of 7 days. Operators can override this default during creation. Expired tokens are reaped daily by a background task in the daemon.

Password Recovery (Reinvite)

There is no self-service password reset mechanism. If a guest forgets their password, they must contact the operator. The operator triggers a reinvite via POST /api/v1/guests/:user_id/reinvite. This action:

  1. Invalidates and deletes all active sessions for the guest in guest_sessions.
  2. Sets the guest's status back to pending and clears the password_hash.
  3. Generates a new one-time invite token and returns the setup URL to the operator.

Password Change

An authenticated guest can change their password while logged in. To do this, the guest submits their current password and a new password to POST /api/v1/g/account/password. The daemon verifies the current password, hashes the new password, updates the database, and invalidates all other active sessions for that guest.

Session Management

Guest Session Cookie

Guest sessions are tracked using a dedicated cookie named kaged_guest_session. This cookie is completely separate from the operator's kaged_session cookie. A browser holding both cookies is treated as two distinct principals by the daemon.

The kaged_guest_session cookie is configured with the following security attributes:

  • HttpOnly: Prevents client-side scripts from accessing the cookie.
  • SameSite=Lax: Provides protection against Cross-Site Request Forgery (CSRF) while allowing standard navigation.
  • Secure: Enforced when the daemon is served over HTTPS.

Session Validation

Every guest request is validated against the guest_sessions table in SQLite. The session ID is a cryptographically secure random string. When a guest logs in, a session record is created with an expiration timestamp (defaulting to 30 days). The daemon updates the last_active_at timestamp on every valid request. If a session is revoked by the operator or deleted due to a password reset, the next request from that guest will fail authentication.

Rate Limiting and Lockout

To protect against brute-force attacks, the daemon implements two layers of rate limiting for guest logins.

Per-Account Lockout

  • Trigger: 5 failed login attempts within a rolling 15-minute window.
  • Consequence: The guest account status is set to locked (or internally flagged), preventing any login attempts for 30 minutes.
  • Operator Override: The operator can unlock the account immediately by sending a request to POST /api/v1/guests/:user_id/unlock.

Per-IP Rate Limiting

  • Trigger: 30 failed login attempts within a rolling 15-minute window from a single IP address.
  • Consequence: The daemon returns a 429 Too Many Requests response for all guest login attempts from that IP address for the next 5 minutes.
  • Persistence: IP-level rate limit state is kept in memory and is not persisted across daemon restarts.

Project Guest Grants

Guest access to projects is governed by the project_guest_grants table. Grants are operator-local relationships and do not live in the portable project DSL.

Permission Set Schema

The permission_set column stores a JSON blob that defines the guest's capabilities within a specific project. This schema is additive and Zod-validated.

{
  "workflows": ["testimonial.add", "blog.draft"],
  "issues": {
    "file": true,
    "view_own": true,
    "view_all": false,
    "comment_own": true
  },
  "session": {
    "view_own_history": true
  }
}

The fields are defined as follows:

  • workflows: An array of strings representing the names of workflows the guest is allowed to invoke.
  • issues.file: A boolean indicating whether the guest can create new issues in the project.
  • issues.view_own: A boolean indicating whether the guest can view issues they created.
  • issues.view_all: A boolean indicating whether the guest can view all issues in the project.
  • issues.comment_own: A boolean indicating whether the guest can comment on issues they created.
  • session.view_own_history: A boolean indicating whether the guest can view their own past chat session history.

Grant CRUD and Revocation

  • Operator-Only: Only authenticated operators can create, read, update, or delete grants. Guests have no ability to modify grants.
  • Revocation: Revoking a grant is performed by deleting the grant row from the database (DELETE /api/v1/projects/:id/guests/:user_id). Once deleted, the guest's access to the project is immediately terminated. Active guest sessions are not destroyed, but any subsequent project-scoped requests will be rejected.

Composition with the Project DSL

The workflows array in the permission set references workflow names declared in the project's DSL workflows: block. Because the DSL is portable and can change independently of the daemon's database, the daemon handles stale references gracefully:

  • Read Time: When the operator views a grant in the UI, any workflow names in the grant that are no longer present in the resolved DSL are flagged with a "no longer in DSL" indicator. The grant is not automatically modified.
  • Invocation Time: If a guest attempts to invoke a workflow that has been removed from the DSL, the daemon rejects the request with a workflow_not_found error.
  • Project Load Time: When a project is loaded or reloaded, the daemon logs a warning for any stale workflow references in existing grants but does not prevent the project from loading.

Audit Events

Every guest and grant lifecycle action is recorded in the daemon's audit log. Each event is tagged with the performing principal's user ID (either the operator's ID or the guest's guest:<ulid>).

Guest Lifecycle Events

  • guest.created: The operator created a new guest record.
  • guest.invited: A new one-time invite token was generated. The audit log records the token's prefix for correlation, never the full token.
  • guest.activated: The guest completed setup and set their initial password.
  • guest.login: A guest successfully logged in.
  • guest.login_failure: A failed login attempt occurred.
  • guest.locked: A guest account was locked due to repeated login failures.
  • guest.password_changed: A guest successfully changed their password.
  • guest.deactivated: The operator disabled a guest account.

Grant Lifecycle Events

  • grant.created: The operator granted a guest access to a project.
  • grant.modified: The operator updated a guest's permission set for a project.
  • grant.revoked: The operator revoked a guest's access to a project.

API Endpoint Summary

This section provides a high-level summary of the HTTP endpoints introduced for the guest and grant systems. Detailed request and response schemas are defined in http-api.md.

Operator-Only Guest Management (/api/v1/guests/*)

These endpoints require operator authentication.

  • GET /api/v1/guests: Lists all guest accounts in the system.
  • POST /api/v1/guests: Creates a new guest account and returns the initial invite URL.
  • GET /api/v1/guests/:user_id: Retrieves details for a specific guest.
  • PATCH /api/v1/guests/:user_id: Updates a guest's handle, display name, or status.
  • DELETE /api/v1/guests/:user_id: Deletes a guest account, cascading deletion to invites, sessions, and grants.
  • POST /api/v1/guests/:user_id/reinvite: Invalidates active sessions and generates a new invite URL.
  • POST /api/v1/guests/:user_id/unlock: Unlocks a locked guest account.
  • GET /api/v1/guests/:user_id/grants: Lists all project grants for a guest, enriched with project metadata.
  • GET /api/v1/guests/:user_id/activity: Returns a chronological activity feed for a guest, optionally filtered by project.

Operator-Only Guest Activity (/api/v1/guests/:user_id/*)

These endpoints require operator authentication and provide a per-guest view of project memberships and activity.

GET /api/v1/guests/:user_id/grants

Returns all project guest grants for the given guest, enriched with project metadata (label, path). Used by the system guest profile page to render the "Projects" section.

Query parameters: none.

Response (200):

{
  "items": [
    {
      "project_id": "...",
      "project_label": "my-project",
      "project_path": "/home/operator/my-project",
      "permission_set": { ... },
      "notes": "...",
      "granted_at": 1717891200000,
      "granted_by": "operator",
      "last_modified_at": 1717891200000
    }
  ]
}

Each item is an OperatorGrantSummary augmented with project_label and project_path resolved from the daemon's project registry. If a project in the grant no longer exists (stale reference), project_label and project_path are null.

GET /api/v1/guests/:user_id/activity

Returns a chronological activity feed for the given guest. This is the single unified feed API that powers both the system guest profile (unfiltered) and the project guest view (filtered by project).

Query parameters:

Parameter Type Required Default Description
project_id string no Filter to a single project. Omit for cross-project feed.
cursor string no Pagination cursor from previous response.
limit integer no 25 Max items per page (1–100).

Activity item kinds:

The feed is a merged, reverse-chronological timeline of multiple event sources:

  1. issue_opened — The guest created an issue. Source: issues table where created_by = user_id.

    {
      "id": "act_<ulid>",
      "kind": "issue_opened",
      "project_id": "...",
      "issue_id": "...",
      "issue_number": 42,
      "issue_title": "Bug in login flow",
      "issue_status": "open",
      "created_at": 1717891200000
    }
    
  2. issue_comment — The guest posted a comment on an issue. Source: issue_updates table where author = user_id AND kind = 'comment'.

    {
      "id": "act_<ulid>",
      "kind": "issue_comment",
      "project_id": "...",
      "issue_id": "...",
      "issue_number": 42,
      "issue_title": "Bug in login flow",
      "body": "Still happening in production",
      "created_at": 1717891300000
    }
    
  3. grant_created — The guest was granted access to a project. Source: project_guest_grants table.

    {
      "id": "act_<ulid>",
      "kind": "grant_created",
      "project_id": "...",
      "project_label": "my-project",
      "granted_by": "operator",
      "created_at": 1717891200000
    }
    
  4. grant_modified — The guest's permissions were changed on a project. Derived from last_modified_at differing from granted_at in project_guest_grants.

    {
      "id": "act_<ulid>",
      "kind": "grant_modified",
      "project_id": "...",
      "project_label": "my-project",
      "created_at": 1717895400000
    }
    

Response (200):

{
  "items": [ ... ],
  "next_cursor": "...",
  "has_more": true
}

Items are sorted by created_at descending (newest first). The id field uses the prefix act_ followed by a ULID to namespace activity items from their source records.

Non-normative note: Future amendments may add session_login, issue_status_change, and issue_assignment_change kinds. The initial implementation covers the four kinds above as the minimum viable feed.

Operator-Only Grant Management (/api/v1/projects/:id/guests/*)

These endpoints require operator authentication and are scoped to a specific project.

  • GET /api/v1/projects/:id/guests: Lists all guest grants for the project.
  • POST /api/v1/projects/:id/guests: Creates a new grant for a guest on the project.
  • PUT /api/v1/projects/:id/guests/:user_id: Replaces the permission set for an existing grant.
  • DELETE /api/v1/projects/:id/guests/:user_id: Revokes a guest's grant (deletes the row).

Guest-Facing Endpoints (/api/v1/g/*)

These endpoints are guest-facing and do not require operator authentication.

  • GET /api/v1/g/setup/validate?token={token}: Validates an invite token without consuming it. Returns { valid: boolean, handle: string | null }. Returns valid: false for unknown, expired, or orphaned invites (never leaks which); always responds 200. Used by the guest setup screen to pre-flight the link before showing the password form.
  • POST /api/v1/g/setup: Consumes an invite token and sets the guest's password.
  • GET /api/v1/g/me: Returns the currently authenticated guest's identity (user_id, handle, display_name, status). Authenticated via the kaged_guest_session cookie. Returns 401 unauthenticated if the cookie is missing, invalid, or expired; 403 forbidden if the guest account is disabled. The guest shell calls this on every page load as the guest-side auth gate, mirroring the operator-side /api/v1/me.
  • POST /api/v1/g/login: Authenticates a guest and sets the kaged_guest_session cookie.
  • POST /api/v1/g/logout: Invalidates the current guest session and clears the cookie.
  • POST /api/v1/g/account/password: Changes the password for the currently authenticated guest.
  • GET /api/v1/g/projects: Lists all projects the authenticated guest has grants for.
  • GET /api/v1/g/projects/:id: Retrieves project details (exposed workflows and issue capabilities) for a granted project.

Type Definitions

The following Zod schemas and TypeScript types define the data structures for the guest and grant systems.

import { z } from "zod";

// Permission Set Schema
export const PermissionSetSchema = z.object({
  workflows: z.array(z.string()),
  issues: z.object({
    file: z.boolean(),
    view_own: z.boolean(),
    view_all: z.boolean(),
    comment_own: z.boolean(),
  }),
  session: z.object({
    view_own_history: z.boolean(),
  }),
});

export type PermissionSet = z.infer<typeof PermissionSetSchema>;

// Guest Record Schema
export const GuestRecordSchema = z.object({
  user_id: z.string().regex(/^guest:[0-9A-HJKMNP-TV-Z]{26}$/),
  handle: z.string().regex(/^[a-z0-9_-]{3,32}$/),
  display_name: z.string().optional(),
  password_hash: z.string().nullable(),
  status: z.enum(["pending", "active", "disabled"]),
  created_at: z.string().datetime(),
  updated_at: z.string().datetime(),
});

export type GuestRecord = z.infer<typeof GuestRecordSchema>;

// Guest Invite Schema
export const GuestInviteSchema = z.object({
  token: z.string(),
  user_id: z.string().regex(/^guest:[0-9A-HJKMNP-TV-Z]{26}$/),
  expires_at: z.string().datetime(),
  created_at: z.string().datetime(),
});

export type GuestInvite = z.infer<typeof GuestInviteSchema>;

// Guest Session Schema
export const GuestSessionSchema = z.object({
  session_id: z.string(),
  user_id: z.string().regex(/^guest:[0-9A-HJKMNP-TV-Z]{26}$/),
  expires_at: z.string().datetime(),
  created_at: z.string().datetime(),
  last_active_at: z.string().datetime(),
});

export type GuestSession = z.infer<typeof GuestSessionSchema>;

// Project Guest Grant Schema
export const ProjectGuestGrantSchema = z.object({
  project_id: z.string(),
  user_id: z.string().regex(/^guest:[0-9A-HJKMNP-TV-Z]{26}$/),
  permission_set: PermissionSetSchema,
  notes: z.string().optional(),
  granted_at: z.string().datetime(),
  granted_by: z.string(),
  last_modified_at: z.string().datetime(),
});

export type ProjectGuestGrant = z.infer<typeof ProjectGuestGrantSchema>;

Failure Modes

Database Constraint Violations

  • Duplicate Handles: If an operator attempts to create or update a guest with a handle that already exists, the database will throw a unique constraint violation. The daemon catches this and returns a 409 Conflict response with a clear error message.
  • Foreign Key Failures: Deleting a guest record cascades to delete all associated invites, sessions, and grants. If the cascade fails due to a database lock, the transaction is rolled back, and the deletion is retried.

Argon2id Resource Exhaustion

Argon2id is computationally expensive. If multiple guests attempt to log in or set up passwords simultaneously, it could lead to CPU or memory exhaustion. The daemon mitigates this by limiting the number of concurrent password hashing operations using a simple queue. If the queue is full, subsequent requests receive a 503 Service Unavailable response.

Stale Workflow References

If a workflow is removed from the project DSL but remains in a guest's grant, the guest will receive a 404 Not Found error when attempting to invoke it. The UI flags these stale references so the operator can clean them up.

Token Expiration and Reaping

If the background reaping task fails to run, expired invite tokens will remain in the database. This does not pose a security risk because the daemon validates the expiration timestamp on every token consumption attempt. The reaping task logs any failures to the operational log for operator visibility.

Testing Notes

Unit Tests

  • Zod Schemas: Verify that the Zod schemas correctly validate and parse valid objects and reject invalid shapes (e.g., invalid handles, malformed user IDs, incorrect permission set structures).
  • Password Hashing: Test that password hashing and verification work correctly using the specified Argon2id parameters.

Integration Tests

  • Invite and Setup Flow: Test the complete lifecycle of an invite: creating a guest, generating a token, consuming the token to set a password, and verifying that the token is deleted and the guest status changes to active.
  • Authentication and Session Lifecycle: Verify that logging in sets the kaged_guest_session cookie, that requests with a valid cookie are authorized, and that logging out invalidates the session in the database and clears the cookie.
  • Rate Limiting and Lockout: Test that 5 failed login attempts lock the account for 30 minutes, that the operator can unlock it, and that 30 failed attempts from one IP trigger a 429 response.
  • Grant Scoping: Verify that a guest can only access projects they have a grant for, and that their capabilities are strictly limited to the permission set.
  • Stale Reference Handling: Test that removing a workflow from the DSL results in a warning at project load time and a workflow_not_found error at guest invocation time.
  • Cascade Deletion: Verify that deleting a guest record successfully deletes all associated invites, sessions, and grants.

Open Questions

  1. Invite URL TTL Default: Is 7 days the optimal default TTL for invite tokens? Some operators may prefer a shorter window (e.g., 24 hours) for tighter security, while others may want a longer window. The spec allows operators to override this, but the default may need tuning based on real-world usage.
  2. Display Name Requirement: Should the display name be strictly optional, or should the daemon auto-populate it with the handle if left blank? Currently, it is optional, but auto-populating it could simplify UI rendering.
  3. Global Brute-Force Ceiling: Should we implement a global rate limit on guest login attempts (across all accounts and IPs) to protect the daemon from distributed brute-force attacks that could exhaust CPU resources via Argon2id hashing?

References


Amendments

2026-06-08 — Guest activity feed and grant listing endpoints

Added two operator-only endpoints under /api/v1/guests/:user_id/ to support the system guest profile page and project-scoped guest views.

  1. GET /api/v1/guests/:user_id/grants: Returns all project guest grants for a guest, enriched with project label and path. Powers the "Projects" section of the guest profile page. Stale grants (project no longer exists) surface with null project metadata.
  2. GET /api/v1/guests/:user_id/activity: Unified chronological activity feed for a guest. Accepts optional project_id filter so the same API powers both the system profile (unfiltered) and project-scoped views. Initial activity kinds: issue_opened, issue_comment, grant_created, grant_modified. Future amendments may add session_login, issue_status_change, issue_assignment_change.-argon2