From 814d615956eb052b2861ff5279a30a0685bbae09 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Tue, 28 Jul 2026 18:07:54 +0000 Subject: [PATCH 1/2] feat(auth): remove agent principals --- packages/client/as-agent.ts | 8 - packages/client/index.ts | 5 +- packages/client/memory.test.ts | 143 ---- packages/client/memory.ts | 35 +- packages/client/user.ts | 61 +- .../idempotent/001_principal_space.sql | 18 +- .../migrate/idempotent/002_group_member.sql | 8 +- .../migrate/idempotent/003_tree_access.sql | 82 +-- .../core/migrate/idempotent/004_space.sql | 4 +- .../core/migrate/idempotent/005_principal.sql | 70 +- .../migrate/idempotent/006_membership.sql | 102 +-- .../core/migrate/idempotent/008_api_key.sql | 13 +- .../idempotent/011_service_account.sql | 4 +- .../migrate/incremental/018_remove_agents.sql | 93 +++ .../core/migrate/migrate.integration.test.ts | 660 ++++-------------- packages/database/core/migrate/migrate.ts | 8 + packages/database/core/version.ts | 2 +- packages/database/space/path.test.ts | 24 - packages/database/space/path.ts | 30 +- packages/engine/core/core.integration.test.ts | 56 +- packages/engine/core/db.integration.test.ts | 3 - packages/engine/core/db.ts | 30 +- packages/engine/core/types.ts | 20 +- packages/protocol/fields.ts | 4 +- packages/protocol/headers.ts | 10 - packages/protocol/index.ts | 7 +- packages/protocol/package.json | 2 +- packages/protocol/principal-name.test.ts | 22 +- packages/protocol/space/access.ts | 16 +- packages/protocol/space/group.ts | 2 +- packages/protocol/space/index.ts | 8 +- packages/protocol/space/principal.ts | 19 +- packages/protocol/space/space.ts | 4 +- packages/protocol/user/agent.ts | 59 -- packages/protocol/user/api-key.ts | 7 +- packages/protocol/user/index.ts | 27 +- packages/protocol/user/space.ts | 2 +- packages/protocol/user/whoami.ts | 12 +- packages/server/auth/betterauth.ts | 8 +- packages/server/middleware/act-as-agent.ts | 26 - .../authenticate-space.integration.test.ts | 288 +------- .../server/middleware/authenticate-space.ts | 84 +-- .../authenticate-user.integration.test.ts | 149 +--- .../server/middleware/authenticate-user.ts | 132 +--- packages/server/router.integration.test.ts | 2 +- packages/server/router.ts | 13 +- packages/server/rpc/handler.ts | 4 - packages/server/rpc/memory/access.ts | 27 +- packages/server/rpc/memory/grant.ts | 24 +- packages/server/rpc/memory/group.ts | 9 +- packages/server/rpc/memory/index.ts | 2 +- .../rpc/memory/management.integration.test.ts | 353 +--------- .../rpc/memory/memory.integration.test.ts | 3 +- packages/server/rpc/memory/memory.test.ts | 18 - packages/server/rpc/memory/memory.ts | 9 +- packages/server/rpc/memory/principal.ts | 32 +- packages/server/rpc/memory/space.ts | 6 +- packages/server/rpc/memory/support.ts | 45 +- packages/server/rpc/memory/types.ts | 21 +- packages/server/rpc/types.ts | 2 +- .../server/rpc/user/agent.integration.test.ts | 458 ------------ packages/server/rpc/user/agent.ts | 135 ---- .../rpc/user/api-key.integration.test.ts | 38 +- packages/server/rpc/user/api-key.ts | 15 +- packages/server/rpc/user/index.ts | 32 +- .../rpc/user/invitation.integration.test.ts | 10 +- packages/server/rpc/user/invitation.ts | 8 +- .../user/service-account.integration.test.ts | 33 +- packages/server/rpc/user/types.ts | 26 +- packages/server/rpc/user/whoami.ts | 6 +- 70 files changed, 545 insertions(+), 3153 deletions(-) delete mode 100644 packages/client/as-agent.ts create mode 100644 packages/database/core/migrate/incremental/018_remove_agents.sql delete mode 100644 packages/protocol/user/agent.ts delete mode 100644 packages/server/middleware/act-as-agent.ts delete mode 100644 packages/server/rpc/user/agent.integration.test.ts delete mode 100644 packages/server/rpc/user/agent.ts diff --git a/packages/client/as-agent.ts b/packages/client/as-agent.ts deleted file mode 100644 index 7514cc26..00000000 --- a/packages/client/as-agent.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const AS_AGENT_PROJECT_SENTINEL = ".me"; - -/** `.me` is a CLI-only sentinel and must never be sent as X-Me-As-Agent. */ -export function assertConcreteAsAgent(asAgent: string | undefined): void { - if (asAgent === AS_AGENT_PROJECT_SENTINEL) { - throw new Error("asAgent '.me' must be resolved before creating a client"); - } -} diff --git a/packages/client/index.ts b/packages/client/index.ts index 5a8232d4..8370aeaa 100644 --- a/packages/client/index.ts +++ b/packages/client/index.ts @@ -8,7 +8,7 @@ * Memory CRUD/search plus principal/group/grant/invite management. * * - {@link createUserClient} — user/account and service-account management. - * Talks to /api/v1/user/rpc: whoami, agent/service-account lifecycle, api keys, + * Talks to /api/v1/user/rpc: whoami, service-account lifecycle, api keys, * space discovery. * * CLI login is handled out-of-band by the `me` binary (OAuth auth-code + PKCE @@ -63,9 +63,8 @@ export { type MemoryNamespace, type PrincipalNamespace, } from "./memory.ts"; -// User client (whoami, agent/service-account lifecycle, api keys, space discovery) +// User client (whoami, service-account lifecycle, api keys, space discovery) export { - type AgentNamespace, type ApiKeyNamespace, createUserClient, type InviteeNamespace, diff --git a/packages/client/memory.test.ts b/packages/client/memory.test.ts index 6ab100fc..13197194 100644 --- a/packages/client/memory.test.ts +++ b/packages/client/memory.test.ts @@ -87,133 +87,6 @@ test("memory client setSpace updates the X-Me-Space header", async () => { expect(captured.headers["X-Me-Space"]).toBe("bbbbbbbbbbbb"); }); -test("memory client sends X-Me-As-Agent when asAgent is set", async () => { - const captured = captureFetch(); - const client = createMemoryClient({ - url: "https://api.example.com", - token: "sess-tok", - space: "abc123def456", - asAgent: "my-agent", - retries: 0, - }); - - await client.memory.tree(); - - expect(captured.headers["X-Me-As-Agent"]).toBe("my-agent"); - expect(captured.headers["X-Me-Space"]).toBe("abc123def456"); -}); - -test("memory client rejects unresolved .me asAgent sentinel", () => { - expect(() => - createMemoryClient({ - url: "https://api.example.com", - token: "sess-tok", - space: "abc123def456", - asAgent: ".me", - retries: 0, - }), - ).toThrow(/resolved/); -}); - -test("memory client omits X-Me-As-Agent when asAgent is unset", async () => { - const captured = captureFetch(); - const client = createMemoryClient({ - url: "https://api.example.com", - token: "sess-tok", - space: "abc123def456", - retries: 0, - }); - - await client.memory.tree(); - - expect(captured.headers["X-Me-As-Agent"]).toBeUndefined(); -}); - -test("memory client setAsAgent sets then clears the X-Me-As-Agent header", async () => { - const captured = captureFetch(); - const client = createMemoryClient({ - url: "https://api.example.com", - token: "t", - space: "aaaaaaaaaaaa", - retries: 0, - }); - - client.setAsAgent("agent-x"); - await client.memory.tree(); - expect(captured.headers["X-Me-As-Agent"]).toBe("agent-x"); - // Space is preserved through the header merge. - expect(captured.headers["X-Me-Space"]).toBe("aaaaaaaaaaaa"); - - client.setAsAgent(""); - captured.headers = {}; - await client.memory.tree(); - expect(captured.headers["X-Me-As-Agent"]).toBeUndefined(); -}); - -test("memory client setAsAgent rejects unresolved .me sentinel", () => { - const client = createMemoryClient({ - url: "https://api.example.com", - token: "t", - space: "aaaaaaaaaaaa", - retries: 0, - }); - - expect(() => client.setAsAgent(".me")).toThrow(/resolved/); -}); - -test("user client sends X-Me-As-Agent when asAgent is set (headers initialized from unset)", async () => { - const captured = captureFetch(); - const client = createUserClient({ - url: "https://api.example.com", - token: "sess-tok", - asAgent: "my-agent", - retries: 0, - }); - - await client.whoami(); - - expect(captured.headers["X-Me-As-Agent"]).toBe("my-agent"); -}); - -test("user client rejects unresolved .me asAgent sentinel", () => { - expect(() => - createUserClient({ - url: "https://api.example.com", - token: "sess-tok", - asAgent: ".me", - retries: 0, - }), - ).toThrow(/resolved/); -}); - -test("user client setAsAgent initializes headers when previously unset, then clears", async () => { - const captured = captureFetch(); - const client = createUserClient({ - url: "https://api.example.com", - token: "sess-tok", - retries: 0, - }); - - client.setAsAgent("agent-x"); - await client.whoami(); - expect(captured.headers["X-Me-As-Agent"]).toBe("agent-x"); - - client.setAsAgent(""); - captured.headers = {}; - await client.whoami(); - expect(captured.headers["X-Me-As-Agent"]).toBeUndefined(); -}); - -test("user client setAsAgent rejects unresolved .me sentinel", () => { - const client = createUserClient({ - url: "https://api.example.com", - token: "sess-tok", - retries: 0, - }); - - expect(() => client.setAsAgent(".me")).toThrow(/resolved/); -}); - test("user client targets the user endpoint with no X-Me-Space", async () => { const captured = captureFetch(); const client = createUserClient({ @@ -229,22 +102,6 @@ test("user client targets the user endpoint with no X-Me-Space", async () => { expect(captured.headers.Authorization).toBe("Bearer sess-tok"); }); -test("user client exposes agent.spaces", async () => { - const captured = captureFetch(); - const client = createUserClient({ - url: "https://api.example.com", - token: "sess-tok", - retries: 0, - }); - - await client.agent.spaces({ id: "018f1138-7f07-7c48-8bd1-c9a6b1095978" }); - - expect(captured.body).toMatchObject({ - method: "agent.spaces", - params: { id: "018f1138-7f07-7c48-8bd1-c9a6b1095978" }, - }); -}); - test("memory client does not retry mutating calls", async () => { const captured = captureStatusFetch([500, 200]); const client = createMemoryClient({ diff --git a/packages/client/memory.ts b/packages/client/memory.ts index 1f8fecc0..77416eef 100644 --- a/packages/client/memory.ts +++ b/packages/client/memory.ts @@ -1,11 +1,10 @@ /** * Memory client — the space data-plane + management client. * - * Talks to POST /api/v1/memory/rpc, authenticated by a session token (human) or - * an api key (agent), with the active space selected via the X-Me-Space header. + * Talks to POST /api/v1/memory/rpc, authenticated by a session token or API key, + * with the active space selected via the X-Me-Space header. * Namespaces: memory (data plane) + space / principal / group / grant / invite * (management). - * (Agent lifecycle and api keys live on the user client.) * * @example * ```ts @@ -15,7 +14,7 @@ * ``` */ -import { AS_AGENT_HEADER, SPACE_HEADER } from "@memory.build/protocol/headers"; +import { SPACE_HEADER } from "@memory.build/protocol/headers"; import type { MemoryBatchCreateParams, MemoryBatchCreateResult, @@ -91,7 +90,6 @@ import type { SpaceListMembersParams, SpaceListMembersResult, } from "@memory.build/protocol/space"; -import { assertConcreteAsAgent } from "./as-agent"; import { rpcCall, type TransportConfig } from "./transport.ts"; export interface MemoryClientOptions { @@ -99,7 +97,7 @@ export interface MemoryClientOptions { url?: string; /** Memory RPC endpoint path (default: "/api/v1/memory/rpc") */ rpcPath?: string; - /** Bearer token: a session token (human) or an api key (agent). */ + /** Bearer token: a session token or API key. */ token?: string; /** * Async bearer provider (overrides `token`); resolved per call, refreshing an @@ -110,13 +108,6 @@ export interface MemoryClientOptions { onUnauthorized?: () => Promise; /** The active space slug, sent as X-Me-Space. */ space?: string; - /** - * Act as one of the caller's own agents — an agent id or name, sent as - * X-Me-As-Agent. A human credential (session / OAuth / user PAT) is then - * authorized as that agent, constrained exactly as the agent's own api key. - * Ignored server-side when the bearer is itself an agent api key. - */ - asAgent?: string; /** Request timeout in milliseconds (default: 30000) */ timeout?: number; /** Maximum retry attempts for read-only calls. Mutating calls are not retried. */ @@ -159,7 +150,7 @@ export interface PrincipalNamespace { } export interface SpaceNamespace { - /** List direct user/agent/service-account members in the active space. */ + /** List direct user/service-account members in the active space. */ listMembers(params?: SpaceListMembersParams): Promise; } @@ -211,11 +202,6 @@ export interface MemoryClient { setToken(token: string): void; /** Update the active space slug (X-Me-Space) at runtime. */ setSpace(space: string): void; - /** - * Update the act-as-agent target (X-Me-As-Agent) at runtime. An empty string - * clears the header (act as the human credential). - */ - setAsAgent(asAgent: string): void; } const DEFAULT_URL = "https://api.memory.build"; @@ -223,14 +209,12 @@ const MEMORY_RPC_PATH = "/api/v1/memory/rpc"; const DEFAULT_TIMEOUT = 30_000; const DEFAULT_RETRIES = 3; -/** Seed the initial header map from the space + act-as-agent options. */ +/** Seed the initial header map from the active-space option. */ function seedHeaders( options: MemoryClientOptions, ): Record | undefined { - assertConcreteAsAgent(options.asAgent); const headers: Record = {}; if (options.space) headers[SPACE_HEADER] = options.space; - if (options.asAgent) headers[AS_AGENT_HEADER] = options.asAgent; return Object.keys(headers).length > 0 ? headers : undefined; } @@ -319,12 +303,5 @@ export function createMemoryClient( setSpace(space: string) { config.headers = { ...config.headers, [SPACE_HEADER]: space }; }, - setAsAgent(asAgent: string) { - assertConcreteAsAgent(asAgent); - const headers = { ...config.headers }; - if (asAgent) headers[AS_AGENT_HEADER] = asAgent; - else delete headers[AS_AGENT_HEADER]; - config.headers = headers; - }, }; } diff --git a/packages/client/user.ts b/packages/client/user.ts index 3a828575..33e469ba 100644 --- a/packages/client/user.ts +++ b/packages/client/user.ts @@ -2,24 +2,13 @@ * User client — user/account and service-account operations. * * Talks to POST /api/v1/user/rpc, authenticated by a session/OAuth token or the - * user's own PAT for management operations. Namespaces: agent (user-owned - * agents), serviceAccount (space-scoped integration identities), apiKey (keys - * for credential-bearing principals), and space (discover/create/manage the - * user's spaces — used by the CLI to pick the active X-Me-Space). + * user's own PAT for management operations. Namespaces: serviceAccount + * (space-scoped integration identities), apiKey (keys for credential-bearing + * principals), and space (discover/create/manage the user's spaces — used by + * the CLI to pick the active X-Me-Space). */ -import { AS_AGENT_HEADER } from "@memory.build/protocol/headers"; import type { - AgentCreateParams, - AgentCreateResult, - AgentDeleteParams, - AgentDeleteResult, - AgentListParams, - AgentListResult, - AgentRenameParams, - AgentRenameResult, - AgentSpacesParams, - AgentSpacesResult, ApiKeyCreateParams, ApiKeyCreateResult, ApiKeyDeleteParams, @@ -57,7 +46,6 @@ import type { WhoamiParams, WhoamiResult, } from "@memory.build/protocol/user"; -import { assertConcreteAsAgent } from "./as-agent"; import { rpcCall, type TransportConfig } from "./transport.ts"; export interface UserClientOptions { @@ -74,13 +62,6 @@ export interface UserClientOptions { getToken?: () => Promise; /** Reactive refresh hook fired on a 401. See {@link TransportConfig.onUnauthorized}. */ onUnauthorized?: () => Promise; - /** - * Act as one of the caller's own agents — an agent id or name, sent as - * X-Me-As-Agent. The human credential is then authorized as that agent, so - * only the agent-allowed reads (`whoami`, `space.list`) succeed; management - * ops fail server-side. Ignored when the bearer is itself an agent api key. - */ - asAgent?: string; /** Request timeout in milliseconds (default: 30000) */ timeout?: number; /** Maximum retry attempts for read-only calls. Mutating calls are not retried. */ @@ -89,14 +70,6 @@ export interface UserClientOptions { clientVersion?: string; } -export interface AgentNamespace { - create(params: AgentCreateParams): Promise; - list(params?: AgentListParams): Promise; - spaces(params: AgentSpacesParams): Promise; - rename(params: AgentRenameParams): Promise; - delete(params: AgentDeleteParams): Promise; -} - export interface ServiceAccountNamespace { create( params: ServiceAccountCreateParams, @@ -139,18 +112,12 @@ export interface InviteeNamespace { export interface UserClient { /** The identity behind the session token. */ whoami(params?: WhoamiParams): Promise; - agent: AgentNamespace; serviceAccount: ServiceAccountNamespace; apiKey: ApiKeyNamespace; space: SpaceNamespace; invite: InviteeNamespace; /** Update the session token at runtime. */ setToken(token: string): void; - /** - * Update the act-as-agent target (X-Me-As-Agent) at runtime. An empty string - * clears the header (act as the human credential). - */ - setAsAgent(asAgent: string): void; } const DEFAULT_URL = "https://api.memory.build"; @@ -159,7 +126,6 @@ const DEFAULT_TIMEOUT = 30_000; const DEFAULT_RETRIES = 3; export function createUserClient(options: UserClientOptions = {}): UserClient { - assertConcreteAsAgent(options.asAgent); const config: TransportConfig = { url: (options.url ?? DEFAULT_URL).replace(/\/+$/, ""), path: options.rpcPath ?? USER_RPC_PATH, @@ -169,11 +135,6 @@ export function createUserClient(options: UserClientOptions = {}): UserClient { timeout: options.timeout ?? DEFAULT_TIMEOUT, retries: options.retries ?? DEFAULT_RETRIES, clientVersion: options.clientVersion, - // createUserClient carries no headers by default; seed one only when acting - // as an agent, then lazily create/merge in setAsAgent. - headers: options.asAgent - ? { [AS_AGENT_HEADER]: options.asAgent } - : undefined, }; function readRpc(method: string, params: unknown): Promise { @@ -189,13 +150,6 @@ export function createUserClient(options: UserClientOptions = {}): UserClient { return { whoami: (p) => readRpc("whoami", p ?? {}), - agent: { - create: (p) => writeRpc("agent.create", p), - list: (p) => readRpc("agent.list", p ?? {}), - spaces: (p) => readRpc("agent.spaces", p), - rename: (p) => writeRpc("agent.rename", p), - delete: (p) => writeRpc("agent.delete", p), - }, serviceAccount: { create: (p) => writeRpc("serviceAccount.create", p), list: (p) => readRpc("serviceAccount.list", p), @@ -224,12 +178,5 @@ export function createUserClient(options: UserClientOptions = {}): UserClient { setToken(token: string) { config.token = token; }, - setAsAgent(asAgent: string) { - assertConcreteAsAgent(asAgent); - const headers = { ...config.headers }; - if (asAgent) headers[AS_AGENT_HEADER] = asAgent; - else delete headers[AS_AGENT_HEADER]; - config.headers = headers; - }, }; } diff --git a/packages/database/core/migrate/idempotent/001_principal_space.sql b/packages/database/core/migrate/idempotent/001_principal_space.sql index 47770554..711ea44f 100644 --- a/packages/database/core/migrate/idempotent/001_principal_space.sql +++ b/packages/database/core/migrate/idempotent/001_principal_space.sql @@ -46,9 +46,8 @@ $func$ language sql stable security invoker -- A principal is a space admin if it has a direct admin membership, OR it is a -- direct member of the space (a principal_space row) who also belongs to a group -- whose own space-membership is admin. Admin via a group requires direct --- membership — group membership alone never confers space access. Agents are --- never space admins, and service accounts only become space admins through a --- direct admin row. +-- membership — group membership alone never confers space access. Service +-- accounts only become space admins through a direct admin row. ------------------------------------------------------------------------------- {{fn is_principal_space_admin(_principal_id uuid, _space_id uuid, _api_key_id uuid) returns bool}} create or replace function {{schema}}.is_principal_space_admin @@ -63,7 +62,6 @@ as $func$ select 1 from {{schema}}.principal p where p.id = _principal_id - and p.kind <> 'a' -- agents cannot be space admins and ( _api_key_id is null @@ -123,10 +121,10 @@ $func$ language sql stable security invoker -- Invariant: a live space must always have at least one *effective* admin — a -- user who is a direct admin (principal_space.admin) OR a direct member who -- belongs to an admin-flagged group (admin via a group requires direct --- membership). Agents are never admins, and an admin-flagged group whose user --- members aren't direct space members does NOT count. Checking the effective set --- (not just the principal_space.admin flag) closes the brick where a space's --- sole admin is an empty/non-member admin group, leaving it unrecoverable. +-- membership). An admin-flagged group whose user members aren't direct space +-- members does NOT count. Checking the effective set (not just the +-- principal_space.admin flag) closes the brick where a space's sole admin is +-- an empty/non-member admin group, leaving it unrecoverable. -- -- Guards every path that could drop the effective set, uniformly: -- * principal_space remove/demote — incl. a group losing its admin flag, and @@ -277,12 +275,12 @@ end $$; -- guards this for its own callers; this is the table-level backstop that also -- catches a direct insert/update: any principal_space row for a group (kind 'g') -- whose space_id differs from the group's principal.space_id is rejected. Users --- and agents are global (principal.space_id null) and unconstrained — they can be +-- users are global (principal.space_id null) and unconstrained — they can be -- rostered into many spaces. -- -- This is the half of the group<->space coherence invariant that cannot be a -- composite FK: principal_space(principal_id, space_id) -> principal(id, space_id) --- would break users/agents, whose principal.space_id is null (and FKs can't be +-- would break users, whose principal.space_id is null (and FKs can't be -- conditioned on kind). The group_member half IS a composite FK — see -- incremental/011_group_member_space_fk.sql. ------------------------------------------------------------------------------- diff --git a/packages/database/core/migrate/idempotent/002_group_member.sql b/packages/database/core/migrate/idempotent/002_group_member.sql index 86248758..08386929 100644 --- a/packages/database/core/migrate/idempotent/002_group_member.sql +++ b/packages/database/core/migrate/idempotent/002_group_member.sql @@ -16,10 +16,10 @@ as $func$ -- the member is also a direct space member (gated in member_tree_access), so a -- member can be pre-staged into a group before joining the space. This just -- lists the member's groups; the FKs constrain group_id to a group and - -- member_id to a user/agent/service account. + -- member_id to a user/service account. select gm.group_id - , gm.admin and (not m.kind = 'a') -- agents cannot be group admins; service accounts can + , gm.admin from {{schema}}.group_member gm inner join {{schema}}.principal m on (m.member_id = gm.member_id) where gm.member_id = _member_id @@ -29,8 +29,8 @@ $func$ language sql stable security invoker ------------------------------------------------------------------------------- -- is_group_admin --- Whether a member is an admin of a specific group in a space. (Agents are --- never group admins; service accounts may be admins of ordinary groups.) +-- Whether a member is an admin of a specific group in a space. Service accounts +-- may be admins of ordinary groups. ------------------------------------------------------------------------------- create or replace function {{schema}}.is_group_admin ( _member_id uuid diff --git a/packages/database/core/migrate/idempotent/003_tree_access.sql b/packages/database/core/migrate/idempotent/003_tree_access.sql index c4cdb6ae..908086c3 100644 --- a/packages/database/core/migrate/idempotent/003_tree_access.sql +++ b/packages/database/core/migrate/idempotent/003_tree_access.sql @@ -147,7 +147,7 @@ returns table as $func$ -- Service accounts are top-level space-scoped members: their effective access -- is their direct grants plus grants inherited from ordinary groups they belong - -- to. Unlike agents, there is no owner clamp. + -- to. select ta.tree_path , ta.access @@ -164,85 +164,11 @@ as $func$ $func$ language sql stable security invoker ; -------------------------------------------------------------------------------- --- agent_tree_access -------------------------------------------------------------------------------- -create or replace function {{schema}}.agent_tree_access -( _agent_id uuid -, _space_id uuid -) -returns table -( tree_path ltree -, access int -) -as $func$ - with agent_access as materialized - ( - -- agent's direct grants - select - ta.tree_path - , ta.access - from {{schema}}.principal a - inner join {{schema}}.principal_space ps on (a.id = ps.principal_id and ps.space_id = _space_id) - inner join {{schema}}.tree_access ta on (a.id = ta.principal_id and ta.space_id = _space_id) - where a.agent_id = _agent_id - union - -- agent's access via groups - select - x.tree_path - , x.access - from {{schema}}.member_tree_access(_agent_id, _space_id) x - ) - , owner_access as materialized - ( - -- get the access for the user that owns the agent - select - x.tree_path - , x.access - from - ( - select p.owner_id - from {{schema}}.principal p - where p.agent_id = _agent_id - ) a - cross join lateral {{schema}}.user_tree_access(a.owner_id, _space_id) x - ) - -- An agent's effective access is its grants clamped to its owner's: at every - -- path on a shared lineage the agent gets least(agent, owner). So it can never - -- exceed the owner — and, when granted MORE than the owner holds, it clamps - -- DOWN to the owner's level rather than vanishing (e.g. owner read@foo + agent - -- write@foo.bar -> the agent gets read@foo.bar). Two arms cover the two nesting - -- directions; the deeper path wins each pairing, and max() collapses duplicate - -- paths to the highest surviving level. A path the owner doesn't cover at all - -- produces no row, so the agent gets nothing there. - select - x.tree_path - , max(x.access) - from - ( - -- owner grant is at-or-above the agent's path: clamp the agent's grant down - select - aa.tree_path - , least(aa.access, oa.access) as access - from agent_access aa - inner join owner_access oa on (oa.tree_path @> aa.tree_path) - union all - -- agent grant is at-or-above the owner's (narrower) path: take min at the owner's - select - oa.tree_path - , least(aa.access, oa.access) as access - from agent_access aa - inner join owner_access oa on (aa.tree_path @> oa.tree_path) - ) x - group by x.tree_path -$func$ language sql stable security invoker -; - ------------------------------------------------------------------------------- -- build_tree_access -- -- The bridge from core's access model to the space data-plane functions: - -- resolves a member's (user, agent, or service account) effective grants in a +-- resolves a member's (user or service account) effective grants in a -- space and returns them as the jsonb array shape that space.search_memory / -- *_memory consume via jsonb_to_recordset(...) x(tree_path ltree, access int). ------------------------------------------------------------------------------- @@ -265,10 +191,6 @@ as $func$ from {{schema}}.user_tree_access(p.user_id, _space_id) uta where p.kind = 'u' union all - select ata.tree_path, ata.access - from {{schema}}.agent_tree_access(p.agent_id, _space_id) ata - where p.kind = 'a' - union all select sta.tree_path, sta.access from {{schema}}.service_account_tree_access(p.id, _space_id) sta where p.kind = 's' diff --git a/packages/database/core/migrate/idempotent/004_space.sql b/packages/database/core/migrate/idempotent/004_space.sql index deb3acb7..41fc07ce 100644 --- a/packages/database/core/migrate/idempotent/004_space.sql +++ b/packages/database/core/migrate/idempotent/004_space.sql @@ -111,8 +111,8 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- list_spaces_for_member --- Spaces a member (user/agent) belongs to — i.e. has a direct principal_space --- row. Group membership alone does NOT make you a space member, so a space +-- Spaces a member (user/service account) belongs to — i.e. has a direct +-- principal_space row. Group membership alone does NOT make you a space member, so a space -- reached only through a group is not listed. `admin` is the effective -- space-admin status (a direct admin row OR a direct member who belongs to an -- admin group). Used by the user endpoint so a logged-in human can pick their diff --git a/packages/database/core/migrate/idempotent/005_principal.sql b/packages/database/core/migrate/idempotent/005_principal.sql index 03c64919..c002b64b 100644 --- a/packages/database/core/migrate/idempotent/005_principal.sql +++ b/packages/database/core/migrate/idempotent/005_principal.sql @@ -1,6 +1,6 @@ ------------------------------------------------------------------------------- -- create_user --- Users are global (no space_id, no owner_id). The id is supplied by the caller +-- Users are global (no space_id). The id is supplied by the caller -- so it equals auth.users.id (one identity across auth + core). ------------------------------------------------------------------------------- create or replace function {{schema}}.create_user @@ -16,24 +16,6 @@ $func$ language sql volatile security invoker set search_path to pg_catalog, {{schema}}, public, pg_temp ; -------------------------------------------------------------------------------- --- create_agent --- Agents are owned by a user (owner_id -> a user principal's id) and are global. -------------------------------------------------------------------------------- -create or replace function {{schema}}.create_agent -( _owner_id uuid -, _name text -, _id uuid default null -) -returns uuid -as $func$ - insert into {{schema}}.principal (id, kind, name, owner_id) - values (coalesce(_id, uuidv7()), 'a', _name, _owner_id) - returning id -$func$ language sql volatile security invoker -set search_path to pg_catalog, {{schema}}, public, pg_temp -; - ------------------------------------------------------------------------------- -- create_group -- Groups belong to a single space, and are rostered into that space's @@ -41,7 +23,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp -- who/what belongs to a space, so a group is a first-class roster entry (this is -- what makes it resolvable and grantable by name via principal.resolve / -- list_space_principals). add_principal_to_space skips the home grant for groups --- (only u/a get a home). Rostering the group does NOT confer space access on its +-- (only users get a home). Rostering the group does NOT confer space access on its -- members: member_tree_access still gates a group's grants on each member's own -- principal_space row, so group membership alone never confers space membership. -- @@ -83,6 +65,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- get_principal ------------------------------------------------------------------------------- +{{fn get_principal(_id uuid) returns table(id uuid, kind text, name text, space_id uuid, created_at timestamptz, updated_at timestamptz)}} create or replace function {{schema}}.get_principal ( _id uuid ) @@ -90,24 +73,25 @@ returns table ( id uuid , kind text , name text -, owner_id uuid , space_id uuid , created_at timestamptz , updated_at timestamptz ) as $func$ - select p.id, p.kind, p.name::text, p.owner_id, p.space_id, p.created_at, p.updated_at + select p.id, p.kind, p.name::text, p.space_id, p.created_at, p.updated_at from {{schema}}.principal p where p.id = _id $func$ language sql stable strict rows 1 security invoker set search_path to pg_catalog, {{schema}}, public, pg_temp ; +{{endfn}} ------------------------------------------------------------------------------- -- get_user_by_name -- Resolve a global user (kind 'u') by name. User names are globally unique -- (citext), so this returns at most one row. ------------------------------------------------------------------------------- +{{fn get_user_by_name(_name text) returns table(id uuid, kind text, name text, space_id uuid, created_at timestamptz, updated_at timestamptz)}} create or replace function {{schema}}.get_user_by_name ( _name text ) @@ -115,45 +99,19 @@ returns table ( id uuid , kind text , name text -, owner_id uuid , space_id uuid , created_at timestamptz , updated_at timestamptz ) as $func$ - select p.id, p.kind, p.name::text, p.owner_id, p.space_id, p.created_at, p.updated_at + select p.id, p.kind, p.name::text, p.space_id, p.created_at, p.updated_at from {{schema}}.principal p where p.kind = 'u' and p.name = _name::citext $func$ language sql stable strict rows 1 security invoker set search_path to pg_catalog, {{schema}}, public, pg_temp ; - -------------------------------------------------------------------------------- --- list_agents --- A user's agents (global; agents are owned by a user, not scoped to a space). -------------------------------------------------------------------------------- -create or replace function {{schema}}.list_agents -( _owner_id uuid -) -returns table -( id uuid -, kind text -, name text -, owner_id uuid -, space_id uuid -, created_at timestamptz -, updated_at timestamptz -) -as $func$ - select p.id, p.kind, p.name::text, p.owner_id, p.space_id, p.created_at, p.updated_at - from {{schema}}.principal p - where p.kind = 'a' - and p.owner_id = _owner_id - order by p.name -$func$ language sql stable strict security invoker -set search_path to pg_catalog, {{schema}}, public, pg_temp -; +{{endfn}} ------------------------------------------------------------------------------- -- list_space_groups @@ -190,10 +148,10 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- rename_principal --- Rename an agent, group, or service account. Users are intentionally excluded: a user's name is --- its email — the global identity handle that mirrors auth.users — so changing +-- Rename a group or service account. Users are intentionally excluded: a user's +-- name is its email — the global identity handle that mirrors auth.users — so changing -- it is an account concern, not a space-management one. Returns true if an --- agent/group/service-account with this id was renamed. Name uniqueness is +-- group/service-account with this id was renamed. Name uniqueness is -- enforced by the principal table indexes. ------------------------------------------------------------------------------- create or replace function {{schema}}.rename_principal @@ -207,7 +165,7 @@ as $func$ update {{schema}}.principal set name = _name::citext where id = _id - and kind in ('a', 'g', 's') -- never rename users (kind 'u') + and kind in ('g', 's') -- never rename users (kind 'u') returning 1 ) select exists (select 1 from u) @@ -217,8 +175,8 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- delete_principal --- Delete a principal row. Foreign keys cascade: a user's agents (owner_id), --- its space memberships, group memberships, tree-access grants, and api keys +-- Delete a principal row. Foreign keys cascade its space memberships, group +-- memberships, tree-access grants, and api keys -- all go with it. Service-account bound-admin-group cleanup is handled by the -- service-account delete trigger. Returns true if a row was deleted. ------------------------------------------------------------------------------- diff --git a/packages/database/core/migrate/idempotent/006_membership.sql b/packages/database/core/migrate/idempotent/006_membership.sql index 8ac87e33..0171b0e1 100644 --- a/packages/database/core/migrate/idempotent/006_membership.sql +++ b/packages/database/core/migrate/idempotent/006_membership.sql @@ -1,9 +1,9 @@ ------------------------------------------------------------------------------- -- add_principal_to_space -- Adds (or updates the admin flag of) a principal's membership in a space, and --- grants a joining user/agent owner over its home directory. The single +-- grants a joining user owner over its home directory. The single -- chokepoint every join path goes through (provisioning, invite redemption, --- direct add), so a user/agent membership implies home ownership — service +-- direct add), so a user membership implies home ownership — service -- accounts intentionally get no home — and the one place that enforces a -- space-scoped principal can only be rostered into its own space. ------------------------------------------------------------------------------- @@ -16,8 +16,8 @@ returns void as $func$ begin -- Groups and service accounts belong to exactly one space (principal.space_id, - -- fixed at creation), so they can only be members of that space. Users and - -- agents are global (space_id null), so this constrains space-scoped kinds. + -- fixed at creation), so they can only be members of that space. Users are + -- global (space_id null), so this constrains space-scoped kinds. if exists ( select 1 @@ -37,33 +37,23 @@ begin on conflict (principal_id, space_id) do update set admin = excluded.admin; -- updated_at maintained by the before-update trigger - -- A joining member owns its home directory; the path differs by kind (hyphens - -- stripped to valid ltree labels; see packages/database/space/path.ts - -- homePrefix() for the matching client form): - -- user -> home. - -- agent -> home.. (nested under the owner's home) - -- The agent's home nests under its owner's home so the owner's - -- owner@home. grant covers it and agent_tree_access keeps the grant - -- effective (a bare home. would be clamped to nothing — the owner - -- holds no access there). Groups have no home. Idempotent and non-clobbering: - -- an existing home grant is left untouched. + -- A joining user owns its home directory (hyphens are stripped to valid ltree + -- labels). Groups and service accounts have no home. Idempotent and + -- non-clobbering: an existing home grant is left untouched. -- -- Custom spaces: the home grant is suppressed when the space has -- auto_grant_home = false, so a joiner gets NO owner@~ (the operator sets up -- access manually). Read off the space row so every join path — provisioning, -- invite redeem, direct add — honors it without threading a param. insert into {{schema}}.tree_access (space_id, principal_id, tree_path, access) - select _space_id, _principal_id - , case - when p.kind = 'a' - then ('home.' || replace(p.owner_id::text, '-', '') || '.' || replace(p.id::text, '-', ''))::ltree - else ('home.' || replace(p.id::text, '-', ''))::ltree - end - , 3 -- owner + select + _space_id, _principal_id + , ('home.' || replace(p.id::text, '-', ''))::ltree + , 3 -- owner from {{schema}}.principal p join {{schema}}.space s on s.id = _space_id where p.id = _principal_id - and p.kind in ('u', 'a') -- service accounts do not get a home directory by default + and p.kind = 'u' -- only users get a home directory by default and s.auto_grant_home on conflict (space_id, principal_id, tree_path) do nothing; end; @@ -73,7 +63,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- add_group_member --- Adds a user/agent/service-account member to a group within a space. Groups are NOT nestable: +-- Adds a user/service-account member to a group within a space. Groups are NOT nestable: -- a group can never be a group member. This is already structurally impossible -- (group_member.member_id references principal(member_id), which is null for -- groups, so a group id can't be inserted), but we reject it explicitly first so @@ -81,7 +71,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp -- -- Service accounts are space-scoped, so a service account from a DIFFERENT space -- is rejected: it could never join _space_id, so the row would be a permanently --- dormant member that still shows up in listings. (Users/agents are global — +-- dormant member that still shows up in listings. (Users are global — -- their space_id is null — so this only bites cross-space service accounts.) ------------------------------------------------------------------------------- create or replace function {{schema}}.add_group_member @@ -189,22 +179,11 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- remove_principal_from_space --- Removes a user/agent member from a space and cascades: scrubs its tree_access +-- Removes a user member from a space and cascades: scrubs its tree_access -- grants and its group_member rows in that space. Returns true if the principal -- was a member of the space. (Space-scoped only; the principal row itself and -- any other spaces are left untouched.) -- --- User → agent cascade: removing a USER also deprovisions the agents that user --- owns from THIS space (their tree_access / group_member / principal_space rows), --- because an agent is a separate principal nested under its owner's home and would --- otherwise stay rostered and usable via its own api key after its owner leaves. --- The cascade is space-scoped (only rows in _space_id) — the agents' `principal` --- rows and their memberships in other spaces are left intact — and gated on the --- target actually having been a member, so removing a non-member is a clean no-op. --- It lives here so every caller (admin remove-member, self-leave) inherits it --- atomically; agents are never admins, so the agent-row deletes can't trip the --- deferred enforce_last_admin guard. --- -- Groups and service accounts are rejected: both are space-scoped principals -- whose lifecycle is delete-based. Removing just the roster row here would -- orphan the principal in a half-deprovisioned state while also wiping its @@ -258,39 +237,6 @@ begin ) select exists (select 1 from del_membership) into _removed; - -- User → agent cascade (space-scoped). Only when the target was actually a - -- member and is a user: deprovision the agents it owns from this space too. - if _removed and exists - ( - select 1 - from {{schema}}.principal p - where p.id = _principal_id - and p.kind = 'u' - ) then - with owned_agents as - ( - select p.id - from {{schema}}.principal p - where p.kind = 'a' - and p.owner_id = _principal_id - ) - , del_agent_grants as - ( - delete from {{schema}}.tree_access - where space_id = _space_id - and principal_id in (select id from owned_agents) - ) - , del_agent_group_member as - ( - delete from {{schema}}.group_member - where space_id = _space_id - and member_id in (select id from owned_agents) - ) - delete from {{schema}}.principal_space - where space_id = _space_id - and principal_id in (select id from owned_agents); - end if; - return _removed; end; $func$ language plpgsql volatile security invoker @@ -324,20 +270,19 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- list_space_principals -- The space roster: principals with a direct membership row (principal_space) — --- users, agents, AND groups (a group is rostered into its space on creation, so +-- users, service accounts, AND groups (a group is rostered into its space on creation, so -- principal_space is the single source of truth for who/what belongs to a space). -- Note the distinction: a group appears here because it is itself a roster entry; --- this says nothing about its members — a user/agent who is only in a group (no +-- this says nothing about its members — a user who is only in a group (no -- principal_space row of their own) is still NOT a space member and is not listed. -- `admin` is the EFFECTIVE space-admin status via is_principal_space_admin (a --- direct admin row OR a direct member who belongs to an admin group, never an --- agent; false for a group rostered admin=false). Optional kind filter --- ('u' | 'a' | 'g'); null returns all. +-- direct admin row OR a direct member who belongs to an admin group; false for +-- a group rostered admin=false). Optional kind filter; null returns all. ------------------------------------------------------------------------------- -- list_space_principals dropped its `direct` output column — a returns-table -- change create-or-replace cannot make. The fn block drops a stale-signatured -- definition before the create and asserts the result after. -{{fn list_space_principals(_space_id uuid, _kind text) returns table(id uuid, kind text, name text, owner_id uuid, admin bool, created_at timestamptz, updated_at timestamptz)}} +{{fn list_space_principals(_space_id uuid, _kind text) returns table(id uuid, kind text, name text, admin bool, created_at timestamptz, updated_at timestamptz)}} create or replace function {{schema}}.list_space_principals ( _space_id uuid , _kind text default null @@ -346,13 +291,12 @@ returns table ( id uuid , kind text , name text -, owner_id uuid , admin bool , created_at timestamptz , updated_at timestamptz ) as $func$ - select p.id, p.kind, p.name::text, p.owner_id + select p.id, p.kind, p.name::text , {{schema}}.is_principal_space_admin(p.id, _space_id) as admin , p.created_at, p.updated_at from {{schema}}.principal_space ps @@ -409,7 +353,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- list_group_members --- Members (users / agents / service accounts) of a group within a space, with +-- Members (users / service accounts) of a group within a space, with -- the stored admin flag. ------------------------------------------------------------------------------- create or replace function {{schema}}.list_group_members @@ -436,7 +380,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- list_groups_for_member --- Groups within a space that a member (user / agent / service account) belongs +-- Groups within a space that a member (user / service account) belongs -- to, with the effective admin flag. ------------------------------------------------------------------------------- create or replace function {{schema}}.list_groups_for_member diff --git a/packages/database/core/migrate/idempotent/008_api_key.sql b/packages/database/core/migrate/idempotent/008_api_key.sql index 03eef7f9..e9036eb2 100644 --- a/packages/database/core/migrate/idempotent/008_api_key.sql +++ b/packages/database/core/migrate/idempotent/008_api_key.sql @@ -133,16 +133,14 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp ------------------------------------------------------------------------------- -- validate_api_key -- Looks a key up by lookup_id, compares the hashed secret, and enforces expiry. --- Returns the member_id + api_key id + the member's owner_id (non-null when the --- key-holder is an agent, null for a user) + the member's kind + name when valid; --- no rows otherwise. owner_id drives `~` home nesting for agents at the RPC --- boundary; kind/name let the auth middleware skip a second principal lookup per --- request (it already joins principal here). +-- Returns the member_id + api_key id + the member's kind + name when valid; no +-- rows otherwise. Kind/name let the auth middleware skip a second principal +-- lookup per request (it already joins principal here). ------------------------------------------------------------------------------- -- validate_api_key's returns-table has grown output columns several times — a -- change create-or-replace cannot make. The fn block drops a -- stale-signatured definition before the create and asserts the result after. -{{fn validate_api_key(_lookup_id text, _secret text) returns table(member_id uuid, api_key_id uuid, owner_id uuid, kind text, name text, restricted bool)}} +{{fn validate_api_key(_lookup_id text, _secret text) returns table(member_id uuid, api_key_id uuid, kind text, name text, restricted bool)}} create or replace function {{schema}}.validate_api_key ( _lookup_id text , _secret text -- hashed @@ -150,13 +148,12 @@ create or replace function {{schema}}.validate_api_key returns table ( member_id uuid , api_key_id uuid -, owner_id uuid , kind text , name text , restricted bool ) as $func$ - select k.member_id, k.id, p.owner_id, p.kind, p.name::text, k.restricted + select k.member_id, k.id, p.kind, p.name::text, k.restricted from {{schema}}.api_key k inner join {{schema}}.principal p on p.id = k.member_id where k.lookup_id = _lookup_id diff --git a/packages/database/core/migrate/idempotent/011_service_account.sql b/packages/database/core/migrate/idempotent/011_service_account.sql index c0aae66f..4c3d142d 100644 --- a/packages/database/core/migrate/idempotent/011_service_account.sql +++ b/packages/database/core/migrate/idempotent/011_service_account.sql @@ -24,7 +24,7 @@ comment on column {{schema}}.principal.admin_id is -- The service account is rostered into the space with no tree grants and is not -- added to the default group. _group_admin_member_ids is a subset-or-extension of -- _member_ids: ids present there are inserted with group_member.admin=true. --- Initial bound-admin-group members may be users, agents, or service accounts; +-- Initial bound-admin-group members may be users or service accounts; -- only direct user members are service-account admins (is_service_account_admin). ------------------------------------------------------------------------------- create or replace function {{schema}}.create_service_account @@ -96,7 +96,7 @@ begin raise exception 'cannot seed a service account from a different space into service account %''s admin group', _service_id using errcode = '23514' - , hint = 'initial admin-group members must be users, agents, or service accounts in the same space'; + , hint = 'initial admin-group members must be users or service accounts in the same space'; end if; insert into {{schema}}.group_member (space_id, group_id, member_id, admin) diff --git a/packages/database/core/migrate/incremental/018_remove_agents.sql b/packages/database/core/migrate/incremental/018_remove_agents.sql new file mode 100644 index 00000000..2478e9d0 --- /dev/null +++ b/packages/database/core/migrate/incremental/018_remove_agents.sql @@ -0,0 +1,93 @@ +------------------------------------------------------------------------------- +-- Remove retired agent principals in one atomic schema upgrade. +-- +-- Incrementals run before idempotents, so drop every function that still +-- depends on agent columns before removing those columns. The living idempotents +-- recreate the surviving functions with their agent-free shapes. +------------------------------------------------------------------------------- +drop function if exists {{schema}}.build_tree_access(uuid, uuid, uuid); +drop function if exists {{schema}}.agent_tree_access(uuid, uuid); +drop function if exists {{schema}}.create_agent(uuid, text, uuid); +drop function if exists {{schema}}.list_agents(uuid); +drop function if exists {{schema}}.add_principal_to_space(uuid, uuid, bool); +drop function if exists {{schema}}.remove_principal_from_space(uuid, uuid); +drop function if exists {{schema}}.get_principal(uuid); +drop function if exists {{schema}}.get_user_by_name(text); +drop function if exists {{schema}}.list_space_principals(uuid, text); +drop function if exists {{schema}}.validate_api_key(text, text); + +-- Deleting the principals cascades their memberships, group memberships, +-- grants, API keys, and scoped-key declarations. Space memory tables are not +-- part of core and deliberately remain untouched. +delete from {{schema}}.principal where kind = 'a'; + +-- The original owner invariant references owner_id, so remove it by the +-- columns it covers rather than relying on its generated constraint name. +do $$ +declare + _conname text; + _kind smallint; + _owner smallint; +begin + select attnum into _kind + from pg_attribute + where attrelid = '{{schema}}.principal'::regclass and attname = 'kind'; + select attnum into _owner + from pg_attribute + where attrelid = '{{schema}}.principal'::regclass and attname = 'owner_id'; + + select c.conname into _conname + from pg_constraint c + where c.conrelid = '{{schema}}.principal'::regclass + and c.contype = 'c' + and c.conkey @> array[_kind, _owner] + and c.conkey <@ array[_kind, _owner]; + + if _conname is not null then + execute format('alter table {{schema}}.principal drop constraint %I', _conname); + end if; +end $$; + +alter table {{schema}}.principal + alter column member_id set expression as + (case when kind in ('u', 's') then id else null end) +; + +alter table {{schema}}.principal + drop column if exists owner_id +, drop column if exists agent_id +; + +do $$ +declare + _conname text; + _kind smallint; +begin + select attnum into _kind + from pg_attribute + where attrelid = '{{schema}}.principal'::regclass and attname = 'kind'; + + select c.conname into _conname + from pg_constraint c + where c.conrelid = '{{schema}}.principal'::regclass + and c.contype = 'c' + and c.conkey @> array[_kind] and c.conkey <@ array[_kind]; + + if _conname is not null then + execute format('alter table {{schema}}.principal drop constraint %I', _conname); + end if; +end $$; + +alter table {{schema}}.principal + add constraint principal_kind_check check (kind in ('g', 'u', 's')) +; + +alter table {{schema}}.principal + drop constraint if exists principal_agent_group_name_check +, drop constraint if exists principal_handle_name_check +, add constraint principal_handle_name_check check + ( + kind not in ('g', 's') + or name::text ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$' + ) +; diff --git a/packages/database/core/migrate/migrate.integration.test.ts b/packages/database/core/migrate/migrate.integration.test.ts index 0a0558e4..20fe3ee5 100644 --- a/packages/database/core/migrate/migrate.integration.test.ts +++ b/packages/database/core/migrate/migrate.integration.test.ts @@ -14,6 +14,9 @@ import { CORE_SCHEMA_VERSION } from "../version"; import backfill012 from "./incremental/012_default_groups.sql" with { type: "text", }; +import removeAgents018 from "./incremental/018_remove_agents.sql" with { + type: "text", +}; import { migrateCore } from "./migrate"; import { appliedMigrations, @@ -64,13 +67,13 @@ const EXPECTED_MIGRATIONS = [ "015_service_accounts", "016_api_key_last_used_on", "017_scoped_api_keys", + "018_remove_agents", ]; const EXPECTED_FUNCTIONS = [ "_delete_service_account_admin_group", "_enforce_service_account_admin_group_not_space_admin", "_enforce_service_account_principal_invariants", - "agent_tree_access", "api_key_declared_tree_access", "create_service_account", "enforce_group_space_coherence", @@ -181,7 +184,7 @@ describe("provisioned core schema", () => { }); describe("schema constraints enforce", () => { - test("principal.kind is restricted to g/u/a/s", async () => { + test("principal.kind is restricted to g/u/s", async () => { await expectReject(() => sql.unsafe( `insert into ${canonical.schema}.principal (kind, name) values ('x', 'bad-kind')`, @@ -224,24 +227,14 @@ describe("schema constraints enforce", () => { } }); - test("agent, group, and service account names are restricted to handle-safe characters", async () => { + test("group and service-account names are restricted to handle-safe characters", async () => { const s = canonical.schema; - const [owner] = await sql.unsafe(`select uuidv7() as id`); - const ownerId = owner?.id as string; - await sql.unsafe(`select ${s}.create_user($1, $2)`, [ - ownerId, - `owner_${crypto.randomUUID().slice(0, 8)}+alias@example.com`, - ]); const [space] = await sql.unsafe(`select ${s}.create_space($1, $2) as id`, [ randomSlug(), "Name Checks", ]); const spaceId = space?.id as string; - for (const ok of ["agent", "agent-v2", "ci_agent", "bot.v2"]) { - await sql.unsafe(`select ${s}.create_agent($1, $2)`, [ownerId, ok]); - } - await sql.unsafe(`select ${s}.create_group($1, $2)`, [ spaceId, "backend-team", @@ -260,9 +253,6 @@ describe("schema constraints enforce", () => { "-prod", ".group", ]) { - await expectReject(() => - sql.unsafe(`select ${s}.create_agent($1, $2)`, [ownerId, bad]), - ); await expectReject(() => sql.unsafe(`select ${s}.create_group($1, $2)`, [spaceId, bad]), ); @@ -292,7 +282,7 @@ describe("schema constraints enforce", () => { const [svc] = await sql.unsafe( `insert into ${s}.principal (kind, name, space_id, admin_id) values ('s', 'eon', $1, $2) - returning id, member_id, space_id, admin_id, user_id, agent_id, group_id`, + returning id, member_id, space_id, admin_id, user_id, group_id`, [spaceId, adminGroupId], ); @@ -301,7 +291,6 @@ describe("schema constraints enforce", () => { expect(svc?.space_id).toBe(spaceId); expect(svc?.admin_id).toBe(adminGroupId); expect(svc?.user_id).toBeNull(); - expect(svc?.agent_id).toBeNull(); expect(svc?.group_id).toBeNull(); await expectReject(() => @@ -451,9 +440,6 @@ describe("access-control functions are callable", () => { await sql.unsafe( `select * from ${s}.user_tree_access('${dummy}', '${dummy}')`, ); - await sql.unsafe( - `select * from ${s}.agent_tree_access('${dummy}', '${dummy}')`, - ); await sql.unsafe( `select * from ${s}.member_tree_access('${dummy}', '${dummy}')`, ); @@ -475,279 +461,106 @@ describe("access-control functions are callable", () => { }); }); -describe("agent_tree_access clamps agent access to its owner", () => { - // Regression for the `max(x.access)` + `group by tree_path` at the end of - // agent_tree_access (idempotent/003_tree_access.sql). Setup: - // - // agent grants: foo = owner(3), foo.bar = read(1) <- foo.bar is redundant, - // owner grants: foo.bar = write(2) already covered by foo=3 - // - // The inner UNION then emits foo.bar twice with different access levels: - // * arm 1 keeps the agent's (foo.bar, read) — the owner's foo.bar covers it - // * arm 2 keeps the owner's (foo.bar, write) — the agent's foo covers it - // Without the trailing max/group-by, agent_tree_access would return foo.bar - // twice; the effective access is the highest surviving row, (foo.bar, write). - // `foo` itself never surfaces — the owner grants nothing at or above it. - test("collapses the two clamp directions into one row per path", async () => { - await withTestCore(sql, {}, async (core) => { - const s = core.schema; - - const [space] = await sql.unsafe( - `insert into ${s}.space (slug, name) values ($1, $2) returning id`, - [randomSlug(), "clamp"], - ); - const spaceId = space?.id as string; - - const [owner] = await sql.unsafe( - `insert into ${s}.principal (kind, name) values ('u', 'owner') returning id`, - ); - const ownerId = owner?.id as string; - - const [agent] = await sql.unsafe( - `insert into ${s}.principal (kind, name, owner_id) values ('a', 'agent', $1) returning id`, - [ownerId], - ); - const agentId = agent?.id as string; - - // both principals must belong to the space for the access functions to see them - await sql.unsafe( - `insert into ${s}.principal_space (space_id, principal_id) values ($1, $2), ($1, $3)`, - [spaceId, ownerId, agentId], - ); - - await sql.unsafe( - `insert into ${s}.tree_access (space_id, principal_id, tree_path, access) values - ($1, $2, 'foo', 3), - ($1, $2, 'foo.bar', 1), - ($1, $3, 'foo.bar', 2)`, - [spaceId, agentId, ownerId], - ); - - const rows = await sql.unsafe( - `select tree_path::text as tree_path, access - from ${s}.agent_tree_access($1, $2) - order by tree_path`, - [agentId, spaceId], - ); - const result = rows.map((r) => ({ - tree_path: r.tree_path as string, - access: r.access as number, - })); - - // One clamped row, access collapsed to the max of the two union arms. - expect(result).toEqual([{ tree_path: "foo.bar", access: 2 }]); - }); - }); - - // Resolve an agent's effective (clamped) access given a set of owner grants and - // a set of agent grants, each as [tree_path, access] tuples. Owner and agent - // both join the space so the access functions can see them. - const effectiveAgentAccess = async ( - ownerGrants: [string, number][], - agentGrants: [string, number][], - ): Promise<{ tree_path: string; access: number }[]> => { - let out: { tree_path: string; access: number }[] = []; - await withTestCore(sql, {}, async (core) => { - const s = core.schema; - const [space] = await sql.unsafe( - `insert into ${s}.space (slug, name) values ($1, $2) returning id`, - [randomSlug(), "clamp"], - ); - const spaceId = space?.id as string; - const [owner] = await sql.unsafe( - `insert into ${s}.principal (kind, name) values ('u', 'owner') returning id`, - ); - const ownerId = owner?.id as string; - const [agent] = await sql.unsafe( - `insert into ${s}.principal (kind, name, owner_id) values ('a', 'agent', $1) returning id`, - [ownerId], - ); - const agentId = agent?.id as string; - await sql.unsafe( - `insert into ${s}.principal_space (space_id, principal_id) values ($1, $2), ($1, $3)`, - [spaceId, ownerId, agentId], - ); - const rows: [string, string, string, number][] = [ - ...ownerGrants.map( - ([p, a]) => - [spaceId, ownerId, p, a] as [string, string, string, number], - ), - ...agentGrants.map( - ([p, a]) => - [spaceId, agentId, p, a] as [string, string, string, number], - ), - ]; - for (const [sp, pr, path, acc] of rows) { - await sql.unsafe( - `insert into ${s}.tree_access (space_id, principal_id, tree_path, access) values ($1, $2, $3, $4)`, - [sp, pr, path, acc], - ); - } - const result = await sql.unsafe( - `select tree_path::text as tree_path, access - from ${s}.agent_tree_access($1, $2) - order by tree_path`, - [agentId, spaceId], - ); - out = result.map((r) => ({ - tree_path: r.tree_path as string, - access: r.access as number, - })); - }); - return out; - }; - - test("clamps DOWN (not away) when the agent grant exceeds the owner deeper", async () => { - // TNT-165: owner holds read@foo; the agent is granted write@foo.bar (deeper - // AND higher). The agent must end up with read@foo.bar — the owner's level — - // not nothing. (The old exists-based clamp dropped this row entirely.) - expect(await effectiveAgentAccess([["foo", 1]], [["foo.bar", 2]])).toEqual([ - { tree_path: "foo.bar", access: 1 }, +test("018 removes agent rows and their dependent state from an upgraded schema", async () => { + await withTestCore(sql, {}, async (core) => { + const s = core.schema; + const [space] = await sql.unsafe(`select ${s}.create_space($1, $2) as id`, [ + randomSlug(), + "Agent Upgrade", ]); - }); - - test("a broad agent grant is clamped to the owner's narrower coverage", async () => { - // owner owns only foo.deep; the agent is granted read across all of foo. The - // agent is effective only where the owner has coverage — read@foo.deep. - expect(await effectiveAgentAccess([["foo.deep", 3]], [["foo", 1]])).toEqual( - [{ tree_path: "foo.deep", access: 1 }], - ); - }); - - test("a grant the owner does not cover at all yields nothing", async () => { - // owner has access only under bar; an agent grant under foo has no owner - // coverage on its lineage, so it is dropped. - expect(await effectiveAgentAccess([["bar", 3]], [["foo", 2]])).toEqual([]); - }); - - test("write@root mirrors the owner's whole footprint, clamped to write", async () => { - // The agent is granted write at the root (the empty path). Root is an - // ancestor of every owner grant, so the agent inherits the owner's ENTIRE - // footprint, each path clamped to least(write, owner): the owner's owner@share - // becomes write@share, while the owner's read@docs stays read@docs. - expect( - await effectiveAgentAccess( - [ - ["share", 3], - ["docs", 1], - ], - [["", 2]], - ), - ).toEqual([ - { tree_path: "docs", access: 1 }, - { tree_path: "share", access: 2 }, + const spaceId = space?.id as string; + const ownerId = await sql.unsafe(`select uuidv7() as id`); + const userId = ownerId[0]?.id as string; + await sql.unsafe(`select ${s}.create_user($1, $2)`, [ + userId, + "upgrade-owner@example.com", + ]); + await sql.unsafe(`select ${s}.add_principal_to_space($1, $2)`, [ + spaceId, + userId, ]); - }); - test("no escalation across overlapping owner grants", async () => { - // Owners can hold many overlapping grants (the unique constraint is only on - // (space, principal, path)): here owner@foo.bar + read@foo + write@foo.bar.baz. - // The agent holds read@foo. Every covered path must resolve to read — the - // owner's deeper owner@foo.bar must NOT leak extra access to the agent. - const result = await effectiveAgentAccess( - [ - ["foo.bar", 3], - ["foo", 1], - ["foo.bar.baz", 2], - ], - [["foo", 1]], + // Recreate the pre-018 principal shape in an otherwise migrated schema, + // then execute the upgrade SQL directly. migrateCore below recreates the + // idempotent functions which the incremental intentionally drops first. + await sql.unsafe( + `alter table ${s}.principal + drop constraint principal_kind_check, + drop constraint principal_handle_name_check, + add column agent_id uuid unique nulls distinct generated always as + (case when kind = 'a' then id else null end) stored, + add column owner_id uuid references ${s}.principal (user_id) on delete cascade, + alter column member_id set expression as + (case when kind in ('u', 'a', 's') then id else null end), + add constraint principal_kind_check check (kind in ('g', 'u', 'a', 's')), + add constraint principal_owner_check check + ((kind = 'a' and owner_id is not null) or (kind != 'a' and owner_id is null)), + add constraint principal_agent_group_name_check check + (kind not in ('a', 'g', 's') or name::text ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$')`, + ); + const [agent] = await sql.unsafe( + `insert into ${s}.principal (kind, name, owner_id) + values ('a', 'upgrade-agent', $1) + returning id`, + [userId], + ); + const agentId = agent?.id as string; + await sql.unsafe( + `insert into ${s}.principal_space (space_id, principal_id) values ($1, $2)`, + [spaceId, agentId], ); - expect(result).toEqual([ - { tree_path: "foo", access: 1 }, - { tree_path: "foo.bar", access: 1 }, - { tree_path: "foo.bar.baz", access: 1 }, + await sql.unsafe( + `insert into ${s}.tree_access (space_id, principal_id, tree_path, access) + values ($1, $2, 'share', 2)`, + [spaceId, agentId], + ); + const [group] = await sql.unsafe(`select ${s}.create_group($1, $2) as id`, [ + spaceId, + "upgrade-group", ]); - // no path resolves above read - expect(result.every((r) => r.access <= 1)).toBe(true); - }); + await sql.unsafe( + `insert into ${s}.group_member (space_id, group_id, member_id) + values ($1, $2, $3)`, + [spaceId, group?.id as string, agentId], + ); + const [key] = await sql.unsafe( + `select ${s}.create_api_key($1, $2, $3, $4) as id`, + [agentId, "upgradeAgentKey1", "hashed-secret", "upgrade-agent-key"], + ); - test("an explicit over-grant clamps down to the owner's level", async () => { - // agent granted owner@foo where the owner only reads foo → clamps to read@foo. - expect(await effectiveAgentAccess([["foo", 1]], [["foo", 3]])).toEqual([ - { tree_path: "foo", access: 1 }, - ]); - }); + await sql.unsafe(template(removeAgents018, { schema: s })); - test("clamps against the owner's group-inherited access (same path, two groups)", async () => { - // The one case the (space, principal, path) unique constraint doesn't cover: - // the owner receives the SAME path at two levels via two separate groups - // (read@foo via g1, write@foo via g2). user_tree_access unions both, so the - // owner's effective foo is write (the max). An agent granted owner@foo must - // clamp to write@foo — exercising the union/group path in member_tree_access - // → user_tree_access that the single-grant tests skip. - await withTestCore(sql, {}, async (core) => { - const s = core.schema; - const [space] = await sql.unsafe( - `insert into ${s}.space (slug, name) values ($1, $2) returning id`, - [randomSlug(), "groups"], - ); - const spaceId = space?.id as string; - const [owner] = await sql.unsafe( - `insert into ${s}.principal (kind, name) values ('u', 'owner') returning id`, - ); - const ownerId = owner?.id as string; - const [agent] = await sql.unsafe( - `insert into ${s}.principal (kind, name, owner_id) values ('a', 'agent', $1) returning id`, - [ownerId], - ); - const agentId = agent?.id as string; - await sql.unsafe( - `insert into ${s}.principal_space (space_id, principal_id) values ($1, $2), ($1, $3)`, - [spaceId, ownerId, agentId], + for (const [table, column] of [ + ["principal", "id"], + ["principal_space", "principal_id"], + ["tree_access", "principal_id"], + ["group_member", "member_id"], + ["api_key", "id"], + ]) { + const [row] = await sql.unsafe( + `select count(*)::int as count from ${s}.${table} where ${column} = $1`, + [table === "api_key" ? key?.id : agentId], ); + expect(row?.count).toBe(0); + } + const columns = await sql.unsafe( + `select column_name from information_schema.columns + where table_schema = $1 and table_name = 'principal'`, + [s], + ); + expect(columns.map((column) => column.column_name)).not.toContain( + "agent_id", + ); + expect(columns.map((column) => column.column_name)).not.toContain( + "owner_id", + ); + await expectReject(() => + sql.unsafe( + `insert into ${s}.principal (kind, name) values ('a', 'removed-agent')`, + ), + ); - // two groups the owner belongs to, each granting foo at a different level - const [g1] = await sql.unsafe(`select ${s}.create_group($1, $2) as id`, [ - spaceId, - "readers", - ]); - const [g2] = await sql.unsafe(`select ${s}.create_group($1, $2) as id`, [ - spaceId, - "writers", - ]); - const g1Id = g1?.id as string; - const g2Id = g2?.id as string; - for (const gid of [g1Id, g2Id]) { - await sql.unsafe(`select ${s}.add_group_member($1, $2, $3)`, [ - spaceId, - gid, - ownerId, - ]); - } - await sql.unsafe(`select ${s}.grant_tree_access($1, $2, $3::ltree, $4)`, [ - spaceId, - g1Id, - "foo", - 1, - ]); - await sql.unsafe(`select ${s}.grant_tree_access($1, $2, $3::ltree, $4)`, [ - spaceId, - g2Id, - "foo", - 2, - ]); - // the agent is granted owner@foo directly - await sql.unsafe(`select ${s}.grant_tree_access($1, $2, $3::ltree, $4)`, [ - spaceId, - agentId, - "foo", - 3, - ]); - - const rows = await sql.unsafe( - `select tree_path::text as tree_path, access - from ${s}.agent_tree_access($1, $2) - order by tree_path`, - [agentId, spaceId], - ); - // owner's effective foo = max(read, write) = write; the agent clamps to it - expect( - rows.map((r) => ({ - tree_path: r.tree_path as string, - access: r.access as number, - })), - ).toEqual([{ tree_path: "foo", access: 2 }]); - }); + await migrateCore(sql, { schema: s }); }); }); @@ -760,10 +573,6 @@ describe("control-plane functions", () => { /** A principal's canonical home path (mirrors space/path.ts homePrefix). */ const homePath = (id: string) => `home.${id.replace(/-/g, "")}`; - /** An agent's home nests under its owner: home... */ - const agentHomePath = (ownerId: string, agentId: string) => - `${homePath(ownerId)}.${agentId.replace(/-/g, "")}`; - type Grant = { tree_path: string; access: number }; test("012 backfill: adds a rostered 'team' group + grants for spaces lacking one (idempotent, non-clobbering)", async () => { @@ -885,7 +694,7 @@ describe("control-plane functions", () => { }); }); - test("add_principal_to_space grants owner@home to users and agents (nested), idempotently; not groups or service accounts", async () => { + test("add_principal_to_space grants owner@home to users, idempotently; not groups or service accounts", async () => { await withTestCore(sql, {}, async (core) => { const s = core.schema; const [sp] = await sql.unsafe(`select ${s}.create_space($1, $2) as id`, [ @@ -896,12 +705,6 @@ describe("control-plane functions", () => { const userId = await v7(); await sql.unsafe(`select ${s}.create_user($1, $2)`, [userId, "homer"]); - const agentId = await v7(); - await sql.unsafe(`select ${s}.create_agent($1, $2, $3)`, [ - userId, // owner - `agent_${randomSlug()}`, // name - agentId, // id - ]); const [grp] = await sql.unsafe(`select ${s}.create_group($1, $2) as id`, [ spaceId, `grp_${randomSlug()}`, @@ -914,7 +717,7 @@ describe("control-plane functions", () => { const serviceId = svc?.id as string; // add each twice to prove the home grant is idempotent - for (const id of [userId, agentId, groupId, serviceId]) { + for (const id of [userId, groupId, serviceId]) { await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, $3)`, [ spaceId, id, @@ -939,24 +742,8 @@ describe("control-plane functions", () => { expect(await grants(userId)).toEqual([ { tree_path: homePath(userId), access: 3 }, ]); - // the agent gets owner@home nested under its owner (covered by the owner's - // home grant, so agent_tree_access keeps it); groups and service accounts - // have no home - expect(await grants(agentId)).toEqual([ - { tree_path: agentHomePath(userId, agentId), access: 3 }, - ]); expect(await grants(groupId)).toEqual([]); expect(await grants(serviceId)).toEqual([]); - - // the agent's nested home is EFFECTIVE (not clamped to nothing): the - // owner holds owner@home., which covers home... - const [agentTa] = await sql.unsafe( - `select ${s}.build_tree_access($1, $2) as ta`, - [agentId, spaceId], - ); - expect(agentTa?.ta as Grant[]).toEqual([ - { tree_path: agentHomePath(userId, agentId), access: 3 }, - ]); }); }); @@ -972,14 +759,7 @@ describe("control-plane functions", () => { const userId = await v7(); await sql.unsafe(`select ${s}.create_user($1, $2)`, [userId, "no-home"]); - const agentId = await v7(); - await sql.unsafe(`select ${s}.create_agent($1, $2, $3)`, [ - userId, - `agent_${randomSlug()}`, - agentId, - ]); - - for (const id of [userId, agentId]) { + for (const id of [userId]) { await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, $3)`, [ spaceId, id, @@ -995,9 +775,8 @@ describe("control-plane functions", () => { ); return rows as unknown as Grant[]; }; - // neither the user nor the agent gets an owner@home grant + // the user gets no owner@home grant expect(await grants(userId)).toEqual([]); - expect(await grants(agentId)).toEqual([]); }); }); @@ -1019,11 +798,6 @@ describe("control-plane functions", () => { groupAdminId, "svc-group-admin@example.com", ]); - const [agent] = await sql.unsafe( - `select ${s}.create_agent($1, $2) as id`, - [memberId, `svc_agent_${randomSlug()}`], - ); - const agentId = agent?.id as string; const [memberService] = await sql.unsafe( `select * from ${s}.create_service_account($1, $2, $3::uuid[], $4::uuid[])`, [spaceId, "helper", [], []], @@ -1032,7 +806,7 @@ describe("control-plane functions", () => { const [svc] = await sql.unsafe( `select * from ${s}.create_service_account($1, $2, $3::uuid[], $4::uuid[])`, - [spaceId, "eon", [memberId, agentId], [groupAdminId, memberServiceId]], + [spaceId, "eon", [memberId], [groupAdminId, memberServiceId]], ); const serviceId = svc?.id as string; const adminGroupId = svc?.admin_id as string; @@ -1087,10 +861,6 @@ describe("control-plane functions", () => { member_id: groupAdminId, admin: true, }); - expect(normalizedMembers).toContainEqual({ - member_id: agentId, - admin: false, - }); expect(normalizedMembers).toContainEqual({ member_id: memberServiceId, admin: true, @@ -1100,24 +870,15 @@ describe("control-plane functions", () => { spaceId, memberId, ]); - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, false)`, [ - spaceId, - agentId, - ]); const [userAdmin] = await sql.unsafe( `select ${s}.is_service_account_admin($1, $2) as ok`, [serviceId, memberId], ); - const [agentAdmin] = await sql.unsafe( - `select ${s}.is_service_account_admin($1, $2) as ok`, - [serviceId, agentId], - ); const [serviceAdmin] = await sql.unsafe( `select ${s}.is_service_account_admin($1, $2) as ok`, [serviceId, memberServiceId], ); expect(userAdmin?.ok).toBe(true); - expect(agentAdmin?.ok).toBe(false); expect(serviceAdmin?.ok).toBe(false); const [helper] = await sql.unsafe( @@ -1148,7 +909,6 @@ describe("control-plane functions", () => { ["lookupservice002", "hashed-secret"], ); expect(validated?.member_id).toBe(serviceId); - expect(validated?.owner_id).toBeNull(); }); }); @@ -1275,11 +1035,6 @@ describe("control-plane functions", () => { userId, "svc-admin@example.com", ]); - const [agent] = await sql.unsafe( - `select ${s}.create_agent($1, $2) as id`, - [userId, `svc_agent_${randomSlug()}`], - ); - const agentId = agent?.id as string; const [svc] = await sql.unsafe( `select * from ${s}.create_service_account($1, $2, $3::uuid[], $4::uuid[])`, [spaceId, "ci", [userId], []], @@ -1287,15 +1042,6 @@ describe("control-plane functions", () => { const serviceId = svc?.id as string; const adminGroupId = svc?.admin_id as string; - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, false)`, [ - spaceId, - agentId, - ]); - await sql.unsafe(`select ${s}.add_group_member($1, $2, $3, false)`, [ - spaceId, - adminGroupId, - agentId, - ]); await sql.unsafe(`select ${s}.add_group_member($1, $2, $3, false)`, [ spaceId, adminGroupId, @@ -1442,13 +1188,11 @@ describe("control-plane functions", () => { const groupAdmin = await v7(); const regular = await v7(); const groupOnly = await v7(); - const agentOwner = await v7(); for (const [id, name] of [ [directAdmin, "direct@example.com"], [groupAdmin, "group@example.com"], [regular, "regular@example.com"], [groupOnly, "group-only@example.com"], - [agentOwner, "owner@example.com"], ] as const) { await sql.unsafe(`select ${s}.create_user($1, $2)`, [id, name]); } @@ -1464,10 +1208,6 @@ describe("control-plane functions", () => { spaceId, regular, ]); - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, false)`, [ - spaceId, - agentOwner, - ]); const [groupRow] = await sql.unsafe( `select ${s}.create_group($1, $2, true) as id`, @@ -1485,14 +1225,6 @@ describe("control-plane functions", () => { groupOnly, ]); - const [agentRow] = await sql.unsafe( - `select ${s}.create_agent($1, $2) as id`, - [agentOwner, "agent-admin"], - ); - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, true)`, [ - spaceId, - agentRow?.id as string, - ]); const [serviceRow] = await sql.unsafe( `select id from ${s}.create_service_account($1, $2)`, [spaceId, "service-admin"], @@ -1974,114 +1706,6 @@ describe("control-plane functions", () => { }); }); - test("remove_principal_from_space cascades a user's owned agents (space-scoped)", async () => { - await withTestCore(sql, {}, async (core) => { - const s = core.schema; - const [sp1] = await sql.unsafe(`select ${s}.create_space($1, $2) as id`, [ - randomSlug(), - "One", - ]); - const [sp2] = await sql.unsafe(`select ${s}.create_space($1, $2) as id`, [ - randomSlug(), - "Two", - ]); - const space1 = sp1?.id as string; - const space2 = sp2?.id as string; - const userId = await v7(); - await sql.unsafe(`select ${s}.create_user($1, $2)`, [userId, "frank"]); - - // agent1 lives in space1 (with a grant + group membership); agent2 in space2. - const [a1] = await sql.unsafe(`select ${s}.create_agent($1, $2) as id`, [ - userId, - "agent-one", - ]); - const [a2] = await sql.unsafe(`select ${s}.create_agent($1, $2) as id`, [ - userId, - "agent-two", - ]); - const agent1 = a1?.id as string; - const agent2 = a2?.id as string; - - // roster the user + agent1 into space1 - for (const id of [userId, agent1]) { - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, $3)`, [ - space1, - id, - false, - ]); - } - // agent1 gets a grant and a group membership in space1 - const [grp] = await sql.unsafe(`select ${s}.create_group($1, $2) as id`, [ - space1, - "team", - ]); - const groupId = grp?.id as string; - await sql.unsafe(`select ${s}.add_group_member($1, $2, $3, $4)`, [ - space1, - groupId, - agent1, - false, - ]); - await sql.unsafe(`select ${s}.grant_tree_access($1, $2, $3::ltree, $4)`, [ - space1, - agent1, - "share", - 2, - ]); - - // agent2 is rostered into space2 (a different space); the join grants it - // owner over its home directory (the one tree_access row we assert stays). - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, $3)`, [ - space2, - agent2, - false, - ]); - - const [removed] = await sql.unsafe( - `select ${s}.remove_principal_from_space($1, $2) as removed`, - [space1, userId], - ); - expect(removed?.removed).toBe(true); - - const count = async ( - table: string, - col: string, - id: string, - spaceId: string, - ) => { - const [r] = await sql.unsafe( - `select count(*)::int as n from ${s}.${table} where space_id=$1 and ${col}=$2`, - [spaceId, id], - ); - return Number(r?.n); - }; - - // agent1 is fully deprovisioned from space1: membership, grant, group row - expect( - await count("principal_space", "principal_id", agent1, space1), - ).toBe(0); - expect(await count("tree_access", "principal_id", agent1, space1)).toBe( - 0, - ); - expect(await count("group_member", "member_id", agent1, space1)).toBe(0); - - // but agent1's `principal` row itself survives (it was not deleted) - const [pr] = await sql.unsafe( - `select count(*)::int as n from ${s}.principal where id=$1`, - [agent1], - ); - expect(Number(pr?.n)).toBe(1); - - // agent2 in the OTHER space is untouched (membership + its home grant) - expect( - await count("principal_space", "principal_id", agent2, space2), - ).toBe(1); - expect(await count("tree_access", "principal_id", agent2, space2)).toBe( - 1, - ); - }); - }); - test("group_member.space_id is pinned to the group's space (composite FK)", async () => { await withTestCore(sql, {}, async (core) => { const s = core.schema; @@ -2723,13 +2347,11 @@ describe("control-plane functions", () => { ]); const valid = await sql.unsafe( - `select member_id, owner_id from ${s}.validate_api_key($1, $2)`, + `select member_id from ${s}.validate_api_key($1, $2)`, [lookup, "hashed-secret"], ); expect(valid.length).toBe(1); expect(valid[0]?.member_id).toBe(userId); - // a user key has no owner - expect(valid[0]?.owner_id).toBeNull(); const [afterValidate] = await sql.unsafe( `select last_used_on from ${s}.api_key where lookup_id = $1`, [lookup], @@ -2783,27 +2405,6 @@ describe("control-plane functions", () => { ); expect(listed?.last_used_on).toBe("2026-07-18"); - // an agent key reports the agent's owner (drives `~` home nesting) - const agentId = await v7(); - await sql.unsafe(`select ${s}.create_agent($1, $2, $3)`, [ - userId, // owner - `agent_${randomSlug()}`, - agentId, - ]); - const agentLookup = "AGENTlookup12345"; - await sql.unsafe(`select ${s}.create_api_key($1, $2, $3, $4)`, [ - agentId, - agentLookup, - "agent-secret", - "agent-key", - ]); - const agentValid = await sql.unsafe( - `select member_id, owner_id from ${s}.validate_api_key($1, $2)`, - [agentLookup, "agent-secret"], - ); - expect(agentValid[0]?.member_id).toBe(agentId); - expect(agentValid[0]?.owner_id).toBe(userId); - const wrong = await sql.unsafe( `select member_id from ${s}.validate_api_key($1, $2)`, [lookup, "nope"], @@ -3035,51 +2636,29 @@ describe("control-plane functions", () => { }); }); - test("scoped API keys reject agent holders and empty access arrays", async () => { + test("scoped API keys reject empty access arrays", async () => { await withTestCore(sql, {}, async (core) => { const s = core.schema; const [sp] = await sql.unsafe(`select ${s}.create_space($1, $2) as id`, [ randomSlug(), - "Agent Reject", + "Scoped Key Validation", ]); const spaceId = sp?.id as string; - const ownerId = await v7(); + const userId = await v7(); await sql.unsafe(`select ${s}.create_user($1, $2)`, [ - ownerId, - "agent-owner", - ]); - await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, $3)`, [ - spaceId, - ownerId, - false, + userId, + "key-holder", ]); - const [agent] = await sql.unsafe( - `select ${s}.create_agent($1, $2) as id`, - [ownerId, `agent_${randomSlug()}`], - ); - const agentId = agent?.id as string; await sql.unsafe(`select ${s}.add_principal_to_space($1, $2, $3)`, [ spaceId, - agentId, + userId, false, ]); - // Agent keys cannot carry scope declarations — the space_access - // invariant trigger rejects kind='a'. - await expectReject(() => - sql.unsafe(`select ${s}.create_api_key($1, $2, $3, $4, null, $5)`, [ - agentId, - "scopeAgentKey001", - "secret", - "agent-scoped", - sql.json([{ space_id: spaceId, grants: [] }]), - ]), - ); - // Empty and non-array _access payloads are rejected up-front (22023). await expectReject(() => sql.unsafe(`select ${s}.create_api_key($1, $2, $3, $4, null, $5)`, [ - ownerId, + userId, "scopeEmptyArr001", "secret", "empty", @@ -3088,7 +2667,7 @@ describe("control-plane functions", () => { ); await expectReject(() => sql.unsafe(`select ${s}.create_api_key($1, $2, $3, $4, null, $5)`, [ - ownerId, + userId, "scopeBadShape001", "secret", "not-array", @@ -3096,10 +2675,10 @@ describe("control-plane functions", () => { ]), ); - // An unrestricted agent key still works (no _access provided). + // An unrestricted user key still works (no _access provided). const [ok] = await sql.unsafe( `select ${s}.create_api_key($1, $2, $3, $4) as id`, - [agentId, "scopeAgentPlain0", "secret", "plain-agent"], + [userId, "scopePlainKey001", "secret", "plain"], ); expect(ok?.id).toBeTruthy(); }); @@ -3517,10 +3096,10 @@ describe("migration behavior", () => { }); }); - // list_space_principals dropped its `direct` output column, a returns-table - // signature change create-or-replace can't do. The migration guards the drop - // so a current (or absent) definition is never churned — only a stale one is - // dropped + recreated. + // list_space_principals dropped its `owner_id` and `direct` output columns, + // returns-table signature changes create-or-replace can't do. The migration + // guards the drop so a current (or absent) definition is never churned — only + // a stale one is dropped + recreated. test("list_space_principals signature guard: no churn when current, upgrades a stale definition", async () => { await withTestCore(sql, {}, async (core) => { const s = core.schema; @@ -3535,8 +3114,9 @@ describe("migration behavior", () => { return row as { oid: string; proargnames: string[] } | undefined; }; - // fresh provision: current signature (no `direct` output column) + // fresh provision: current signature (no `owner_id` or `direct` output columns) const fresh = await fn(); + expect(fresh?.proargnames).not.toContain("owner_id"); expect(fresh?.proargnames).not.toContain("direct"); // re-migrate: already current, so the guard must NOT drop it — the oid is diff --git a/packages/database/core/migrate/migrate.ts b/packages/database/core/migrate/migrate.ts index 14fdcf04..3ad1386a 100644 --- a/packages/database/core/migrate/migrate.ts +++ b/packages/database/core/migrate/migrate.ts @@ -94,6 +94,9 @@ import incremental016 from "./incremental/016_api_key_last_used_on.sql" with { import incremental017 from "./incremental/017_scoped_api_keys.sql" with { type: "text", }; +import incremental018 from "./incremental/018_remove_agents.sql" with { + type: "text", +}; import provisionSql from "./provision.sql" with { type: "text" }; const DIR = "packages/database/core/migrate"; @@ -180,6 +183,11 @@ const incrementals: Migration[] = [ file: "incremental/017_scoped_api_keys.sql", sql: incremental017, }, + { + name: "018_remove_agents", + file: "incremental/018_remove_agents.sql", + sql: incremental018, + }, ]; const idempotents: Migration[] = [ diff --git a/packages/database/core/version.ts b/packages/database/core/version.ts index acb22d68..da51863e 100644 --- a/packages/database/core/version.ts +++ b/packages/database/core/version.ts @@ -1 +1 @@ -export const CORE_SCHEMA_VERSION = "0.0.5"; +export const CORE_SCHEMA_VERSION = "0.0.6"; diff --git a/packages/database/space/path.test.ts b/packages/database/space/path.test.ts index 60aebe83..02d182fa 100644 --- a/packages/database/space/path.test.ts +++ b/packages/database/space/path.test.ts @@ -10,9 +10,6 @@ import { const ID = "0199c2a4-f8e1-7b3c-9d2e-5a6f08b4c1d7"; const HOME = "home.0199c2a4f8e17b3c9d2e5a6f08b4c1d7"; -// An agent's home nests under its owner's: home... -const AGENT = "0199dddd-1111-7222-8333-444455556666"; -const AGENT_HOME = `home.${ID.replace(/-/g, "")}.${AGENT.replace(/-/g, "")}`; describe("normalizeTreePath", () => { test("root forms collapse to the empty path", () => { @@ -47,13 +44,6 @@ describe("normalizeTreePath", () => { expect(normalizeTreePath("~/a/b", { home: ID })).toBe(`${HOME}.a.b`); }); - test("an agent's ~ nests under its owner's home (homeOwner)", () => { - const opts = { home: AGENT, homeOwner: ID }; - expect(normalizeTreePath("~", opts)).toBe(AGENT_HOME); - expect(normalizeTreePath("~/bar", opts)).toBe(`${AGENT_HOME}.bar`); - expect(normalizeTreePath("~/a/b", opts)).toBe(`${AGENT_HOME}.a.b`); - }); - test("~ requires a home and is only valid as the first segment", () => { expect(() => normalizeTreePath("~/bar")).toThrow(TreePathError); expect(() => normalizeTreePath("foo/~/bar", { home: ID })).toThrow( @@ -149,10 +139,6 @@ describe("homePrefix", () => { test("strips hyphens from the principal id", () => { expect(homePrefix(ID)).toBe(HOME); }); - - test("nests under the owner when an owner id is given (agent home)", () => { - expect(homePrefix(AGENT, ID)).toBe(AGENT_HOME); - }); }); describe("denormalizeTreePath", () => { @@ -186,14 +172,4 @@ describe("denormalizeTreePath", () => { const abs = denormalizeTreePath("work.projects"); // /work/projects expect(normalizeTreePath(abs)).toBe("work.projects"); // leading slash stripped }); - - test("reverse-maps an agent's nested home (homeOwner) to ~", () => { - const opts = { home: AGENT, homeOwner: ID }; - expect(denormalizeTreePath(AGENT_HOME, opts)).toBe("~"); - expect(denormalizeTreePath(`${AGENT_HOME}.a.b`, opts)).toBe("~/a/b"); - // the owner's own home (one level up) is NOT the agent's ~ - expect(denormalizeTreePath(HOME, opts)).toBe( - `/${HOME.replace(/\./g, "/")}`, - ); - }); }); diff --git a/packages/database/space/path.ts b/packages/database/space/path.ts index 6136c186..4c0d5c21 100644 --- a/packages/database/space/path.ts +++ b/packages/database/space/path.ts @@ -11,9 +11,8 @@ * trailing separators are dropped. `/a/b`, `a/b`, `a.b` → `a.b`. * - **Root**: ``, `/`, `.` → `` (the empty ltree path). * - **Home**: a leading `~` segment expands to the caller's home prefix — - * `home.` for a user, or `home..` for an agent (whose - * home nests under its owner's home; see `homePrefix`). Ids have hyphens - * stripped to valid ltree labels. `~` → the prefix, `~/bar` → + * `home.` for a user. Ids have hyphens stripped to valid ltree + * labels. `~` → the prefix, `~/bar` → * `.bar`. `~` is only meaningful as the first segment. * - **Labels** (concrete paths): each segment must be a legal ltree label * (`[A-Za-z0-9_-]+`, PG16+); anything else throws `TreePathError`. @@ -61,27 +60,14 @@ export interface TreePathOptions { * omitting it makes a `~` segment an error. */ home?: string; - /** - * When set, the home nests under this owner's home: `~` → - * `home..`. Set it for agents (whose home lives under their - * owner's home so the owner's grant covers it); omit for users. - */ - homeOwner?: string; } /** * The canonical ltree prefix for a principal's home (ids hyphen-stripped to - * valid ltree labels): - * - user (no owner): `home.` - * - agent (owner given): `home..` — nested under the owner's - * home so the owner's `owner@home.` grant covers it and - * `agent_tree_access` keeps the agent's home grant (a bare `home.` - * would be clamped to nothing). Mirrors core `add_principal_to_space`. + * valid ltree labels). Mirrors core `add_principal_to_space`. */ -export function homePrefix(principalId: string, ownerId?: string): string { - const self = homeLabel(principalId); - if (ownerId === undefined) return `${HOME_NAMESPACE}.${self}`; - return `${HOME_NAMESPACE}.${homeLabel(ownerId)}.${self}`; +export function homePrefix(principalId: string): string { + return `${HOME_NAMESPACE}.${homeLabel(principalId)}`; } /** A principal id as a valid ltree label (hyphens stripped). */ @@ -121,7 +107,7 @@ export function normalizeTreePath( if (opts.home === undefined) { throw new TreePathError("'~' (home) is not available here"); } - out.push(homePrefix(opts.home, opts.homeOwner)); // valid `home.<…>` ltree + out.push(homePrefix(opts.home)); // valid `home.<…>` ltree continue; } if (!LTREE_LABEL.test(seg)) { @@ -151,7 +137,7 @@ export function normalizeTreeFilter( if (opts.home === undefined) { throw new TreePathError("'~' (home) is not available here"); } - s = homePrefix(opts.home, opts.homeOwner) + s.slice(1); // "~/foo" → "/foo" + s = homePrefix(opts.home) + s.slice(1); // "~/foo" → "/foo" } // Slash → dot, collapse separator runs, trim ends. @@ -214,7 +200,7 @@ export function denormalizeTreePath( opts: TreePathOptions = {}, ): string { if (opts.home !== undefined) { - const prefix = homePrefix(opts.home, opts.homeOwner); + const prefix = homePrefix(opts.home); if (path === prefix) return "~"; if (path.startsWith(`${prefix}.`)) { // home..a.b → ~/a/b — `~` is the anchor, so no leading slash diff --git a/packages/engine/core/core.integration.test.ts b/packages/engine/core/core.integration.test.ts index 1b2a2982..47d83c22 100644 --- a/packages/engine/core/core.integration.test.ts +++ b/packages/engine/core/core.integration.test.ts @@ -96,15 +96,6 @@ test("listSpacePrincipals excludes group-only principals (not space members)", a expect(ids).not.toContain(groupOnlyId); }); -test("agents appear as space members of kind 'a'", async () => { - const agentId = await core.createAgent(userId, `agent-${rand(6)}`); - await core.addPrincipalToSpace(spaceId, agentId); - const agents = await core.listSpacePrincipals(spaceId, "a"); - expect(agents).toHaveLength(1); - expect(agents[0]?.id).toBe(agentId); - expect(agents[0]?.ownerId).toBe(userId); -}); - test("groups: create, list, rename, members, delete", async () => { const groupId = await core.createGroup(spaceId, "eng"); let groups = await core.listSpaceGroups(spaceId); @@ -173,7 +164,7 @@ test("provisionDefaultGroup: idempotent, rostered 'team' group with read@share + }); }); -test("custom space (autoGrantHome=false): joining users AND agents get no owner@~", async () => { +test("custom space (autoGrantHome=false): joining users gets no owner@~", async () => { const sid = await core.createSpace(rand(12), "Custom", { autoGrantHome: false, }); @@ -185,11 +176,6 @@ test("custom space (autoGrantHome=false): joining users AND agents get no owner@ expect(await core.listTreeAccessGrants(sid, u)).toEqual([]); expect(await core.buildTreeAccess(u, sid)).toEqual([]); - // a joining agent likewise gets no home grant - const agent = await core.createAgent(u, `a_${rand(6)}`); - await core.addPrincipalToSpace(sid, agent); - expect(await core.listTreeAccessGrants(sid, agent)).toEqual([]); - // contrast: a standard space DOES seed owner@~ for a joining user const sid2 = await core.createSpace(rand(12), "Standard", { autoGrantHome: true, @@ -249,7 +235,7 @@ test("createGroup rosters the group into principal_space (admin=false, no home g const all = await core.listSpacePrincipals(spaceId); expect(all.map((p) => p.id)).toContain(groupId); - // groups get NO home grant (only users/agents do), so the group holds no + // groups get NO home grant (only users do), so the group holds no // tree_access of its own and cannot authenticate (build_tree_access empty). expect(await core.listTreeAccessGrants(spaceId, groupId)).toEqual([]); expect(await core.buildTreeAccess(groupId, spaceId)).toEqual([]); @@ -295,42 +281,6 @@ test("removePrincipalFromSpace rejects a group (a group leaves only by deletion) ).toBe(false); }); -test("removePrincipalFromSpace cascades to owned agents (space-scoped)", async () => { - // a second, non-admin member (removing the beforeEach admin would trip ME001) - const member = await v7(); - await core.createUser(member, `m_${rand(8)}@example.com`); - await core.addPrincipalToSpace(spaceId, member); - - // agent1 lives in this space with a grant + a group membership - const agent1 = await core.createAgent(member, `a1-${rand(6)}`); - await core.addPrincipalToSpace(spaceId, agent1); - await core.grantTreeAccess(spaceId, agent1, "share", ACCESS.write); - const groupId = await core.createGroup(spaceId, `g-${rand(6)}`); - await core.addGroupMember(spaceId, groupId, agent1); - - // agent2 lives in a different space (must stay untouched) - const otherSpace = await core.createSpace(rand(12), "Other"); - const agent2 = await core.createAgent(member, `a2-${rand(6)}`); - await core.addPrincipalToSpace(otherSpace, agent2); - - expect(await core.removePrincipalFromSpace(spaceId, member)).toBe(true); - - // the member and its in-space agent are both off this space's roster - const ids = (await core.listSpacePrincipals(spaceId)).map((p) => p.id); - expect(ids).not.toContain(member); - expect(ids).not.toContain(agent1); - // agent1's grant + group membership in this space are scrubbed - expect(await core.listTreeAccessGrants(spaceId, agent1)).toHaveLength(0); - expect(await core.listGroupMembers(spaceId, groupId)).toHaveLength(0); - - // but agent1's principal row itself survives (only its space rows were removed) - expect((await core.getPrincipal(agent1))?.id).toBe(agent1); - // and agent2 in the OTHER space is untouched - expect( - (await core.listSpacePrincipals(otherSpace)).map((p) => p.id), - ).toContain(agent2); -}); - test("admin groups: create as admin, toggle via setGroupIsSpaceAdmin, confer admin to direct members", async () => { // create the group directly as an admin group const groupId = await core.createGroup(spaceId, `adm_${rand(6)}`, true); @@ -440,8 +390,6 @@ test("listEffectiveSpaceAdmins returns only direct-member user admins", async () await core.createUser(groupOnly, `group_only_${rand(8)}@example.com`); await core.addGroupMember(spaceId, groupId, groupOnly); - const agentId = await core.createAgent(userId, `agent_${rand(6)}`); - await core.addPrincipalToSpace(spaceId, agentId, true); const serviceAccount = await core.createServiceAccount( spaceId, `svc_${rand(6)}`, diff --git a/packages/engine/core/db.integration.test.ts b/packages/engine/core/db.integration.test.ts index e18ab34c..115bf219 100644 --- a/packages/engine/core/db.integration.test.ts +++ b/packages/engine/core/db.integration.test.ts @@ -66,7 +66,6 @@ test("createUser + getPrincipal", async () => { const p = await db.getPrincipal(userId); expect(p?.id).toBe(userId); expect(p?.kind).toBe("u"); - expect(p?.ownerId).toBeNull(); expect(p?.spaceId).toBeNull(); }); @@ -137,7 +136,6 @@ test("createApiKey + validateApiKey (good / wrong secret)", async () => { const valid = await db.validateApiKey(key.lookupId, key.secret); expect(valid?.memberId).toBe(userId); expect(valid?.apiKeyId).toBe(key.id); - expect(valid?.ownerId).toBeNull(); // a user key has no owner // kind + name come back with validation, so the auth middleware needs no // second principal lookup. expect(valid?.kind).toBe("u"); @@ -266,7 +264,6 @@ test("service account lifecycle methods wrap the SQL functions", async () => { const key = await db.createApiKey(account.id, "robot-key"); expect(await db.validateApiKey(key.lookupId, key.secret)).toMatchObject({ memberId: account.id, - ownerId: null, }); expect( diff --git a/packages/engine/core/db.ts b/packages/engine/core/db.ts index 3e727047..2de9142f 100644 --- a/packages/engine/core/db.ts +++ b/packages/engine/core/db.ts @@ -57,7 +57,6 @@ export interface CoreStore { deleteSpace(slug: string): Promise; createUser(id: string, name: string): Promise; - createAgent(ownerId: string, name: string, id?: string): Promise; /** * Create a group, rostered into its space. `isSpaceAdmin` makes it an admin * group (its space-admin authority flows to direct-member users); defaults @@ -86,7 +85,7 @@ export interface CoreStore { getPrincipal(id: string): Promise; /** Resolve a global user (kind 'u') by name (email). */ getUserByName(name: string): Promise; - /** Rename an agent or group (never a user — its name is its identity email). */ + /** Rename a group or service account (never a user — its name is its identity email). */ renamePrincipal(id: string, name: string): Promise; deletePrincipal(id: string): Promise; @@ -97,7 +96,7 @@ export interface CoreStore { ): Promise; /** Direct-member users who are effective admins of a space. */ listEffectiveSpaceAdmins(spaceId: string): Promise; - /** Whether a principal is an admin of a space (agents are never admins). */ + /** Whether a principal is an admin of a space. */ isSpaceAdmin( principalId: string, spaceId: string, @@ -109,7 +108,7 @@ export interface CoreStore { spaceId: string, apiKeyId?: string | null, ): Promise; - /** Whether a member is an admin of a group (agents are never group admins). */ + /** Whether a member is an admin of a group. */ isGroupAdmin( memberId: string, groupId: string, @@ -127,9 +126,6 @@ export interface CoreStore { groupId: string, isSpaceAdmin: boolean, ): Promise; - /** A user's agents (global; agents are owned by a user, not a space). */ - listAgents(ownerId: string): Promise; - addPrincipalToSpace( spaceId: string, principalId: string, @@ -150,7 +146,7 @@ export interface CoreStore { groupId: string, memberId: string, ): Promise; - /** Members (users / agents) of a group within a space. */ + /** Members (users / service accounts) of a group within a space. */ listGroupMembers(spaceId: string, groupId: string): Promise; /** Groups within a space that a member belongs to. */ listGroupsForMember( @@ -320,7 +316,6 @@ function mapPrincipal(row: Record): Principal { id: row.id as string, kind: row.kind as PrincipalKind, name: row.name as string, - ownerId: (row.owner_id as string | null) ?? null, spaceId: (row.space_id as string | null) ?? null, createdAt: row.created_at as Date, updatedAt: (row.updated_at as Date | null) ?? null, @@ -332,7 +327,6 @@ function mapSpacePrincipal(row: Record): SpacePrincipal { id: row.id as string, kind: row.kind as PrincipalKind, name: row.name as string, - ownerId: (row.owner_id as string | null) ?? null, admin: Boolean(row.admin), createdAt: row.created_at as Date, updatedAt: (row.updated_at as Date | null) ?? null, @@ -449,14 +443,6 @@ export function coreStore(sql: Sql, schema: string = CORE_SCHEMA): CoreStore { return row.id as string; }, - async createAgent(ownerId, name, id) { - const [row] = await sql` - select ${sch}.create_agent(${ownerId}, ${name}, ${id ?? null}) as id - `; - if (!row) throw new Error("create_agent returned no row"); - return row.id as string; - }, - async createGroup(spaceId, name, isSpaceAdmin = false, id) { const [row] = await sql` select ${sch}.create_group( @@ -528,11 +514,6 @@ export function coreStore(sql: Sql, schema: string = CORE_SCHEMA): CoreStore { return Boolean(row?.updated); }, - async listAgents(ownerId) { - const rows = await sql`select * from ${sch}.list_agents(${ownerId})`; - return rows.map(mapPrincipal); - }, - async isSpaceAdmin(principalId, spaceId, apiKeyId) { const [row] = await sql` select ${sch}.is_principal_space_admin(${principalId}, ${spaceId}, ${apiKeyId ?? null}) as ok @@ -730,14 +711,13 @@ export function coreStore(sql: Sql, schema: string = CORE_SCHEMA): CoreStore { async validateApiKey(lookupId, secret) { const secretHash = hashApiKeySecret(secret); const [row] = await sql` - select member_id, api_key_id, owner_id, kind, name, restricted + select member_id, api_key_id, kind, name, restricted from ${sch}.validate_api_key(${lookupId}, ${secretHash}) `; if (!row) return null; return { memberId: row.member_id as string, apiKeyId: row.api_key_id as string, - ownerId: (row.owner_id as string | null) ?? null, kind: row.kind as ValidatedApiKey["kind"], name: row.name as string, restricted: Boolean(row.restricted), diff --git a/packages/engine/core/types.ts b/packages/engine/core/types.ts index 7cf452f7..ac120626 100644 --- a/packages/engine/core/types.ts +++ b/packages/engine/core/types.ts @@ -6,7 +6,7 @@ * core tables directly. */ -export type PrincipalKind = "u" | "g" | "a" | "s"; +export type PrincipalKind = "u" | "g" | "s"; /** Access levels stored in core.tree_access: 1 = read, 2 = write, 3 = owner. */ export type AccessLevel = 1 | 2 | 3; @@ -22,7 +22,7 @@ export const ACCESS = { * The root tree path: the empty ltree (`''`), which is the ancestor of every * path — so a grant here covers the whole space. (ltree separates with `.`, not * `/`, and its root is the empty path; `/` is not an ltree concept and is - * reserved for agent names like `user/agent`.) + * not a valid ltree label.) */ export const ROOT_PATH = ""; @@ -38,7 +38,7 @@ export interface Space { /** A space a principal is a direct member of, with the principal's effective admin flag. */ export interface MemberSpace extends Space { admin: boolean; - /** Whether joining users/agents automatically get owner@~ (custom spaces set false). */ + /** Whether joining users automatically get owner@~ (custom spaces set false). */ autoGrantHome: boolean; /** The space's default/invite group (is_default_group), or null if it has none. */ defaultGroup: { id: string; name: string } | null; @@ -48,7 +48,6 @@ export interface Principal { id: string; kind: PrincipalKind; name: string; - ownerId: string | null; spaceId: string | null; createdAt: Date; updatedAt: Date | null; @@ -72,21 +71,19 @@ export interface CreatedApiKey { } export interface ValidatedApiKey { - /** The principal (user, agent, or service account) the key belongs to. */ + /** The principal (user or service account) the key belongs to. */ memberId: string; /** The api_key row id. */ apiKeyId: string; - /** The member's owner — non-null when the key-holder is an agent, null for a user. */ - ownerId: string | null; /** - * The member's kind — always one of u|a|s in practice (groups hold no key). + * The member's kind — always one of u|s in practice (groups hold no key). * Returned alongside the key so the auth middleware need not re-fetch the * principal just to learn the kind. */ kind: PrincipalKind; /** * The member's principal name (the user's email for a user PAT, else the - * agent/service-account handle). Saves the middleware a second lookup. + * service-account handle). Saves the middleware a second lookup. */ name: string; /** Whether this key carries space/tree access declarations. */ @@ -95,7 +92,7 @@ export interface ValidatedApiKey { /** * A principal on a space's roster — i.e. with a direct principal_space row. - * Users, agents, groups (a group is rostered into its space on creation), and + * Users, groups (a group is rostered into its space on creation), and * service accounts. This is about the principal's own roster entry, not * membership conferral: a member who is only in a group (no principal_space row * of their own) is still not a space member. `admin` is the effective @@ -105,7 +102,6 @@ export interface SpacePrincipal { id: string; kind: PrincipalKind; name: string; - ownerId: string | null; admin: boolean; createdAt: Date; updatedAt: Date | null; @@ -130,7 +126,7 @@ export interface Group { updatedAt: Date | null; } -/** A member (user / agent / service account) of a group, with the group admin flag. */ +/** A member (user / service account) of a group, with the group admin flag. */ export interface GroupMember { memberId: string; kind: PrincipalKind; diff --git a/packages/protocol/fields.ts b/packages/protocol/fields.ts index 1fa11a67..de5d3216 100644 --- a/packages/protocol/fields.ts +++ b/packages/protocol/fields.ts @@ -202,8 +202,8 @@ export const nameSchema = z .max(100, "name must be at most 100 characters"); /** - * Agent/group principal names are CLI handles, not emails. User principal names - * remain emails and are validated at the auth boundary. + * Group/service-account principal names are CLI handles, not emails. User + * principal names remain emails and are validated at the auth boundary. */ export const principalHandleNameSchema = z .string() diff --git a/packages/protocol/headers.ts b/packages/protocol/headers.ts index aabe035d..fa1377c8 100644 --- a/packages/protocol/headers.ts +++ b/packages/protocol/headers.ts @@ -15,13 +15,3 @@ export const CLIENT_VERSION_HEADER = "X-Client-Version"; * targets (both session and api-key auth). The value is the active space slug. */ export const SPACE_HEADER = "X-Me-Space"; - -/** - * Header a human-authenticated caller (session / OAuth token / user PAT) sends - * to act as one of their own agents. The value is the agent's id or name; the - * server resolves it against the caller's owned agents and, on a match, - * authorizes the request as that agent — constrained exactly as the agent's own - * api key would be. Honored on both RPC endpoints; ignored when the bearer is - * itself an agent api key (the bearer already is an agent). - */ -export const AS_AGENT_HEADER = "X-Me-As-Agent"; diff --git a/packages/protocol/index.ts b/packages/protocol/index.ts index 1aebe65d..64fc1135 100644 --- a/packages/protocol/index.ts +++ b/packages/protocol/index.ts @@ -7,14 +7,13 @@ * * RPC endpoints / contracts: * - Memory RPC (POST /api/v1/memory/rpc) — OAuth access token, api-key bearer - * (user PAT, agent key, or service-account key), or cookie session, plus a + * (user PAT or service-account key), or cookie session, plus a * required X-Me-Space header; the memory data plane (./memory) + the space * management contract (./space). * - User RPC (POST /api/v1/user/rpc) — OAuth access token, cookie session, or - * the user's own PAT; agent/service-account keys are admitted only for the + * the user's own PAT; service-account keys are admitted only for the * allow-listed reads (whoami, space.list), and key mint/revoke stays - * session-only. whoami + agent/service-account lifecycle + space discovery - * (./user). + * session-only. whoami + service-account lifecycle + space discovery (./user). */ // Error codes and AppError diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b1151555..b677750a 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -78,7 +78,7 @@ "!*.test.ts" ], "scripts": { - "build:js": "../../bun build index.ts fields.ts jsonrpc.ts errors.ts headers.ts version.ts memory.ts meta.ts space/index.ts space/principal.ts space/group.ts space/grant.ts space/invitation.ts user/index.ts user/agent.ts user/space.ts user/whoami.ts user/api-key.ts --outdir dist --format esm --target node --packages external --splitting", + "build:js": "../../bun build index.ts fields.ts jsonrpc.ts errors.ts headers.ts version.ts memory.ts meta.ts space/index.ts space/principal.ts space/group.ts space/grant.ts space/invitation.ts user/index.ts user/space.ts user/whoami.ts user/api-key.ts --outdir dist --format esm --target node --packages external --splitting", "build:types": "tsc -p tsconfig.build.json", "build": "../../bun run build:js && ../../bun run build:types", "prepublishOnly": "../../bun run build" diff --git a/packages/protocol/principal-name.test.ts b/packages/protocol/principal-name.test.ts index e6fa3b1b..244dcbb8 100644 --- a/packages/protocol/principal-name.test.ts +++ b/packages/protocol/principal-name.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { principalHandleNameSchema } from "./fields.ts"; import { groupCreateParams, groupRenameParams } from "./space/group.ts"; import { principalKindSchema } from "./space/principal.ts"; -import { agentCreateParams, agentRenameParams } from "./user/agent.ts"; import { serviceAccountCreateParams, serviceAccountRenameParams, @@ -11,7 +10,7 @@ import { const uuidv7 = "01900000-0000-7000-8000-000000000000"; describe("principalHandleNameSchema", () => { - test("accepts agent/group handle names", () => { + test("accepts group/service-account handle names", () => { for (const ok of ["backend", "backend-team", "ci_agent", "bot.v2", "a"]) { expect(principalHandleNameSchema.safeParse(ok).success).toBe(true); } @@ -34,28 +33,15 @@ describe("principalHandleNameSchema", () => { }); describe("principalKindSchema", () => { - test("accepts user, group, agent, and service account kinds", () => { - for (const kind of ["u", "g", "a", "s"]) { + test("accepts user, group, and service account kinds", () => { + for (const kind of ["u", "g", "s"]) { expect(principalKindSchema.safeParse(kind).success).toBe(true); } expect(principalKindSchema.safeParse("x").success).toBe(false); }); }); -describe("agent/group/service-account params", () => { - test("agent create/rename validate handle names", () => { - expect(agentCreateParams.safeParse({ name: "bot.v2" }).success).toBe(true); - expect( - agentRenameParams.safeParse({ id: uuidv7, name: "ci_agent" }).success, - ).toBe(true); - expect( - agentCreateParams.safeParse({ name: "bot@example.com" }).success, - ).toBe(false); - expect( - agentRenameParams.safeParse({ id: uuidv7, name: "bad/name" }).success, - ).toBe(false); - }); - +describe("group/service-account params", () => { test("group create/rename validate handle names", () => { expect(groupCreateParams.safeParse({ name: "backend-team" }).success).toBe( true, diff --git a/packages/protocol/space/access.ts b/packages/protocol/space/access.ts index e3c822ab..af02e6a2 100644 --- a/packages/protocol/space/access.ts +++ b/packages/protocol/space/access.ts @@ -2,14 +2,13 @@ * Effective access introspection schemas (access.*). * * Raw grant rows live under grant.*. Effective access is the access set a member - * actually executes with after direct grants, group grants, and agent owner - * clamps are resolved. + * actually executes with after direct grants and group grants are resolved. */ import { z } from "zod"; import { uuidv7Schema } from "../fields.ts"; import { accessLevelSchema } from "./grant.ts"; -const executablePrincipalKindSchema = z.enum(["u", "a", "s"]); +const executablePrincipalKindSchema = z.enum(["u", "s"]); export const effectiveAccessEntry = z.object({ treePath: z.string(), @@ -22,20 +21,10 @@ export const effectiveAccessPrincipal = z.object({ id: z.string(), kind: executablePrincipalKindSchema, name: z.string(), - ownerId: z.string().nullable(), admin: z.boolean(), }); export type EffectiveAccessPrincipal = z.infer; -export const effectiveAccessAuthenticatedAs = z.object({ - id: z.string(), - kind: executablePrincipalKindSchema, - name: z.string(), -}); -export type EffectiveAccessAuthenticatedAs = z.infer< - typeof effectiveAccessAuthenticatedAs ->; - export const accessEffectiveParams = z.object({ /** Principal to inspect. Omit/null for the current acting principal. */ principalId: uuidv7Schema.optional().nullable(), @@ -49,7 +38,6 @@ export const accessEffectiveResult = z.object({ name: z.string(), }), principal: effectiveAccessPrincipal, - authenticatedAs: effectiveAccessAuthenticatedAs.nullable(), access: z.array(effectiveAccessEntry), }); export type AccessEffectiveResult = z.infer; diff --git a/packages/protocol/space/group.ts b/packages/protocol/space/group.ts index e55f3162..130530aa 100644 --- a/packages/protocol/space/group.ts +++ b/packages/protocol/space/group.ts @@ -4,7 +4,7 @@ * Groups are space-scoped principals used to bundle members for tree-access * grants. A group is itself rostered into its space (principal_space), which is * what makes it resolvable/grantable by name — but group membership alone does - * NOT confer space membership on a user/agent/service account: a group's grants + * NOT confer space membership on a user/service account: a group's grants * (and its admin flag, if it's an admin group) apply to a member only once they * have also joined the space directly. */ diff --git a/packages/protocol/space/index.ts b/packages/protocol/space/index.ts index 26edb3fe..9fc6fec2 100644 --- a/packages/protocol/space/index.ts +++ b/packages/protocol/space/index.ts @@ -2,10 +2,8 @@ * Space management RPC contract — the control-plane methods served on * POST /api/v1/memory/rpc alongside the memory.* data-plane methods. * - * Follows the core model: principals (users/agents/groups), space membership, - * group membership, and 3-level tree-access grants. (Agent lifecycle and api - * keys are user-scoped and live on the user endpoint; here agents are only - * referenced as members.) + * Follows the core model: principals (users/groups/service accounts), space + * membership, group membership, and 3-level tree-access grants. */ import type { z } from "zod"; import { accessEffectiveParams, accessEffectiveResult } from "./access.ts"; @@ -86,7 +84,7 @@ export const spaceMethods = { // Effective access (1) — resolved executable access for a member principal. "access.effective": method(accessEffectiveParams, accessEffectiveResult), - // Membership (4) — the space roster holds principals (user | agent | group) + // Membership (4) — the space roster holds principals (user | group | service account) "principal.list": method(principalListParams, principalListResult), "principal.add": method(principalAddParams, principalAddResult), "principal.remove": method(principalRemoveParams, principalRemoveResult), diff --git a/packages/protocol/space/principal.ts b/packages/protocol/space/principal.ts index 61828ed8..af76616d 100644 --- a/packages/protocol/space/principal.ts +++ b/packages/protocol/space/principal.ts @@ -2,35 +2,34 @@ * Space membership method schemas (principal.*). * * The space management API, served on POST /api/v1/memory/rpc, follows the core - * model: principals (users/agents/groups/service accounts), space membership, group membership, - * 3-level tree-access grants, and agent api keys. All methods are scoped to the + * model: principals (users/groups/service accounts), space membership, group membership, + * and 3-level tree-access grants. All methods are scoped to the * space selected by the X-Me-Space header and require space-owner authority. * - * "Principal" is the union (user | agent | group | service account); the space + * "Principal" is the union (user | group | service account); the space * roster holds principals. "Member" is reserved for credential-bearing - * principals (users/agents/service accounts): group members and api-key holders. + * principals (users/service accounts): group members and api-key holders. */ import { z } from "zod"; import { nameSchema, uuidv7Schema } from "../fields.ts"; -/** Principal kind: user / group / agent / service account. */ -export const principalKindSchema = z.enum(["u", "g", "a", "s"]); +/** Principal kind: user / group / service account. */ +export const principalKindSchema = z.enum(["u", "g", "s"]); export type PrincipalKind = z.infer; /** * A principal on a space's roster — i.e. with a direct membership row - * (principal_space). Users, agents, groups (a group is rostered into its + * (principal_space). Users, groups (a group is rostered into its * space on creation), and service accounts. This describes the principal's own * roster entry, not membership conferral: a member who is only in a group (no * membership row of their own) is still not a space member. `admin` is the effective * space-admin status (a direct admin row OR a direct member who belongs to an - * admin group, never an agent; false for a group rostered admin=false). + * admin group; false for a group rostered admin=false). */ export const spacePrincipalResponse = z.object({ id: z.string(), kind: principalKindSchema, name: z.string(), - ownerId: z.string().nullable(), admin: z.boolean(), createdAt: z.string(), updatedAt: z.string().nullable(), @@ -101,5 +100,5 @@ export const principalLookupResult = z.object({ }); export type PrincipalLookupResult = z.infer; -// shared by agent.* / group.* mutation results +// shared by group.* mutation results export { nameSchema }; diff --git a/packages/protocol/space/space.ts b/packages/protocol/space/space.ts index c79a1151..9e652212 100644 --- a/packages/protocol/space/space.ts +++ b/packages/protocol/space/space.ts @@ -6,7 +6,7 @@ */ import { z } from "zod"; -export const spaceMemberKindSchema = z.enum(["u", "a", "s"]); +export const spaceMemberKindSchema = z.enum(["u", "s"]); export type SpaceMemberKind = z.infer; export const spaceMemberResponse = z.object({ @@ -16,7 +16,7 @@ export const spaceMemberResponse = z.object({ }); export type SpaceMemberResponse = z.infer; -// space.listMembers — list direct user/agent/service-account members of the +// space.listMembers — list direct user/service-account members of the // active space. Groups are principals, but not members, so they are excluded. export const spaceListMembersParams = z.object({ kind: spaceMemberKindSchema.optional().nullable(), diff --git a/packages/protocol/user/agent.ts b/packages/protocol/user/agent.ts deleted file mode 100644 index df6303a3..00000000 --- a/packages/protocol/user/agent.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Agent method schemas (agent.*) for the user RPC. - * - * Agents are user-owned non-human principals (names unique per user, not scoped - * to a space). Their lifecycle lives on the user endpoint - * (POST /api/v1/user/rpc); bringing an agent into a space is an in-space - * operation, and api keys are global per-principal credentials. - */ -import { z } from "zod"; -import { principalHandleNameSchema, uuidv7Schema } from "../fields.ts"; -import { memberSpaceResponse } from "./space.ts"; - -export const agentResponse = z.object({ - id: z.string(), - name: z.string(), - createdAt: z.string(), - updatedAt: z.string().nullable(), -}); -export type AgentResponse = z.infer; - -// agent.create — create an agent owned by the calling user -export const agentCreateParams = z.object({ name: principalHandleNameSchema }); -export type AgentCreateParams = z.infer; - -export const agentCreateResult = z.object({ id: z.string() }); -export type AgentCreateResult = z.infer; - -// agent.list — the caller's agents -export const agentListParams = z.object({}); -export type AgentListParams = z.infer; - -export const agentListResult = z.object({ agents: z.array(agentResponse) }); -export type AgentListResult = z.infer; - -// agent.spaces — spaces an owned agent belongs to -export const agentSpacesParams = z.object({ id: uuidv7Schema }); -export type AgentSpacesParams = z.infer; - -export const agentSpacesResult = z.object({ - spaces: z.array(memberSpaceResponse), -}); -export type AgentSpacesResult = z.infer; - -// agent.rename -export const agentRenameParams = z.object({ - id: uuidv7Schema, - name: principalHandleNameSchema, -}); -export type AgentRenameParams = z.infer; - -export const agentRenameResult = z.object({ renamed: z.boolean() }); -export type AgentRenameResult = z.infer; - -// agent.delete -export const agentDeleteParams = z.object({ id: uuidv7Schema }); -export type AgentDeleteParams = z.infer; - -export const agentDeleteResult = z.object({ deleted: z.boolean() }); -export type AgentDeleteResult = z.infer; diff --git a/packages/protocol/user/api-key.ts b/packages/protocol/user/api-key.ts index b720d253..540b062b 100644 --- a/packages/protocol/user/api-key.ts +++ b/packages/protocol/user/api-key.ts @@ -2,8 +2,8 @@ * Api key method schemas (apiKey.*). * * A key is minted for a credential-bearing member the caller may administer: - * an agent, a service account, or the caller's OWN user principal (a personal - * access token for headless/CLI use). + * a service account or the caller's OWN user principal (a personal access token + * for headless/CLI use). * Keys are global per-principal (not space-bound). The plaintext key is returned * exactly once, by apiKey.create. There is no soft-revoke state: apiKey.delete * is the only removal (revoke ≡ delete). Minting/deleting keys is session-only — @@ -52,8 +52,7 @@ export const apiKeyAccessResponse = apiKeyAccessDeclaration.extend({ export type ApiKeyAccessResponse = z.infer; // apiKey.create — mint a key for a member the caller may administer: their own -// user principal (a PAT), an owned agent, or a service account. `memberId` is -// always explicit. +// user principal (a PAT) or a service account. `memberId` is always explicit. export const apiKeyCreateParams = z.object({ memberId: uuidv7Schema, name: nameSchema, diff --git a/packages/protocol/user/index.ts b/packages/protocol/user/index.ts index f65f87cc..b696a9fa 100644 --- a/packages/protocol/user/index.ts +++ b/packages/protocol/user/index.ts @@ -1,22 +1,10 @@ /** * User RPC contract served on POST /api/v1/user/rpc. Covers user/account - * operations, user-owned agents, space-scoped service accounts, and global api - * keys; memory and in-space management live on the memory endpoint. + * operations, space-scoped service accounts, and global api keys; memory and + * in-space management live on the memory endpoint. */ import type { z } from "zod"; -import { - agentCreateParams, - agentCreateResult, - agentDeleteParams, - agentDeleteResult, - agentListParams, - agentListResult, - agentRenameParams, - agentRenameResult, - agentSpacesParams, - agentSpacesResult, -} from "./agent.ts"; import { apiKeyCreateParams, apiKeyCreateResult, @@ -61,7 +49,6 @@ import { } from "./space.ts"; import { whoamiParams, whoamiResult } from "./whoami.ts"; -export * from "./agent.ts"; export * from "./api-key.ts"; export * from "./invitation.ts"; export * from "./service-account.ts"; @@ -76,18 +63,12 @@ function method( } /** - * User RPC method contract (identity + agent lifecycle + api keys + space - * discovery). + * User RPC method contract (identity, service-account lifecycle, api keys, and + * space discovery). */ export const userMethods = { whoami: method(whoamiParams, whoamiResult), - "agent.create": method(agentCreateParams, agentCreateResult), - "agent.list": method(agentListParams, agentListResult), - "agent.spaces": method(agentSpacesParams, agentSpacesResult), - "agent.rename": method(agentRenameParams, agentRenameResult), - "agent.delete": method(agentDeleteParams, agentDeleteResult), - "serviceAccount.create": method( serviceAccountCreateParams, serviceAccountCreateResult, diff --git a/packages/protocol/user/space.ts b/packages/protocol/user/space.ts index 61b902d4..c2f4d972 100644 --- a/packages/protocol/user/space.ts +++ b/packages/protocol/user/space.ts @@ -14,7 +14,7 @@ export const memberSpaceResponse = z.object({ language: z.string(), /** Whether the user is a (direct) admin of the space. */ admin: z.boolean(), - /** Whether joining users/agents automatically get owner@~ (custom spaces set false). */ + /** Whether joining users automatically get owner@~ (custom spaces set false). */ autoGrantHome: z.boolean(), /** The space's default/invite group (targeted by `me space invite` by default), or null. */ defaultGroup: z.object({ id: z.string(), name: z.string() }).nullable(), diff --git a/packages/protocol/user/whoami.ts b/packages/protocol/user/whoami.ts index cdec0998..dd107eaa 100644 --- a/packages/protocol/user/whoami.ts +++ b/packages/protocol/user/whoami.ts @@ -3,9 +3,9 @@ * * Returns the identity behind the credential — used by the CLI for `me login` * confirmation and `me whoami`. Admits any authenticated principal: a human - * (session / OAuth / user PAT) reports `kind: "u"` with their email; an agent or - * service account (acting with its api key) reports `kind: "a"` or `kind: "s"` - * with a null email. Account-management on the user RPC stays user-only — see + * (session / OAuth / user PAT) reports `kind: "u"` with their email; a service + * account acting with its API key reports `kind: "s"` with a null email. + * Account-management on the user RPC stays user-only — see * the per-method authorization in the server's user handlers. */ import { z } from "zod"; @@ -16,9 +16,9 @@ export type WhoamiParams = z.infer; export const whoamiResult = z.object({ id: z.string(), - /** The authenticated principal's kind: a user, agent, or service account. */ - kind: z.enum(["u", "a", "s"]), - /** The user's email; null for agents and service accounts. */ + /** The authenticated principal's kind: a user or service account. */ + kind: z.enum(["u", "s"]), + /** The user's email; null for service accounts. */ email: z.string().nullable(), name: z.string(), }); diff --git a/packages/server/auth/betterauth.ts b/packages/server/auth/betterauth.ts index 784a8bba..ebae892b 100644 --- a/packages/server/auth/betterauth.ts +++ b/packages/server/auth/betterauth.ts @@ -16,7 +16,7 @@ // presented back as a signed bearer session token — hence the `bearer` // plugin. // -// The api-key path (agents) stays entirely in `core` (core.validate_api_key). +// The api-key path stays entirely in `core` (core.validate_api_key). // // UPGRADING better-auth: the versions here are pinned EXACTLY (no `^`) because // better-auth owns DB tables — a bump can change the schema it expects and drift @@ -152,8 +152,8 @@ export function createBetterAuth(opts: BetterAuthOptions) { // chokepoint. Social sign-in creates the session here, and the CLI's // OAuth flow is built on that web session, so blocking it blocks both; // the memory RPC is covered transitively (no session/token → no - // bearer). Agents never reach this (they use api keys, not sessions), - // and credentials minted while verified keep working. `email_verified` + // bearer). Credentials minted while verified keep working. + // `email_verified` // is the provider's claim (GitHub/Google only release a verified // email), re-read on every login. Throwing an APIError makes the // social callback redirect to the error URL (see AUTH_DESIGN flows @@ -404,7 +404,7 @@ export function createBetterAuth(opts: BetterAuthOptions) { } | null> { // Join the user so callers get identity (whoami / provisioning) in one hop. // A user-less token (client_credentials) yields no row → treated as invalid; - // our API auth is user-bound (agents use core api keys, not OAuth). + // our API auth is user-bound (API keys use core validation, not OAuth). const { rows } = await pool.query( `select t.user_id, u.email, u.name, u.email_verified, t.scopes from oauth_access_token t diff --git a/packages/server/middleware/act-as-agent.ts b/packages/server/middleware/act-as-agent.ts deleted file mode 100644 index a5a1d871..00000000 --- a/packages/server/middleware/act-as-agent.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Principal } from "@memory.build/engine/core"; - -export type ActAsAgentResolution = - | { kind: "found"; agent: Principal } - | { kind: "not_found" } - | { kind: "ambiguous" }; - -/** Resolve an X-Me-As-Agent value without silently preferring id over name. */ -export function resolveOwnedAgent( - agents: Principal[], - asAgent: string, -): ActAsAgentResolution { - const wanted = asAgent.toLowerCase(); - const matches = agents.filter( - (agent) => - agent.id.toLowerCase() === wanted || agent.name.toLowerCase() === wanted, - ); - const unique = new Map(matches.map((agent) => [agent.id, agent])); - - if (unique.size === 0) return { kind: "not_found" }; - if (unique.size > 1) return { kind: "ambiguous" }; - - const [agent] = unique.values(); - if (!agent) return { kind: "not_found" }; - return { kind: "found", agent }; -} diff --git a/packages/server/middleware/authenticate-space.integration.test.ts b/packages/server/middleware/authenticate-space.integration.test.ts index 97ed12af..51d802b0 100644 --- a/packages/server/middleware/authenticate-space.integration.test.ts +++ b/packages/server/middleware/authenticate-space.integration.test.ts @@ -16,7 +16,6 @@ import { provisionSpace, } from "@memory.build/database"; import * as engineCore from "@memory.build/engine/core"; -import { AS_AGENT_HEADER } from "@memory.build/protocol/headers"; import postgres, { type Sql } from "postgres"; import { createBetterAuth } from "../auth/betterauth"; import { addSpaceCreator } from "../provision"; @@ -55,7 +54,7 @@ function deps() { }; } -/** Build a request with optional bearer token + X-Me-Space / X-Me-As-Agent. */ +/** Build a request with optional bearer token + X-Me-Space / legacy ignored header. */ function req(opts: { token?: string; space?: string; @@ -64,34 +63,13 @@ function req(opts: { const headers: Record = {}; if (opts.token) headers.Authorization = `Bearer ${opts.token}`; if (opts.space) headers[SPACE_HEADER] = opts.space; - if (opts.asAgent) headers[AS_AGENT_HEADER] = opts.asAgent; + if (opts.asAgent) headers["X-Me-As-Agent"] = opts.asAgent; return new Request("http://localhost/api/v1/memory/rpc", { method: "POST", headers, }); } -/** - * Provision `p`'s owned agent as a space member with a `read@share` grant (clamped - * to the owner's `owner@share`) and an api key. Returns the agent + full key so - * the act-as (human header) and agent-key paths can be compared for parity. - */ -async function seedOwnedAgent(p: Awaited>) { - const core = engineCore.coreStore(sql, coreSchema); - const name = `agent-${rand()}`; - const agentId = await core.createAgent(p.userId, name); - await core.addPrincipalToSpace(p.spaceId, agentId); - await core.grantTreeAccess( - p.spaceId, - agentId, - "share", - engineCore.ACCESS.read, - ); - const key = await core.createApiKey(agentId, "ci"); - const fullKey = engineCore.formatApiKey(key.lookupId, key.secret); - return { agentId, name, fullKey }; -} - async function restrictApiKey( keyId: string, spaceId: string, @@ -215,36 +193,6 @@ test("session: member with owner grant resolves space + treeAccess", async () => } }); -test("api key: agent of the space resolves with apiKeyId set", async () => { - const p = await provision(); - const core = engineCore.coreStore(sql, coreSchema); - - const agentId = await core.createAgent(p.userId, `agent-${rand()}`); - await core.addPrincipalToSpace(p.spaceId, agentId); - // grant within the owner's access (it owns `share`) so the agent's clamped - // effective access is non-empty — the owner is no longer owner@root. - await core.grantTreeAccess( - p.spaceId, - agentId, - "share", - engineCore.ACCESS.read, - ); - const key = await core.createApiKey(agentId, "ci"); - const fullKey = engineCore.formatApiKey(key.lookupId, key.secret); - - const result = await authenticateSpace( - req({ token: fullKey, space: p.spaceSlug }), - deps(), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.principalId).toBe(agentId); - expect(result.context.apiKeyId).not.toBeNull(); - expect(result.context.treeAccess.length).toBeGreaterThan(0); - } - expect((await core.getApiKey(key.id))?.lastUsedOn).toBe(today()); -}); - test("api key: a user's own key (PAT) resolves as the user with full grants", async () => { const p = await provision(); const core = engineCore.coreStore(sql, coreSchema); @@ -259,7 +207,7 @@ test("api key: a user's own key (PAT) resolves as the user with full grants", as ); expect(result.ok).toBe(true); if (result.ok) { - // Authenticates as the user (not clamped like an agent) with full grants. + // Authenticates as the user with full grants. expect(result.context.principalId).toBe(p.userId); expect(result.context.apiKeyId).not.toBeNull(); expect(result.context.treeAccess).toContainEqual({ @@ -402,7 +350,6 @@ test("api key: service account resolves with direct tree access and no owner", a if (result.ok) { expect(result.context.principalId).toBe(serviceAccount.id); expect(result.context.principalKind).toBe("s"); - expect(result.context.ownerId).toBeNull(); expect(result.context.apiKeyId).not.toBeNull(); expect(result.context.treeAccess).toContainEqual({ tree_path: "share.deploy", @@ -411,36 +358,6 @@ test("api key: service account resolves with direct tree access and no owner", a } }); -test("api key is global: one key authenticates into every space the agent belongs to", async () => { - const p = await provision(); - const core = engineCore.coreStore(sql, coreSchema); - - // A second space also created by p, so p (the agent's owner) has access in - // both — the agent's effective access is clamped to its owner's. - const slug2 = generateSlug(); - const spaceId2 = await core.createSpace(slug2, "second"); - await provisionSpace(sql, { slug: slug2 }); - createdSpaceSchemas.push(`me_${slug2}`); - await addSpaceCreator(core, spaceId2, p.userId); - - const agentId = await core.createAgent(p.userId, `agent-${rand()}`); - for (const sid of [p.spaceId, spaceId2]) { - await core.addPrincipalToSpace(sid, agentId); - await core.grantTreeAccess(sid, agentId, "share", engineCore.ACCESS.read); - } - const key = await core.createApiKey(agentId, "ci"); - const fullKey = engineCore.formatApiKey(key.lookupId, key.secret); - - for (const slug of [p.spaceSlug, slug2]) { - const result = await authenticateSpace( - req({ token: fullKey, space: slug }), - deps(), - ); - expect(result.ok).toBe(true); - if (result.ok) expect(result.context.principalId).toBe(agentId); - } -}); - test("legacy 4-part api key → 401 with a LEGACY_API_KEY recreate message", async () => { const p = await provision(); // A token shaped like the retired me... format. @@ -496,20 +413,16 @@ test("invalid session token → 401", async () => { if (!result.ok) expect(result.error.status).toBe(401); }); -test("api key: agent that is not a member of the requested space → 403", async () => { +test("api key: service account that is not a member of the requested space → 403", async () => { const p = await provision(); const other = await provision(); const core = engineCore.coreStore(sql, coreSchema); - const agentId = await core.createAgent(p.userId, `agent-${rand()}`); - await core.addPrincipalToSpace(p.spaceId, agentId); - await core.grantTreeAccess( + const serviceAccount = await core.createServiceAccount( p.spaceId, - agentId, - "share", - engineCore.ACCESS.read, + `svc-${rand()}`, ); - const key = await core.createApiKey(agentId, "ci"); - // A valid global key, but the agent has no principal_space membership in + const key = await core.createApiKey(serviceAccount.id, "ci"); + // A valid key, but the service account has no principal_space membership in // `other` — the membership gate denies it rather than a parse-time rejection. const fullKey = engineCore.formatApiKey(key.lookupId, key.secret); const result = await authenticateSpace( @@ -566,191 +479,12 @@ test("session: member of another space is not a member here → 403", async () = if (!result.ok) expect(result.error.status).toBe(403); }); -// ============================================================================= -// Act-as-agent (X-Me-As-Agent) -// ============================================================================= - -test("act-as: human session + owned agent by id → principal switch, ownerId=human, clamped access, admin=false", async () => { +test("legacy X-Me-As-Agent is ignored", async () => { const p = await provision(); - const { agentId } = await seedOwnedAgent(p); - const result = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: agentId }), + req({ token: p.token, space: p.spaceSlug, asAgent: "retired-agent" }), deps(), ); expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.principalId).toBe(agentId); - expect(result.context.ownerId).toBe(p.userId); - expect(result.context.authenticatedAs).toBe(p.userId); - expect(result.context.admin).toBe(false); - // Clamped to the agent's grant (least(read, owner@share) = read), not the - // human's owner@share. - expect(result.context.treeAccess).toContainEqual({ - tree_path: "share", - access: engineCore.ACCESS.read, - }); - expect(result.context.treeAccess).not.toContainEqual({ - tree_path: "share", - access: engineCore.ACCESS.owner, - }); - } -}); - -test("act-as: human session + owned agent by name (mixed case) → principal switch", async () => { - const p = await provision(); - const { agentId, name } = await seedOwnedAgent(p); - - const mixed = name.toUpperCase(); - const result = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: mixed }), - deps(), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.principalId).toBe(agentId); - expect(result.context.authenticatedAs).toBe(p.userId); - } -}); - -test("act-as: human session + owned agent by UPPERCASE id → principal switch (id match is case-insensitive)", async () => { - const p = await provision(); - const { agentId } = await seedOwnedAgent(p); - - // Postgres emits uuids lowercase, but a client may send an uppercase UUID - // (the CLI's UUID gate is case-insensitive and passes it through verbatim). - const result = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: agentId.toUpperCase() }), - deps(), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.principalId).toBe(agentId); - expect(result.context.authenticatedAs).toBe(p.userId); - } -}); - -test("act-as: id/name collision among owned agents → 403 INVALID_AGENT", async () => { - const p = await provision(); - const { agentId } = await seedOwnedAgent(p); - const core = engineCore.coreStore(sql, coreSchema); - await core.createAgent(p.userId, agentId); - - const result = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: agentId }), - deps(), - ); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.status).toBe(403); - const body = (await result.error.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("INVALID_AGENT"); - expect(body.error.message).toContain("matches multiple agents"); - } -}); - -test("act-as: agent-key bearer + X-Me-As-Agent (a valid other owned agent) → header ignored, key trumps", async () => { - const p = await provision(); - const a = await seedOwnedAgent(p); - const b = await seedOwnedAgent(p); // a valid, other owned agent - - // Bearer is a's key; header names b. The key already IS an agent → ignored. - const result = await authenticateSpace( - req({ token: a.fullKey, space: p.spaceSlug, asAgent: b.agentId }), - deps(), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.principalId).toBe(a.agentId); - expect(result.context.apiKeyId).not.toBeNull(); - expect(result.context.authenticatedAs).toBeNull(); - } -}); - -test("act-as: user PAT + X-Me-As-Agent → header ignored, key stays the user", async () => { - const p = await provision(); - const agent = await seedOwnedAgent(p); - const core = engineCore.coreStore(sql, coreSchema); - const key = await core.createApiKey(p.userId, "user-pat"); - - const result = await authenticateSpace( - req({ - token: engineCore.formatApiKey(key.lookupId, key.secret), - space: p.spaceSlug, - asAgent: agent.agentId, - }), - deps(), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.principalId).toBe(p.userId); - expect(result.context.authenticatedAs).toBeNull(); - } -}); - -test("act-as: unknown/unowned/non-agent header → 403 INVALID_AGENT", async () => { - const p = await provision(); - const other = await provision(); - // An agent owned by a DIFFERENT user — not one p owns. - const foreign = await seedOwnedAgent(other); - - for (const value of [foreign.agentId, "does-not-exist", p.userId]) { - const result = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: value }), - deps(), - ); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.status).toBe(403); - const body = (await result.error.json()) as { - error: { code: string }; - }; - expect(body.error.code).toBe("INVALID_AGENT"); - } - } -}); - -test("act-as: owned agent that is not a member of this space → 403", async () => { - const p = await provision(); - const core = engineCore.coreStore(sql, coreSchema); - // An owned agent that is NOT a member of the space — the membership gate denies - // after the switch. - const agentId = await core.createAgent(p.userId, `agent-${rand()}`); - - const result = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: agentId }), - deps(), - ); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.status).toBe(403); -}); - -test("act-as parity: human+X-Me-As-Agent equals the agent-key context on the authz fields", async () => { - const p = await provision(); - const a = await seedOwnedAgent(p); - - const asAgent = await authenticateSpace( - req({ token: p.token, space: p.spaceSlug, asAgent: a.agentId }), - deps(), - ); - const byKey = await authenticateSpace( - req({ token: a.fullKey, space: p.spaceSlug }), - deps(), - ); - expect(asAgent.ok).toBe(true); - expect(byKey.ok).toBe(true); - if (asAgent.ok && byKey.ok) { - // Authorization reads only these fields — identical on both paths. - expect(asAgent.context.principalId).toBe(byKey.context.principalId); - expect(asAgent.context.ownerId).toBe(byKey.context.ownerId); - expect(asAgent.context.treeAccess).toEqual(byKey.context.treeAccess); - expect(asAgent.context.admin).toBe(byKey.context.admin); - // Observability-only fields may differ (and do). - expect(asAgent.context.apiKeyId).toBeNull(); - expect(byKey.context.apiKeyId).not.toBeNull(); - expect(asAgent.context.authenticatedAs).toBe(p.userId); - expect(byKey.context.authenticatedAs).toBeNull(); - } + if (result.ok) expect(result.context.principalId).toBe(p.userId); }); diff --git a/packages/server/middleware/authenticate-space.ts b/packages/server/middleware/authenticate-space.ts index 3094e114..d85d79b7 100644 --- a/packages/server/middleware/authenticate-space.ts +++ b/packages/server/middleware/authenticate-space.ts @@ -6,7 +6,8 @@ * Two credential modes, discriminated by whether the bearer token parses as an * api key: * - * - api key (agent): `me..` — validated against core. + * - api key (user or service account): `me..` — validated + * against core. * - human: an OAuth access token (Bearer, CLI/MCP), a better-auth session * token presented as a bearer (the device-authorization flow, via the * `bearer` plugin), or a better-auth cookie session — all opaque, validated @@ -25,12 +26,11 @@ import { type TreeAccess, } from "@memory.build/engine/core"; import { type SpaceStore, spaceStore } from "@memory.build/engine/space"; -import { AS_AGENT_HEADER, SPACE_HEADER } from "@memory.build/protocol/headers"; +import { SPACE_HEADER } from "@memory.build/protocol/headers"; import { debug, span } from "@pydantic/logfire-node"; import type { Sql } from "postgres"; import type { Auth, VerifyOAuthAccessToken } from "../auth/betterauth"; import { error, forbidden, unauthorized } from "../util/response"; -import { resolveOwnedAgent } from "./act-as-agent"; import { recordApiKeyUse } from "./api-key-usage"; import { bearerOnlyHeaders, @@ -51,29 +51,16 @@ export interface SpaceAuthContext { core: CoreStore; /** The resolved space. */ space: Space; - /** Authenticated principal id (user id for sessions, agent id for api keys). */ + /** Authenticated principal id. */ principalId: string; - /** Authenticated principal kind after any act-as-agent switch. */ - principalKind: "u" | "a" | "s"; - /** - * The authenticated principal's owner — non-null when it is an agent, null for - * a user/session. Drives `~` home nesting (an agent's home lives under its - * owner's home). - */ - ownerId: string | null; + /** Authenticated principal kind. */ + principalKind: "u" | "s"; /** Api key id when authenticated by api key; null for sessions. */ apiKeyId: string | null; /** The principal's effective grants in this space. May be empty. */ treeAccess: TreeAccess; /** Whether the principal is a space admin (principal_space.admin). */ admin: boolean; - /** - * When a human is acting as one of their own agents (via `X-Me-As-Agent`), - * the human's principal id; null otherwise. Observability only — never gates - * authorization (which reads `principalId` / `ownerId` / `treeAccess` / - * `admin`, all already switched to the agent). - */ - authenticatedAs: string | null; } export type SpaceAuthResult = @@ -149,10 +136,8 @@ async function authenticateSpaceInner( // keys arrive only via the Bearer header (never a cookie). const parsed = bearer ? parseApiKey(bearer) : null; let principalId: string; - let principalKind: "u" | "a" | "s"; + let principalKind: "u" | "s"; let apiKeyId: string | null; - // The principal's owner — set only for an agent key; drives `~` home nesting. - let ownerId: string | null = null; if (parsed) { // Api keys are global; the space comes solely from the header. A key whose @@ -165,16 +150,11 @@ async function authenticateSpaceInner( } principalId = validated.memberId; apiKeyId = validated.apiKeyId; - ownerId = validated.ownerId; // validate_api_key already joined core.principal, so the kind comes back with // the validation — no second lookup. An api key only ever belongs to a member - // principal (user, agent, or service account); accept those explicitly and + // principal (user or service account); accept those explicitly and // reject anything else rather than trusting the DB's text `kind`. - if ( - validated.kind !== "u" && - validated.kind !== "a" && - validated.kind !== "s" - ) { + if (validated.kind !== "u" && validated.kind !== "s") { debug("space auth failed: api key principal is not a member kind", { kind: validated.kind, }); @@ -190,7 +170,7 @@ async function authenticateSpaceInner( return { ok: false, error: error( - "This API key uses the old space-scoped format (me...) and no longer works. Recreate it with `me apikey create --agent `, then update ME_API_KEY or your MCP/plugin config.", + "This API key uses the old space-scoped format (me...) and no longer works. Recreate it with `me apikey create`, then update ME_API_KEY or your MCP/plugin config.", 401, "LEGACY_API_KEY", ), @@ -241,45 +221,6 @@ async function authenticateSpaceInner( apiKeyId = null; } - // 4b. Act-as-agent switch. A human credential (session / OAuth / user PAT, - // i.e. `principalKind === "u"`) may send `X-Me-As-Agent` to run as one of their - // own agents. Gated on the user kind specifically — an agent key already IS an - // agent, and a service-account key (also owner-less) must not be able to - // act-as-agent — so the header is ignored for both. Done BEFORE the membership - // gate (step 5) and the admin check (step 6) so `treeAccess`, `~`-home nesting, - // and `admin` all reflect the agent. - let authenticatedAs: string | null = null; - const asAgent = request.headers.get(AS_AGENT_HEADER); - if (asAgent && principalKind === "u" && apiKeyId === null) { - const agents = await core.listAgents(principalId); - const resolved = resolveOwnedAgent(agents, asAgent); - if (resolved.kind !== "found") { - debug("space auth failed: X-Me-As-Agent not an owned agent", { - principalId, - asAgent, - reason: resolved.kind, - }); - return { - ok: false, - error: error( - resolved.kind === "ambiguous" - ? `X-Me-As-Agent '${asAgent}' matches multiple agents you own; rename the conflicting agent` - : `X-Me-As-Agent '${asAgent}' is not an agent you own`, - 403, - "INVALID_AGENT", - ), - }; - } - const { agent } = resolved; - // Overwrite the resolved principal to the agent, reusing the existing agent - // semantics so parity with the agent-key path is automatic. The human owns - // the agent, so `ownerId = human` drives `~` home nesting the same way. - authenticatedAs = principalId; - ownerId = principalId; - principalId = agent.id; - principalKind = "a"; - } - // 5. Endpoint admission is direct space membership (principal_space), not // tree access. A member may legitimately have no tree grants; data-plane // methods still enforce that later through the space SQL functions. @@ -293,8 +234,7 @@ async function authenticateSpaceInner( // 6. Bind the data-plane store to this space's schema, resolve management // authority, and eagerly compute the effective tree grants consumed by data - // handlers. `buildTreeAccess(agentId, spaceId)` applies the `agent_tree_access` - // clamp internally, so act-as remains byte-identical to the agent-key path. + // handlers. const store = spaceStore(db, slugToSchema(space.slug)); const admin = await core.isSpaceAdmin(principalId, space.id, apiKeyId); const treeAccess = await core.buildTreeAccess( @@ -317,11 +257,9 @@ async function authenticateSpaceInner( space, principalId, principalKind, - ownerId, apiKeyId, treeAccess, admin, - authenticatedAs, }, }; } diff --git a/packages/server/middleware/authenticate-user.integration.test.ts b/packages/server/middleware/authenticate-user.integration.test.ts index 887d179c..b1c2fdb6 100644 --- a/packages/server/middleware/authenticate-user.integration.test.ts +++ b/packages/server/middleware/authenticate-user.integration.test.ts @@ -1,9 +1,8 @@ // Integration test for user-RPC authentication (authenticateUser). // // Covers the admitted credentials: an OAuth access token and the user's OWN api -// key (a PAT) authenticate as the user (kind 'u'); an AGENT api key is admitted -// as kind 'a' (per-method authz, not a door bar, keeps it off account -// management); invalid/missing → 401. +// key (a PAT) authenticate as the user (kind 'u'); a service-account key is +// admitted as kind 's'; invalid/missing → 401. // TEST_DATABASE_URL="postgresql://postgres@127.0.0.1:5432/postgres" \ // bun test --timeout 30000 \ // packages/server/middleware/authenticate-user.integration.test.ts @@ -15,7 +14,6 @@ import { migrateCore, } from "@memory.build/database"; import * as engineCore from "@memory.build/engine/core"; -import { AS_AGENT_HEADER } from "@memory.build/protocol/headers"; import postgres, { type Sql } from "postgres"; import { createBetterAuth } from "../auth/betterauth"; import { seedUserSpace } from "../test-support"; @@ -43,11 +41,11 @@ let betterAuth: ReturnType; let core: engineCore.CoreStore; const createdSpaceSchemas: string[] = []; -/** Authenticate a request bearing `token` (+ optional X-Me-As-Agent header). */ +/** Authenticate a request bearing `token` (+ optional legacy ignored header). */ function auth(token: string | undefined, asAgent?: string) { const headers: Record = {}; if (token) headers.Authorization = `Bearer ${token}`; - if (asAgent) headers[AS_AGENT_HEADER] = asAgent; + if (asAgent) headers["X-Me-As-Agent"] = asAgent; const request = new Request("http://localhost/api/v1/user/rpc", { method: "POST", headers, @@ -173,153 +171,36 @@ test("a restricted PAT carries its key identity and restriction state", async () } }); -test("an agent api key is admitted on the user RPC as kind 'a' (no email)", async () => { - // Authn establishes *who*; it no longer doubles as the authz gate. An agent - // key validates to its agent principal so the account-scoped reads (whoami, - // space.list) work; the per-method handlers still deny account management. +test("a service-account api key is admitted on the user RPC as kind 's' (no email)", async () => { const userId = await seedUser(); - const agentId = await core.createAgent(userId, `agent-${rand()}`); - const key = await core.createApiKey(agentId, "ci"); + const spaceId = (await core.listSpacesForMember(userId))[0]?.id as string; + const serviceAccount = await core.createServiceAccount( + spaceId, + `svc-${rand()}`, + ); + const key = await core.createApiKey(serviceAccount.id, "ci"); const result = await auth(engineCore.formatApiKey(key.lookupId, key.secret)); expect(result.ok).toBe(true); if (result.ok) { - expect(result.context.kind).toBe("a"); - expect(result.context.userId).toBe(agentId); + expect(result.context.kind).toBe("s"); + expect(result.context.userId).toBe(serviceAccount.id); expect(result.context.viaApiKey).toBe(true); expect(result.context.email).toBeNull(); expect(result.context.emailVerified).toBe(false); } }); -// ============================================================================= -// Act-as-agent (X-Me-As-Agent) -// ============================================================================= - -test("act-as: human OAuth + owned agent by id → kind='a', userId=agent, email null, viaApiKey, authenticatedAs=human", async () => { - const userId = await seedUser(); - const agentId = await core.createAgent(userId, `agent-${rand()}`); - const result = await auth(await mintAccessToken(userId), agentId); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.kind).toBe("a"); - expect(result.context.userId).toBe(agentId); - expect(result.context.email).toBeNull(); - expect(result.context.emailVerified).toBe(false); - expect(result.context.viaApiKey).toBe(true); - expect(result.context.authenticatedAs).toBe(userId); - } -}); - -test("act-as: human OAuth + owned agent by name (mixed case) → switches to the agent", async () => { - const userId = await seedUser(); - const name = `agent-${rand()}`; - const agentId = await core.createAgent(userId, name); - const result = await auth(await mintAccessToken(userId), name.toUpperCase()); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.kind).toBe("a"); - expect(result.context.userId).toBe(agentId); - expect(result.context.authenticatedAs).toBe(userId); - } -}); - -test("act-as: human OAuth + owned agent by UPPERCASE id → switches (id match is case-insensitive)", async () => { - const userId = await seedUser(); - const agentId = await core.createAgent(userId, `agent-${rand()}`); - // Postgres emits uuids lowercase, but a client may send an uppercase UUID. - const result = await auth( - await mintAccessToken(userId), - agentId.toUpperCase(), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.context.kind).toBe("a"); - expect(result.context.userId).toBe(agentId); - expect(result.context.authenticatedAs).toBe(userId); - } -}); - -test("act-as: id/name collision among owned agents → 403 INVALID_AGENT", async () => { +test("legacy X-Me-As-Agent is ignored", async () => { const userId = await seedUser(); - const idAgent = await core.createAgent(userId, `agent-${rand()}`); - await core.createAgent(userId, idAgent); - - const result = await auth(await mintAccessToken(userId), idAgent); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.status).toBe(403); - const body = (await result.error.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("INVALID_AGENT"); - expect(body.error.message).toContain("matches multiple agents"); - } -}); - -test("act-as: agent-key bearer + X-Me-As-Agent → header ignored (key trumps)", async () => { - const userId = await seedUser(); - const a = await core.createAgent(userId, `agent-${rand()}`); - const b = await core.createAgent(userId, `agent-${rand()}`); // a valid other agent - const key = await core.createApiKey(a, "ci"); - const result = await auth( - engineCore.formatApiKey(key.lookupId, key.secret), - b, - ); - expect(result.ok).toBe(true); - if (result.ok) { - // Stays the key's own agent; not switched to b. - expect(result.context.userId).toBe(a); - expect(result.context.authenticatedAs).toBeNull(); - } -}); - -test("act-as: user PAT + X-Me-As-Agent → header ignored, key stays the user", async () => { - const userId = await seedUser(); - const agentId = await core.createAgent(userId, `agent-${rand()}`); const key = await core.createApiKey(userId, "pat"); const result = await auth( engineCore.formatApiKey(key.lookupId, key.secret), - agentId, + "retired-agent", ); expect(result.ok).toBe(true); if (result.ok) { expect(result.context.kind).toBe("u"); expect(result.context.userId).toBe(userId); - expect(result.context.authenticatedAs).toBeNull(); - } -}); - -test("act-as: unknown / unowned header → 403 INVALID_AGENT", async () => { - const userId = await seedUser(); - const other = await seedUser(); - const foreign = await core.createAgent(other, `agent-${rand()}`); - for (const value of [foreign, "does-not-exist"]) { - const result = await auth(await mintAccessToken(userId), value); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.status).toBe(403); - const body = (await result.error.json()) as { error: { code: string } }; - expect(body.error.code).toBe("INVALID_AGENT"); - } - } -}); - -test("act-as parity: human+X-Me-As-Agent equals the agent-key context on kind/userId", async () => { - const userId = await seedUser(); - const agentId = await core.createAgent(userId, `agent-${rand()}`); - const key = await core.createApiKey(agentId, "ci"); - - const asAgent = await auth(await mintAccessToken(userId), agentId); - const byKey = await auth(engineCore.formatApiKey(key.lookupId, key.secret)); - expect(asAgent.ok).toBe(true); - expect(byKey.ok).toBe(true); - if (asAgent.ok && byKey.ok) { - // Authorization reads only kind + userId — identical on both paths. - expect(asAgent.context.kind).toBe(byKey.context.kind); - expect(asAgent.context.userId).toBe(byKey.context.userId); - // Observability-only: authenticatedAs may differ. - expect(asAgent.context.authenticatedAs).toBe(userId); - expect(byKey.context.authenticatedAs).toBeNull(); } }); diff --git a/packages/server/middleware/authenticate-user.ts b/packages/server/middleware/authenticate-user.ts index 9ed92955..a8220422 100644 --- a/packages/server/middleware/authenticate-user.ts +++ b/packages/server/middleware/authenticate-user.ts @@ -4,25 +4,19 @@ * Resolves the calling principal from one of these credentials — an OAuth access * token (CLI/MCP), a signed better-auth session token presented as a bearer (the * device-authorization flow, via the `bearer` plugin), the browser cookie - * session, or an api key (a user PAT or an agent key, for headless/CLI use). - * Authentication establishes *who*; it no - * longer doubles as the authorization gate. Any authenticated principal is - * admitted here so the account-scoped *reads* (`whoami`, `space.list`) work for - * an agent acting with `ME_API_KEY`; the account-*management* methods stay - * user-only, enforced by the user-RPC gate's allow-list (`gateAgentAccess` + - * `requireUserCaller`). Sessions / OAuth tokens are always users; an api key - * carries its principal's real kind. + * session, or an api key (a user PAT or a service-account key). Authentication + * establishes *who*; the user-RPC gate limits service accounts to `whoami` and + * `space.list` while keeping account-management methods user-only. Sessions / + * OAuth tokens are always users; an api key carries its principal's real kind. */ import { type CoreStore, parseApiKey } from "@memory.build/engine/core"; -import { AS_AGENT_HEADER } from "@memory.build/protocol/headers"; import { debug, span } from "@pydantic/logfire-node"; import type { Auth, GetUserEmailVerified, VerifyOAuthAccessToken, } from "../auth/betterauth"; -import { error, forbidden, unauthorized } from "../util/response"; -import { resolveOwnedAgent } from "./act-as-agent"; +import { forbidden, unauthorized } from "../util/response"; import { recordApiKeyUse } from "./api-key-usage"; import { bearerOnlyHeaders, @@ -32,29 +26,28 @@ import { export interface UserAuthContext { type: "user"; - /** The authenticated principal's kind: user, agent, or service account. */ - kind: "u" | "a" | "s"; - /** The authenticated principal id (a user-principal id, agent id, or service-account id). */ + /** The authenticated principal's kind: user or service account. */ + kind: "u" | "s"; + /** The authenticated principal id (a user-principal or service-account id). */ userId: string; /** The user's email (powers whoami + lazy provisioning); null for non-users. */ email: string | null; /** * The principal's name. From a session / OAuth token this is the human's * display name; on the api-key path it's the core principal's name — which is - * the user's email for a user PAT, or the handle for an agent or service - * account. + * the user's email for a user PAT, or the service-account handle. */ name: string; /** * Whether the identity provider verified the email. Gates email-keyed * provisioning steps (invitation redemption) — invitations are addressed by * email, so an unverified address must not auto-join its invited spaces. - * Always false for non-users (agents and service accounts have no email). + * Always false for service accounts (which have no email). */ emailVerified: boolean; /** - * True when authenticated by an api key (a user PAT, an agent key, or a - * service-account key) rather than a session / OAuth token. The handler layer + * True when authenticated by an api key (a user PAT or service-account key) + * rather than a session / OAuth token. The handler layer * uses this to keep key mint/revoke session-only (a key can't manage keys). */ viaApiKey: boolean; @@ -62,13 +55,6 @@ export interface UserAuthContext { apiKeyId: string | null; /** Whether the authenticated API key carries access declarations. */ apiKeyRestricted: boolean; - /** - * When a human is acting as one of their own agents (via `X-Me-As-Agent`), - * the human's principal id; null otherwise. Observability only — never gates - * authorization (which reads `kind` / `userId`, both already switched to the - * agent). - */ - authenticatedAs: string | null; } export type UserAuthResult = @@ -86,24 +72,15 @@ export async function authenticateUser( return span("auth.user", { attributes: { "auth.type": "user" }, callback: async () => { - const result = await resolvePrincipal(); - if (!result.ok) return result; - // Act-as-agent switch: a human caller (kind 'u') may run as one of their - // own agents via `X-Me-As-Agent`. An agent key (kind 'a') already IS an - // agent → the header is ignored (parity precedence). On a match the whole - // context is overwritten to the agent, reusing the agent-key semantics so - // the `AGENT_ALLOWED` allow-list constrains it automatically. - return applyActAsAgent(result, request, core); + return resolvePrincipal(); }, }); async function resolvePrincipal(): Promise { const bearer = extractBearerToken(request); if (bearer) { - // An api key — a user PAT (kind 'u'), agent key (kind 'a'), or service - // account key (kind 's'). All are admitted; per-method handlers authorize - // what each may do (non-users get `whoami` / `space.list`, nothing that - // manages the account). + // An api key — a user PAT (kind 'u') or service-account key (kind 's'). + // Both are admitted; per-method handlers authorize what each may do. // An api key is never an OAuth token, so this branch always returns. const parsed = parseApiKey(bearer); if (parsed) { @@ -120,14 +97,10 @@ export async function authenticateUser( } // validate_api_key already joined core.principal and returns the kind + // name, so there's no second lookup. An api key only ever belongs to a - // member principal (user, agent, or service account); accept those + // member principal (user or service account); accept those // explicitly and reject anything else rather than trusting the DB's text // `kind`. - if ( - validated.kind !== "u" && - validated.kind !== "a" && - validated.kind !== "s" - ) { + if (validated.kind !== "u" && validated.kind !== "s") { debug("user auth failed: api key principal is not a member kind", { kind: validated.kind, }); @@ -149,13 +122,13 @@ export async function authenticateUser( kind: validated.kind, userId: validated.memberId, // For a user the core principal's name IS the email (the display - // name lives on auth.users, not fetched on the key path); agents and - // service accounts have no email — their names are display names. + // name lives on auth.users, not fetched on the key path); service + // accounts have no email — their names are display names. email: isUser ? validated.name : null, name: validated.name, // For a user PAT, carry the real verified flag (the same fact a // session reports), so it behaves like any other credential — - // including the email-keyed redemption step. Non-user principals have + // including the email-keyed redemption step. Service accounts have // no email to verify. A key's only carve-out is that it can't // mint/revoke keys (enforced at the handler layer). emailVerified: isUser @@ -164,7 +137,6 @@ export async function authenticateUser( viaApiKey: true, apiKeyId: validated.apiKeyId, apiKeyRestricted: validated.restricted, - authenticatedAs: null, }, }; } @@ -185,7 +157,6 @@ export async function authenticateUser( viaApiKey: false, apiKeyId: null, apiKeyRestricted: false, - authenticatedAs: null, }, }; } @@ -223,7 +194,6 @@ export async function authenticateUser( viaApiKey: false, apiKeyId: null, apiKeyRestricted: false, - authenticatedAs: null, }, }; } @@ -263,68 +233,6 @@ export async function authenticateUser( viaApiKey: false, apiKeyId: null, apiKeyRestricted: false, - authenticatedAs: null, - }, - }; - } - - /** - * Apply the `X-Me-As-Agent` switch to a resolved human context. When the - * bearer is a human (`kind === 'u'`) and the header unambiguously names one - * of their owned agents by id or case-insensitive name, overwrite the context - * to that agent — `kind='a'`, `userId=agent.id`, `email=null`, `name=agent.name`, - * `emailVerified=false`, `viaApiKey=true` (Decision B: strict agent-key - * parity), recording the human as `authenticatedAs` for observability. An - * agent key (`kind === 'a'`) already IS an agent, so the header is ignored. A - * header value that isn't an owned agent → 403 `INVALID_AGENT`. - */ - async function applyActAsAgent( - result: { ok: true; context: UserAuthContext }, - req: Request, - coreStore: CoreStore, - ): Promise { - const asAgent = req.headers.get(AS_AGENT_HEADER); - if ( - !asAgent || - result.context.kind !== "u" || - result.context.apiKeyId !== null - ) { - return result; - } - - const human = result.context.userId; - const agents = await coreStore.listAgents(human); - const resolved = resolveOwnedAgent(agents, asAgent); - if (resolved.kind !== "found") { - debug("user auth failed: X-Me-As-Agent not an owned agent", { - userId: human, - asAgent, - reason: resolved.kind, - }); - return { - ok: false, - error: error( - resolved.kind === "ambiguous" - ? `X-Me-As-Agent '${asAgent}' matches multiple agents you own; rename the conflicting agent` - : `X-Me-As-Agent '${asAgent}' is not an agent you own`, - 403, - "INVALID_AGENT", - ), - }; - } - const { agent } = resolved; - debug("user auth act-as-agent", { userId: human, agentId: agent.id }); - return { - ok: true, - context: { - ...result.context, - kind: "a", - userId: agent.id, - email: null, - name: agent.name, - emailVerified: false, - viaApiKey: true, - authenticatedAs: human, }, }; } diff --git a/packages/server/router.integration.test.ts b/packages/server/router.integration.test.ts index 71d83ba6..81040e02 100644 --- a/packages/server/router.integration.test.ts +++ b/packages/server/router.integration.test.ts @@ -114,7 +114,7 @@ test("a restricted PAT keeps its space scope and cannot manage the account throu ]); const managementResponse = await router.handleRequest( - userRpcRequest(token, "agent.list"), + userRpcRequest(token, "serviceAccount.list"), ); expect(managementResponse.status).toBe(200); const managementBody = (await managementResponse.json()) as { diff --git a/packages/server/router.ts b/packages/server/router.ts index b0276edd..0b4a07a0 100644 --- a/packages/server/router.ts +++ b/packages/server/router.ts @@ -166,17 +166,15 @@ export function createRouter(ctx: ServerContext): Router { space: spaceContext.space, principalId: spaceContext.principalId, principalKind: spaceContext.principalKind, - ownerId: spaceContext.ownerId, apiKeyId: spaceContext.apiKeyId, treeAccess: spaceContext.treeAccess, admin: spaceContext.admin, - authenticatedAs: spaceContext.authenticatedAs, embeddingConfig, }; }); - // User RPC (new model): account-scoped. Admits any authenticated principal; - // the handlers authorize per-method (an agent gets whoami / space.list only). + // User RPC (new model): account-scoped. Service accounts may call only + // whoami / space.list; the handlers authorize every other method as user-only. const userRpcHandler = createRpcHandler(userMethods, async (request) => { const result = await authenticateUser( request, @@ -198,15 +196,13 @@ export function createRouter(ctx: ServerContext): Router { viaApiKey, apiKeyId, apiKeyRestricted, - authenticatedAs, } = result.context; // Lazy first-login provisioning: stand up the core principal the first time // a better-auth user reaches the user RPC (idempotent no-op thereafter). The // CLI hits whoami/space.list right after login, so this is the natural first // touchpoint. NO default space is created here — it's provisioned explicitly // via space.ensureDefault at onboarding (so invitees who join don't get a - // junk space). USERS only — an agent is already provisioned by its owner, and - // provisioning is user+email keyed (an agent has neither). + // junk space). USERS only — provisioning is user+email keyed. if (kind === "u" && email !== null) { await ensureUserProvisioned( db, @@ -230,7 +226,6 @@ export function createRouter(ctx: ServerContext): Router { viaApiKey, apiKeyId, apiKeyRestricted, - authenticatedAs, }; }); @@ -276,7 +271,7 @@ export function createRouter(ctx: ServerContext): Router { handler: withClientVersionCheck(memoryRpcHandler), }, - // User RPC: account, agent, service-account, key, and space management. + // User RPC: account, service-account, key, and space management. { method: "POST", pattern: "/api/v1/user/rpc", diff --git a/packages/server/rpc/handler.ts b/packages/server/rpc/handler.ts index bb144e6c..31cef7d6 100644 --- a/packages/server/rpc/handler.ts +++ b/packages/server/rpc/handler.ts @@ -168,10 +168,6 @@ export async function handleRpcRequest( if (typeof ctx.userId === "string") identityAttrs["user.id"] = ctx.userId; if (typeof ctx.apiKeyId === "string") identityAttrs["api_key.id"] = ctx.apiKeyId; - // When a human is acting as one of their own agents (X-Me-As-Agent), record - // the human separately for observability. Never gates authorization. - if (typeof ctx.authenticatedAs === "string") - identityAttrs.authenticated_as = ctx.authenticatedAs; if (ctx.identity && typeof ctx.identity === "object") { const identity = ctx.identity as { id?: string }; if (identity.id) identityAttrs["identity.id"] = identity.id; diff --git a/packages/server/rpc/memory/access.ts b/packages/server/rpc/memory/access.ts index da039d09..93c46935 100644 --- a/packages/server/rpc/memory/access.ts +++ b/packages/server/rpc/memory/access.ts @@ -8,7 +8,6 @@ import type { AccessEffectiveParams, AccessEffectiveResult, AccessLevel, - EffectiveAccessAuthenticatedAs, EffectiveAccessEntry, EffectiveAccessPrincipal, } from "@memory.build/protocol/space"; @@ -19,7 +18,7 @@ import { import { AppError } from "../errors"; import { buildRegistry } from "../registry"; import type { HandlerContext } from "../types"; -import { callerAdministersServiceAccount, callerOwnsAgent } from "./support"; +import { callerAdministersServiceAccount } from "./support"; import { assertSpaceRpcContext, type SpaceRpcContext } from "./types"; /** @@ -31,7 +30,7 @@ import { assertSpaceRpcContext, type SpaceRpcContext } from "./types"; */ function callerTreePathOptions(ctx: SpaceRpcContext): TreePathOptions { if (ctx.principalKind === "s") return {}; - return { home: ctx.principalId, homeOwner: ctx.ownerId ?? undefined }; + return { home: ctx.principalId }; } function toEffectiveAccessEntry( @@ -53,14 +52,14 @@ function compareTreeAccess( return a.tree_path.localeCompare(b.tree_path) || b.access - a.access; } -function assertExecutableKind(kind: string): asserts kind is "u" | "a" | "s" { +function assertExecutableKind(kind: string): asserts kind is "u" | "s" { if (kind === "g") { throw new AppError( "VALIDATION_ERROR", "Groups have raw grants, not effective access as an executable principal", ); } - if (kind !== "u" && kind !== "a" && kind !== "s") { + if (kind !== "u" && kind !== "s") { throw new AppError("VALIDATION_ERROR", "Invalid principal kind"); } } @@ -77,21 +76,10 @@ async function currentPrincipal( id: principal.id, kind: principal.kind, name: principal.name, - ownerId: principal.ownerId, admin: ctx.admin, }; } -async function authenticatedAs( - ctx: SpaceRpcContext, -): Promise { - if (!ctx.authenticatedAs) return null; - const principal = await ctx.core.getPrincipal(ctx.authenticatedAs); - if (!principal) return null; - assertExecutableKind(principal.kind); - return { id: principal.id, kind: principal.kind, name: principal.name }; -} - async function targetPrincipal( ctx: SpaceRpcContext, principalId: string, @@ -111,7 +99,6 @@ async function targetPrincipal( id: principal.id, kind: principal.kind, name: principal.name, - ownerId: principal.ownerId, admin: await ctx.core.isSpaceAdmin(principal.id, ctx.space.id), }; } @@ -122,7 +109,6 @@ async function requireEffectiveAccessInspection( ): Promise { if (principalId === ctx.principalId) return; if (ctx.admin) return; - if (await callerOwnsAgent(ctx, principalId)) return; if (await callerAdministersServiceAccount(ctx, principalId)) return; throw new AppError( "FORBIDDEN", @@ -154,14 +140,9 @@ async function accessEffective( // absolute paths so a `~` is never misattributed to the caller. const pathOptions = isCurrent ? callerTreePathOptions(ctx) : {}; - // authenticatedAs is a property of the *caller's* session (the human behind - // act-as-agent), so it only makes sense alongside the caller's own access. - // When inspecting another principal it would misleadingly pair that - // principal's access with the caller's identity, so report null instead. return { space: { id: ctx.space.id, slug: ctx.space.slug, name: ctx.space.name }, principal, - authenticatedAs: isCurrent ? await authenticatedAs(ctx) : null, access: [...treeAccess] .sort(compareTreeAccess) .map((row) => toEffectiveAccessEntry(pathOptions, row)), diff --git a/packages/server/rpc/memory/grant.ts b/packages/server/rpc/memory/grant.ts index 0fc1ef6a..1f61cda2 100644 --- a/packages/server/rpc/memory/grant.ts +++ b/packages/server/rpc/memory/grant.ts @@ -20,7 +20,6 @@ import { buildRegistry } from "../registry"; import type { HandlerContext } from "../types"; import { callerAdministersServiceAccount, - callerOwnsAgent, guardCore, inputTreePath, ownsTreePath, @@ -32,16 +31,13 @@ import { assertSpaceRpcContext, type SpaceRpcContext } from "./types"; /** * Authority to grant/remove access at a path. Allowed when any of: - * - the target is the caller's OWN agent (self-service — capped anyway); * - the caller is a space admin (admins manage all access); * - the caller owns the path or an ancestor (owner@root owns the whole tree). */ async function requireGrantAuthority( ctx: SpaceRpcContext, - principalId: string, treePath: string, ): Promise { - if (await callerOwnsAgent(ctx, principalId)) return; if (ctx.admin) return; await requireTreeOwner(ctx, treePath); } @@ -52,7 +48,7 @@ async function requireGrantRemovalAuthority( treePath: string, ): Promise { if (await callerAdministersServiceAccount(ctx, principalId)) return; - await requireGrantAuthority(ctx, principalId, treePath); + await requireGrantAuthority(ctx, treePath); } async function grantSet( @@ -62,7 +58,7 @@ async function grantSet( assertSpaceRpcContext(context); const ctx = context as SpaceRpcContext; const treePath = inputTreePath(ctx, params.treePath); - await requireGrantAuthority(ctx, params.principalId, treePath); + await requireGrantAuthority(ctx, treePath); await guardCore(() => ctx.core.grantTreeAccess( ctx.space.id, @@ -97,9 +93,8 @@ async function grantList( // No path filter means the whole space, i.e. the root path. Listing grants // under a path requires owning that path (root → owning the whole space), // else space-admin. Self-service paths (no admin/owner needed): listing your - // own grants (powers `me access mine`), or those of an agent you own — both - // keep the principal filter pinned to you (self) or your owned agent, so they - // can't reveal another principal's grants. + // Own grants (powers `me access mine`) keep the principal filter pinned to the + // caller, so they can't reveal another principal's grants. const under = params.treePath !== undefined && params.treePath !== null ? inputTreePath(ctx, params.treePath) @@ -108,20 +103,11 @@ async function grantList( params.principalId !== undefined && params.principalId !== null && params.principalId === ctx.principalId; - const ownAgent = - params.principalId !== undefined && - params.principalId !== null && - (await callerOwnsAgent(ctx, params.principalId)); const ownServiceAccount = params.principalId !== undefined && params.principalId !== null && (await callerAdministersServiceAccount(ctx, params.principalId)); - if ( - !ownSelf && - !ownAgent && - !ownServiceAccount && - !ownsTreePath(ctx, under) - ) { + if (!ownSelf && !ownServiceAccount && !ownsTreePath(ctx, under)) { await requireSpaceAdmin(ctx); } const grants = await ctx.core.listTreeAccessGrants( diff --git a/packages/server/rpc/memory/group.ts b/packages/server/rpc/memory/group.ts index 6a91f207..98182055 100644 --- a/packages/server/rpc/memory/group.ts +++ b/packages/server/rpc/memory/group.ts @@ -37,7 +37,6 @@ import { AppError } from "../errors"; import { buildRegistry } from "../registry"; import type { HandlerContext } from "../types"; import { - callerOwnsAgent, guardCore, requireGroupAdmin, requireSpaceAdmin, @@ -207,12 +206,8 @@ async function groupListForMember( ): Promise { assertSpaceRpcContext(context); const ctx = context as SpaceRpcContext; - // You may see your OWN memberships, or those of an agent you own (so - // `me agent group list` works); seeing anyone else's requires space-admin. - if ( - params.memberId !== ctx.principalId && - !(await callerOwnsAgent(ctx, params.memberId)) - ) { + // You may see your own memberships; seeing anyone else's requires space-admin. + if (params.memberId !== ctx.principalId) { await requireSpaceAdmin(ctx); } const groups = await ctx.core.listGroupsForMember( diff --git a/packages/server/rpc/memory/index.ts b/packages/server/rpc/memory/index.ts index 5c655a7b..7bcb1132 100644 --- a/packages/server/rpc/memory/index.ts +++ b/packages/server/rpc/memory/index.ts @@ -22,7 +22,7 @@ export { /** * The full memory-endpoint registry: data-plane + space management methods. - * (Agent lifecycle and api keys live on the user endpoint — see rpc/user.) + * (API keys live on the user endpoint — see rpc/user.) */ export const memoryMethods: MethodRegistry = new Map([ ...memoryDataMethods, diff --git a/packages/server/rpc/memory/management.integration.test.ts b/packages/server/rpc/memory/management.integration.test.ts index 61bf9922..0198c840 100644 --- a/packages/server/rpc/memory/management.integration.test.ts +++ b/packages/server/rpc/memory/management.integration.test.ts @@ -1,5 +1,5 @@ -// Integration test for the space management handlers (4C-2b): member / agent / -// group / grant / invite, driven through the merged memory registry against a +// Integration test for the space management handlers: member / group / grant / +// invite, driven through the merged memory registry against a // provisioned space. The provisioned owner has owner@root, satisfying the // management authorization gate. (Api keys are user-endpoint — see // rpc/user/api-key.integration.test.ts.) @@ -47,7 +47,7 @@ function call( params: unknown, as: { principalId?: string; - principalKind?: "u" | "a" | "s"; + principalKind?: "u" | "s"; treeAccess?: TreeAccess; admin?: boolean; } = {}, @@ -61,7 +61,6 @@ function call( space, principalId: as.principalId ?? ownerId, principalKind: as.principalKind ?? "u", - ownerId: null, // user/session caller apiKeyId: null, treeAccess: as.treeAccess ?? ownerTreeAccess, // the provisioned owner is also a space admin; non-owner callers default false @@ -90,16 +89,6 @@ async function makeUser(): Promise { return id; } -/** - * Create a global agent owned by `owner` (the user-endpoint operation), returning - * its id. Not yet a member of any space — principal.add brings it in. - */ -function makeAgent(owner: string): Promise { - return engineCore - .coreStore(sql, coreSchema) - .createAgent(owner, `agent_${rand(6)}`); -} - /** Create a registered user with a known email (the invite key), returning its id. */ async function makeUserWithEmail(email: string): Promise { const [row] = await sql`select uuidv7() as id`; @@ -197,18 +186,14 @@ test("last-admin safeguard: removing/demoting the sole admin → LAST_ADMIN", as ).toBe(true); }); -test("non-admin user can self-remove (leave), cascading their in-space agent", async () => { - // a non-admin member with an agent they brought into the space +test("non-admin user can self-remove (leave)", async () => { const member = await makeUser(); await call("principal.add", { principalId: member }); - const agent = await makeAgent(member); const asMember = { principalId: member, admin: false, treeAccess: [{ tree_path: "share", access: 1 }] as TreeAccess, }; - await call("principal.add", { principalId: agent }, asMember); // self-service add - // the member removes THEMSELVES (no admin) — succeeds expect( ( @@ -220,51 +205,12 @@ test("non-admin user can self-remove (leave), cascading their in-space agent", a ).removed, ).toBe(true); - // both the member and their agent are gone from the roster (DB cascade) + // the member is gone from the roster const { principals } = await call<{ principals: { id: string }[] }>( "principal.list", {}, ); expect(principals.some((p) => p.id === member)).toBe(false); - expect(principals.some((p) => p.id === agent)).toBe(false); -}); - -test("non-admin can remove their OWN agent, but not another user / another's agent", async () => { - const member = await makeUser(); - await call("principal.add", { principalId: member }); - const asMember = { - principalId: member, - admin: false, - treeAccess: [{ tree_path: "share", access: 1 }] as TreeAccess, - }; - - // own agent → allowed - const ownAgent = await makeAgent(member); - await call("principal.add", { principalId: ownAgent }, asMember); - expect( - ( - await call<{ removed: boolean }>( - "principal.remove", - { principalId: ownAgent }, - asMember, - ) - ).removed, - ).toBe(true); - - // another user (the admin owner) → FORBIDDEN (authz before last-admin) - await expectAppError( - call("principal.remove", { principalId: ownerId }, asMember), - "FORBIDDEN", - ); - - // someone else's agent → FORBIDDEN - const otherUser = await makeUser(); - const otherAgent = await makeAgent(otherUser); - await call("principal.add", { principalId: otherAgent }); - await expectAppError( - call("principal.remove", { principalId: otherAgent }, asMember), - "FORBIDDEN", - ); }); test("principal.resolve / lookup are available to non-admin members (list is admin-only)", async () => { @@ -315,8 +261,6 @@ test("space.listMembers is available to non-admin members and can list users onl const memberId = await makeUserWithEmail(email); await call("principal.add", { principalId: memberId }); - const agentId = await makeAgent(ownerId); - await call("principal.add", { principalId: agentId }); const { id: groupId } = await call<{ id: string }>("group.create", { name: `group_${rand(6)}`, }); @@ -344,7 +288,6 @@ test("space.listMembers is available to non-admin members and can list users onl kind: "u", name: email, }); - expect(listed.members.map((m) => m.id)).not.toContain(agentId); expect(listed.members.map((m) => m.id)).not.toContain(service.id); expect(listed.members.map((m) => m.id)).not.toContain(groupId); @@ -353,7 +296,6 @@ test("space.listMembers is available to non-admin members and can list users onl {}, asMember, ); - expect(allMembers.members.map((m) => m.id)).toContain(agentId); expect(allMembers.members.map((m) => m.id)).toContain(service.id); expect(allMembers.members.map((m) => m.id)).not.toContain(groupId); }); @@ -786,15 +728,10 @@ test("access.effective: admin can inspect another member's effective access", as const result = await call<{ principal: { id: string }; - authenticatedAs: unknown; access: { treePath: string; accessName: string }[]; }>("access.effective", { principalId: member }); expect(result.principal.id).toBe(member); - // authenticatedAs describes the caller's session, not the target — it is null - // when inspecting another principal so the target's access is not paired with - // the caller's identity. - expect(result.authenticatedAs).toBeNull(); expect(result.access).toContainEqual( expect.objectContaining({ treePath: "/projects", accessName: "write" }), ); @@ -819,35 +756,6 @@ test("access.effective: admin can inspect another member's effective access", as ); }); -test("access.effective: an agent owner can inspect clamped agent access", async () => { - const member = await makeUser(); - const agentId = await makeAgent(member); - await call("principal.add", { principalId: member }); - await call("principal.add", { principalId: agentId }); - await call("grant.set", { principalId: member, treePath: "docs", access: 1 }); - await call("grant.set", { - principalId: agentId, - treePath: "docs", - access: 2, - }); - - const core = engineCore.coreStore(sql, coreSchema); - const memberAccess = await core.buildTreeAccess(member, space.id); - const result = await call<{ - principal: { id: string; kind: string }; - access: { treePath: string; accessName: string }[]; - }>( - "access.effective", - { principalId: agentId }, - { principalId: member, treeAccess: memberAccess, admin: false }, - ); - - expect(result.principal).toMatchObject({ id: agentId, kind: "a" }); - expect(result.access).toContainEqual( - expect.objectContaining({ treePath: "/docs", accessName: "read" }), - ); -}); - test("access.effective: service-account admin can inspect service-account group access", async () => { const core = engineCore.coreStore(sql, coreSchema); const manager = await makeUser(); @@ -919,7 +827,7 @@ test("access.effective: rejects unrelated principals and groups", async () => { test("a denied grant/roster op names the effective admins on the error", async () => { // A plain member: not an admin, owns no tree path, and the grant target is - // another user (not their own agent) — every self-service carve-out misses. + // another user — every self-service carve-out misses. const member = await makeUser(); const other = await makeUserWithEmail(`target_${rand(6)}@example.com`); await call("principal.add", { principalId: member }); @@ -952,42 +860,6 @@ test("a denied grant/roster op names the effective admins on the error", async ( await expectAdminsOn(call("principal.list", {}, as)); }); -test("grant.list: an agent's owner can list its grants", async () => { - // a member who owns an agent that holds a grant; the member is not an admin - // and owns no tree path of their own - const member = await makeUser(); - const agentId = await makeAgent(member); - await call("principal.add", { principalId: agentId }); - await call("grant.set", { - principalId: agentId, - treePath: "docs", - access: 1, - }); - const as = { - principalId: member, - treeAccess: [] as TreeAccess, - admin: false, - }; - - const res = await call<{ grants: { principalId: string }[] }>( - "grant.list", - { principalId: agentId }, - as, - ); - expect(res.grants.some((g) => g.principalId === agentId)).toBe(true); - - // a stranger who doesn't own the agent cannot - const stranger = await makeUser(); - await expectAppError( - call( - "grant.list", - { principalId: agentId }, - { principalId: stranger, treeAccess: [] as TreeAccess, admin: false }, - ), - "FORBIDDEN", - ); -}); - test("grant.list/remove: a service-account admin can inspect and revoke its grants", async () => { const core = engineCore.coreStore(sql, coreSchema); const manager = await makeUser(); @@ -1063,129 +935,6 @@ test("service-account callers do not get '~' home expansion", async () => { ); }); -test("grant.set/remove: an agent's owner can grant at an unowned path (TNT-165)", async () => { - // A member who is NOT a space admin and does NOT own the target subtree — they - // hold only write@share.work (level 2, not owner) — can still grant and revoke - // access for their OWN agent there. Agent access is clamped to the owner's, so - // this self-service can't escalate beyond what the owner already has. - const core = engineCore.coreStore(sql, coreSchema); - const member = await makeUser(); - await call("principal.add", { principalId: member }); - // real, non-owner write access for the member at share.work - await call("grant.set", { - principalId: member, - treePath: "share/work", - access: 2, - }); - const agentId = await makeAgent(member); - await call("principal.add", { principalId: agentId }); - - const memberTa = await core.buildTreeAccess(member, space.id); - const as = { principalId: member, treeAccess: memberTa, admin: false }; - - // they can grant their own agent at share.work.sub despite holding no owner - // grant there (the agent-ownership bypass, not admin/owner, is what allows it) - expect( - ( - await call<{ granted: boolean }>( - "grant.set", - { principalId: agentId, treePath: "share/work/sub", access: 2 }, - as, - ) - ).granted, - ).toBe(true); - - // proof it's the agent-ownership doing the work: granting the SAME path to a - // plain user (a space member, but NOT the caller's agent) is forbidden for - // this same member — so the failure is strictly the missing agent-ownership - // authority, not the target's non-membership. - const otherUser = await makeUser(); - await call("principal.add", { principalId: otherUser }); - await expectAppError( - call( - "grant.set", - { principalId: otherUser, treePath: "share/work/sub", access: 2 }, - as, - ), - "FORBIDDEN", - ); - - // the grant is EFFECTIVE, clamped to the owner's write (min(2, 2) = 2) - const agentTa = await core.buildTreeAccess(agentId, space.id); - expect(agentTa).toContainEqual({ tree_path: "share.work.sub", access: 2 }); - - // they can revoke it too - expect( - ( - await call<{ removed: boolean }>( - "grant.remove", - { principalId: agentId, treePath: "share/work/sub" }, - as, - ) - ).removed, - ).toBe(true); - - // a stranger who doesn't own the agent (and isn't admin/owner) cannot - const stranger = await makeUser(); - const asStranger = { - principalId: stranger, - treeAccess: [] as TreeAccess, - admin: false, - }; - await expectAppError( - call( - "grant.set", - { principalId: agentId, treePath: "share/work/sub", access: 2 }, - asStranger, - ), - "FORBIDDEN", - ); - await expectAppError( - call( - "grant.remove", - { principalId: agentId, treePath: "share/work/sub" }, - asStranger, - ), - "FORBIDDEN", - ); -}); - -test("an agent grant is clamped DOWN to the owner's level, not dropped (TNT-165)", async () => { - // The headline case: a member holding only READ at share.work grants their - // agent WRITE at share.work.sub. The grant is allowed (self-service) and the - // agent ends up with READ there — clamped down to the owner's level, not - // dropped to nothing. - const core = engineCore.coreStore(sql, coreSchema); - const member = await makeUser(); - await call("principal.add", { principalId: member }); - await call("grant.set", { - principalId: member, - treePath: "share/work", - access: 1, - }); - const agentId = await makeAgent(member); - await call("principal.add", { principalId: agentId }); - const as = { - principalId: member, - treeAccess: await core.buildTreeAccess(member, space.id), - admin: false, - }; - - expect( - ( - await call<{ granted: boolean }>( - "grant.set", - { principalId: agentId, treePath: "share/work/sub", access: 2 }, - as, - ) - ).granted, - ).toBe(true); - - // owner holds read(1) at share.work → the agent's write(2) clamps to read(1) - const agentTa = await core.buildTreeAccess(agentId, space.id); - expect(agentTa).toContainEqual({ tree_path: "share.work.sub", access: 1 }); -}); - test("group member management allows a group admin (not a space admin)", async () => { // owner creates a group and makes `lead` an admin of it (a fresh name — every // space is now provisioned with a default "team" group) @@ -1367,41 +1116,6 @@ test("service-account admin group membership is managed only by space admins or ).toBe(true); }); -test("group.listForMember: an agent's owner can list its groups", async () => { - // owner sets up: a member who owns an agent, the agent is in a group - const member = await makeUser(); - const agentId = await makeAgent(member); - await call("principal.add", { principalId: agentId }); - const { id: groupId } = await call<{ id: string }>("group.create", { - name: "bots", - }); - await call("group.addMember", { groupId, memberId: agentId }); - - // the member (agent owner, not a space admin) can list their agent's groups - const as = { - principalId: member, - treeAccess: [] as TreeAccess, - admin: false, - }; - const res = await call<{ groups: { groupId: string }[] }>( - "group.listForMember", - { memberId: agentId }, - as, - ); - expect(res.groups.some((g) => g.groupId === groupId)).toBe(true); - - // a stranger who doesn't own the agent cannot - const stranger = await makeUser(); - await expectAppError( - call( - "group.listForMember", - { memberId: agentId }, - { principalId: stranger, treeAccess: [] as TreeAccess, admin: false }, - ), - "FORBIDDEN", - ); -}); - test("structural mutations require admin — owner@root is not enough", async () => { // a member who owns the whole data tree (owner@root) but is NOT a space admin const member = await makeUser(); @@ -1424,8 +1138,7 @@ test("structural mutations require admin — owner@root is not enough", async () call("principal.add", { principalId: stranger }, as), "FORBIDDEN", ); - // Removing ANOTHER principal is admin-only (the self / own-agent carve-outs - // are exercised in the dedicated self-remove test above). + // Removing another principal is admin-only (self-leave is covered above). await expectAppError( call("principal.remove", { principalId: stranger }, as), "FORBIDDEN", @@ -1471,55 +1184,3 @@ test("grant authority is path-scoped: a subtree owner delegates within it", asyn expect(underProj.grants.some((g) => g.treePath === "/proj/sub")).toBe(true); await expectAppError(call("grant.list", {}, as), "FORBIDDEN"); }); - -test("self-service: a non-owner member brings their own agent into the space", async () => { - // owner onboards a second user with write (not owner) on a subtree - const member = await makeUser(); - await call("principal.add", { principalId: member }); - await call("grant.set", { principalId: member, treePath: "proj", access: 2 }); - const memberTA = await engineCore - .coreStore(sql, coreSchema) - .buildTreeAccess(member, space.id); - const as = { principalId: member, treeAccess: memberTA }; - - // the member created their agent on the user endpoint (simulated via core); - // they bring it into the space (self-service principal.add) without owner rights - const agentId = await makeAgent(member); - expect( - ( - await call<{ added: boolean }>( - "principal.add", - { principalId: agentId }, - as, - ) - ).added, - ).toBe(true); - - // and self-grant it (capped by their own access). Minting the agent's api key - // is a user-endpoint op (apiKey.* — see rpc/user/api-key.integration.test.ts). - expect( - ( - await call<{ granted: boolean }>( - "grant.set", - { principalId: agentId, treePath: "proj", access: 2 }, - as, - ) - ).granted, - ).toBe(true); - - // but the member cannot manage the roster, add a stranger, or grant to others - await expectAppError(call("principal.list", {}, as), "FORBIDDEN"); - const stranger = await makeUser(); - await expectAppError( - call("principal.add", { principalId: stranger }, as), - "FORBIDDEN", - ); - await expectAppError( - call( - "grant.set", - { principalId: stranger, treePath: "proj", access: 1 }, - as, - ), - "FORBIDDEN", - ); -}); diff --git a/packages/server/rpc/memory/memory.integration.test.ts b/packages/server/rpc/memory/memory.integration.test.ts index 2cff1eb5..7bd5b57a 100644 --- a/packages/server/rpc/memory/memory.integration.test.ts +++ b/packages/server/rpc/memory/memory.integration.test.ts @@ -45,7 +45,7 @@ function call( method: string, params: unknown, ta: TreeAccess = treeAccess, - as: { principalId?: string; principalKind?: "u" | "a" | "s" } = {}, + as: { principalId?: string; principalKind?: "u" | "s" } = {}, ): Promise { const registered = memoryDataMethods.get(method); if (!registered) throw new Error(`no handler for ${method}`); @@ -56,7 +56,6 @@ function call( space, principalId: as.principalId ?? principalId, principalKind: as.principalKind ?? "u", - ownerId: null, // user/session caller apiKeyId: null, treeAccess: ta, } as unknown as HandlerContext; diff --git a/packages/server/rpc/memory/memory.test.ts b/packages/server/rpc/memory/memory.test.ts index 4afc35f1..745b0a8d 100644 --- a/packages/server/rpc/memory/memory.test.ts +++ b/packages/server/rpc/memory/memory.test.ts @@ -41,24 +41,6 @@ describe("dedupeOwnHome", () => { expect(result.find((e) => e.tree === "home.abc")?.count).toBe(2); }); - test("agent home strips both `home` and the owner-home ancestor", () => { - // An agent's home nests under its owner's: `home..`. - const AGENT = "home.own.ag"; - const entries: Entry[] = [ - { tree: "home", count: 5 }, // 2 agent + 3 owner/others - { tree: "home.own", count: 4 }, // 2 agent + 2 owner-direct - { tree: "home.own.ag", count: 2 }, // ~ (agent's own) - { tree: "home.own.ag.x", count: 2 }, - { tree: "home.own.notes", count: 2 }, // owner's own memories - ]; - const result = dedupeOwnHome(entries, AGENT); - const byTree = Object.fromEntries(result.map((e) => [e.tree, e.count])); - expect(byTree.home).toBe(3); // 5 - 2 - expect(byTree["home.own"]).toBe(2); // 4 - 2 (owner's non-agent memories) - expect(byTree["home.own.ag"]).toBe(2); // untouched (this is `~`) - expect(byTree["home.own.notes"]).toBe(2); // untouched - }); - test("no-op when the caller has no home memories", () => { const entries: Entry[] = [ { tree: "home", count: 1 }, // another member's home only diff --git a/packages/server/rpc/memory/memory.ts b/packages/server/rpc/memory/memory.ts index 607872ab..27b63f0e 100644 --- a/packages/server/rpc/memory/memory.ts +++ b/packages/server/rpc/memory/memory.ts @@ -172,9 +172,8 @@ function nlevel(path: string): number { * time under a literal `home` root. * * `list_tree` explodes every memory into all of its prefixes, so a memory at - * `home..x` bumps the count of the bare `home` prefix (and, for an - * agent whose home nests under its owner's, `home.` too). Those ancestor - * prefixes are NOT reverse-mapped to `~` by `displayTreePath` — they render as a + * `home..x` bumps the count of the bare `home` prefix. That ancestor is + * NOT reverse-mapped to `~` by `displayTreePath` — it renders as a * literal `home` root — so their counts double-count the caller's own home, * which is already shown under `~`. We subtract the caller's own-home aggregate * (the count at `homePrefix` itself) from each strict ancestor of `homePrefix`, @@ -192,8 +191,8 @@ export function dedupeOwnHome( const result: T[] = []; for (const entry of entries) { - // A strict ancestor of the caller's home (`home`, and `home.` for an - // agent) — not the home prefix itself, nor any of its descendants. + // A strict ancestor of the caller's home — not the home prefix itself, nor + // any of its descendants. if (homePrefix.startsWith(`${entry.tree}.`)) { const adjusted = entry.count - own; if (adjusted > 0) result.push({ ...entry, count: adjusted }); diff --git a/packages/server/rpc/memory/principal.ts b/packages/server/rpc/memory/principal.ts index 9f16e6eb..1baf18d9 100644 --- a/packages/server/rpc/memory/principal.ts +++ b/packages/server/rpc/memory/principal.ts @@ -23,7 +23,6 @@ import { import { buildRegistry } from "../registry"; import type { HandlerContext } from "../types"; import { - callerOwnsAgentGlobal, guardCore, requireSpaceAdmin, toSpacePrincipalResponse, @@ -37,7 +36,7 @@ async function principalList( assertSpaceRpcContext(context); const ctx = context as SpaceRpcContext; // Enumerating the whole roster — including groups and each principal's admin / - // owner_id / timestamps — is structural, so admin only. (Targeted name / id + // timestamps — is structural, so admin only. (Targeted name / id // lookups for any member are principal.resolve / principal.lookup; a minimal // member listing without that metadata is space.listMembers, member-accessible.) await requireSpaceAdmin(ctx); @@ -54,16 +53,7 @@ async function principalAdd( ): Promise { assertSpaceRpcContext(context); const ctx = context as SpaceRpcContext; - // Bringing your OWN agent into a space is self-service (it stays capped by - // your access); adding anyone else is a structural roster change that requires - // space-admin (owner@root is not enough). A member can't grant themselves admin - // on their own agent membership. - const ownAgent = - params.admin !== true && - (await callerOwnsAgentGlobal(ctx, params.principalId)); - if (!ownAgent) { - await requireSpaceAdmin(ctx); - } + await requireSpaceAdmin(ctx); await guardCore(() => ctx.core.addPrincipalToSpace( ctx.space.id, @@ -80,26 +70,16 @@ async function principalRemove( ): Promise { assertSpaceRpcContext(context); const ctx = context as SpaceRpcContext; - // Removing a roster member is structural — space-admin only — with two - // self-service exceptions that mirror `principal.add`'s own-agent carve-out: - // (a) a member removing THEIR OWN agent (inverse of `me agent add`), and - // (b) a USER removing THEMSELVES (`me space leave`). - // A user may remove themselves (`me space leave`), but agents and service - // accounts are managed by their owner/admins and must fall through to the - // structural gate even when `params.principalId === ctx.principalId`. + // Removing a roster member is structural — space-admin only — except a user + // may remove themselves (`me space leave`). Service accounts must fall through + // to the structural gate even when `params.principalId === ctx.principalId`. // `LAST_ADMIN` still protects a sole admin (the deferred trigger, mapped by // guardCore). // - // The allow set is `isSelfUser || admin || ownAgent`. Evaluate the two cheap - // in-context checks first and only fall back to the own-agent carve-out — a - // `getPrincipal` round-trip — for a non-self, non-admin removal, so the common - // admin remove-member and self-leave paths pay no extra query. const isSelfUser = params.principalId === ctx.principalId && ctx.principalKind === "u"; if (!isSelfUser && !ctx.admin) { - const ownAgent = await callerOwnsAgentGlobal(ctx, params.principalId); - // Not self, not admin, not the caller's own agent → structural, denied. - if (!ownAgent) await requireSpaceAdmin(ctx); + await requireSpaceAdmin(ctx); } const removed = await guardCore(() => ctx.core.removePrincipalFromSpace(ctx.space.id, params.principalId), diff --git a/packages/server/rpc/memory/space.ts b/packages/server/rpc/memory/space.ts index 189b3370..755fb3c4 100644 --- a/packages/server/rpc/memory/space.ts +++ b/packages/server/rpc/memory/space.ts @@ -9,7 +9,7 @@ import { buildRegistry } from "../registry"; import type { HandlerContext } from "../types"; import { assertSpaceRpcContext, type SpaceRpcContext } from "./types"; -const MEMBER_KINDS: SpaceMemberKind[] = ["u", "a", "s"]; +const MEMBER_KINDS: Array<"u" | "s"> = ["u", "s"]; async function spaceListMembers( params: SpaceListMembersParams, @@ -18,7 +18,9 @@ async function spaceListMembers( assertSpaceRpcContext(context); const ctx = context as SpaceRpcContext; - const kinds = params.kind ? [params.kind] : MEMBER_KINDS; + const kind = params.kind; + const kinds: Array<"u" | "s"> = + kind === "u" ? ["u"] : kind === "s" ? ["s"] : MEMBER_KINDS; const members = ( await Promise.all( kinds.map((kind) => ctx.core.listSpacePrincipals(ctx.space.id, kind)), diff --git a/packages/server/rpc/memory/support.ts b/packages/server/rpc/memory/support.ts index 5f6664c0..6155be7b 100644 --- a/packages/server/rpc/memory/support.ts +++ b/packages/server/rpc/memory/support.ts @@ -42,12 +42,12 @@ export { guardCore }; // ============================================================================= /** - * The caller's `~` home expansion: `home.` for a user, or - * `home..` for an agent (nested under its owner's home). + * The caller's `~` home expansion: `home.` for a user. Service accounts + * do not have a home. */ function homeOpts(ctx: SpaceRpcContext): TreePathOptions { if (ctx.principalKind === "s") return {}; - return { home: ctx.principalId, homeOwner: ctx.ownerId ?? undefined }; + return { home: ctx.principalId }; } /** @@ -85,14 +85,14 @@ export function displayTreePath(ctx: SpaceRpcContext, stored: string): string { } /** - * The caller's own-home ltree prefix (`home.` for a user, `home..` - * for an agent), or `null` when the caller has no `~` home (service accounts). + * The caller's own-home ltree prefix (`home.`), or `null` when the caller + * has no `~` home (service accounts). * This is the prefix that `displayTreePath` reverse-maps to `~`. */ export function callerHomePrefix(ctx: SpaceRpcContext): string | null { const opts = homeOpts(ctx); if (opts.home === undefined) return null; - return homePrefix(opts.home, opts.homeOwner); + return homePrefix(opts.home); } function asValidationError(e: unknown): AppError { @@ -188,38 +188,6 @@ export async function requireTreeOwner( } } -/** - * True if `principalId` is an agent in this space owned by the caller. Agents - * are user-owned and capped by their owner's access (agent_tree_access), so a - * member managing their own agents (create/keys/self-grants) is self-service - * and safe — it can't escalate beyond the caller's own access. - */ -export async function callerOwnsAgent( - context: SpaceRpcContext, - principalId: string, -): Promise { - const agents = await context.core.listSpacePrincipals(context.space.id, "a"); - const agent = agents.find((a) => a.id === principalId); - return agent !== undefined && agent.ownerId === context.principalId; -} - -/** - * True if `principalId` is an agent owned by the caller, checked globally (not - * scoped to the current space). Used by principal.add so a member can bring - * their OWN agent into a space before it is a member there. - */ -export async function callerOwnsAgentGlobal( - context: SpaceRpcContext, - principalId: string, -): Promise { - const principal = await context.core.getPrincipal(principalId); - return ( - principal !== null && - principal.kind === "a" && - principal.ownerId === context.principalId - ); -} - /** True if the caller is a direct user member of an SA's bound admin group. */ export async function callerAdministersServiceAccount( context: SpaceRpcContext, @@ -240,7 +208,6 @@ export function toSpacePrincipalResponse( id: m.id, kind: m.kind, name: m.name, - ownerId: m.ownerId, admin: m.admin, createdAt: m.createdAt.toISOString(), updatedAt: m.updatedAt?.toISOString() ?? null, diff --git a/packages/server/rpc/memory/types.ts b/packages/server/rpc/memory/types.ts index 6a6c78a1..f0bac45d 100644 --- a/packages/server/rpc/memory/types.ts +++ b/packages/server/rpc/memory/types.ts @@ -18,27 +18,16 @@ export interface SpaceRpcContext extends HandlerContext { core: CoreStore; /** The resolved space. */ space: Space; - /** Authenticated principal id (user id for sessions, agent id for api keys). */ + /** Authenticated principal id. */ principalId: string; - /** Authenticated principal kind after any act-as-agent switch. */ - principalKind: "u" | "a" | "s"; - /** - * The principal's owner — non-null when it is an agent, null for a user. Drives - * `~` home nesting (an agent's home lives under its owner's home). - */ - ownerId: string | null; + /** Authenticated principal kind. */ + principalKind: "u" | "s"; /** Api key id when authenticated by api key; null for sessions. */ apiKeyId: string | null; /** The principal's effective grants in this space. May be empty. */ treeAccess: TreeAccess; /** Whether the principal is a space admin (principal_space.admin). */ admin: boolean; - /** - * When a human is acting as one of their own agents (via `X-Me-As-Agent`), - * the human's principal id; null otherwise. Observability only — authorization - * reads the (already switched) `principalId` / `ownerId` / `treeAccess` / `admin`. - */ - authenticatedAs: string | null; /** Embedding config for semantic search (optional). */ embeddingConfig?: EmbeddingConfig; } @@ -60,9 +49,7 @@ export function isSpaceRpcContext(ctx: HandlerContext): ctx is SpaceRpcContext { "principalId" in ctx && typeof ctx.principalId === "string" && "principalKind" in ctx && - (ctx.principalKind === "u" || - ctx.principalKind === "a" || - ctx.principalKind === "s") && + (ctx.principalKind === "u" || ctx.principalKind === "s") && "treeAccess" in ctx && Array.isArray(ctx.treeAccess) ); diff --git a/packages/server/rpc/types.ts b/packages/server/rpc/types.ts index d2fac056..ab820e38 100644 --- a/packages/server/rpc/types.ts +++ b/packages/server/rpc/types.ts @@ -49,7 +49,7 @@ export interface RegisteredMethod { * Optional per-method authorization, run by the dispatcher BEFORE param * validation. Throw an AppError to deny (e.g. FORBIDDEN). Gates a method on * the caller's identity without parsing input it can't use — see the - * user-RPC agent allow-list (`gateAgentAccess`). + * user-RPC non-user allow-list (`gateNonUserAccess`). */ authorize?: (context: HandlerContext) => void; } diff --git a/packages/server/rpc/user/agent.integration.test.ts b/packages/server/rpc/user/agent.integration.test.ts deleted file mode 100644 index 10163a9a..00000000 --- a/packages/server/rpc/user/agent.integration.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -// Integration test for the user RPC agent handlers (agent.* lifecycle). -// User-scoped (no space): a user manages their own agents. -// TEST_DATABASE_URL="postgresql://postgres@127.0.0.1:5432/postgres" \ -// bun test --timeout 30000 \ -// packages/server/rpc/user/agent.integration.test.ts -import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; -import { bootstrapSpaceDatabase, migrateCore } from "@memory.build/database"; -import { ACCESS, coreStore, ROOT_PATH } from "@memory.build/engine/core"; -import { type AppErrorCode, isAppError } from "@memory.build/protocol/errors"; -import postgres, { type Sql } from "postgres"; -import { handleRpcRequest } from "../handler"; -import type { HandlerContext } from "../types"; -import { userMethods } from "./index"; - -const URL = - process.env.TEST_DATABASE_URL ?? - "postgresql://postgres@127.0.0.1:5432/postgres"; - -const rand = (n: number) => { - const a = "abcdefghijklmnopqrstuvwxyz0123456789"; - const bytes = crypto.getRandomValues(new Uint8Array(n)); - let s = ""; - for (const b of bytes) s += a[b % 36]; - return s; -}; - -let sql: Sql; -let coreSchema: string; -let userId: string; -const createdSpaceSchemas: string[] = []; - -async function call( - method: string, - params: unknown, - asUser: string = userId, - identity?: { - email?: string; - name?: string; - kind?: "u" | "a"; - apiKeyId?: string; - apiKeyRestricted?: boolean; - }, -): Promise { - const registered = userMethods.get(method); - if (!registered) throw new Error(`no handler for ${method}`); - const kind = identity?.kind ?? "u"; - const context = { - request: new Request("http://localhost/api/v1/user/rpc"), - core: coreStore(sql, coreSchema), - // Identity the middleware (authenticateUser) would have put on the context - // from the validated credential; whoami echoes it. An agent carries no - // email (kind "a"); the account-management methods reject it. - kind, - userId: asUser, - email: kind === "a" ? null : (identity?.email ?? `${asUser}@example.com`), - name: identity?.name ?? "Test User", - db: sql, - coreSchema, - apiKeyId: identity?.apiKeyId ?? null, - apiKeyRestricted: identity?.apiKeyRestricted ?? false, - } as unknown as HandlerContext; - // Mirror the dispatcher: per-method authorization (the agent allow-list gate) - // runs before the handler. async, so a denial surfaces as a rejected promise. - registered.authorize?.(context); - return registered.handler(params, context) as Promise; -} - -async function expectAppError(p: Promise, code: AppErrorCode) { - try { - await p; - throw new Error(`expected AppError(${code}), but it resolved`); - } catch (e) { - if (!isAppError(e)) throw e; - expect(e.code).toBe(code); - } -} - -async function makeUser(): Promise { - const [row] = await sql`select uuidv7() as id`; - const id = row?.id as string; - await coreStore(sql, coreSchema).createUser(id, `u_${rand(8)}@example.com`); - return id; -} - -beforeAll(async () => { - sql = postgres(URL, { onnotice: () => {} }); - coreSchema = `core_test_${rand(8)}`; - await bootstrapSpaceDatabase(sql); // extensions for me_ (space.create) - await migrateCore(sql, { schema: coreSchema }); -}); - -afterAll(async () => { - for (const s of createdSpaceSchemas) { - await sql.unsafe(`drop schema if exists ${s} cascade`); - } - await sql.unsafe(`drop schema if exists ${coreSchema} cascade`); - await sql.end(); -}); - -beforeEach(async () => { - userId = await makeUser(); -}); - -test("whoami echoes the validated session identity", async () => { - // Under the new model whoami trusts the identity the middleware resolved from - // the validated session/token (ctx.email/ctx.name) — no store lookup. Token - // validity (e.g. a deleted user → 401) is the middleware's job, covered in the - // authenticate-space/user integration tests. - const me = await call<{ - id: string; - kind: string; - email: string | null; - name: string; - }>("whoami", {}, userId, { email: "who@example.com", name: "Who Am I" }); - expect(me).toEqual({ - id: userId, - kind: "u", - email: "who@example.com", - name: "Who Am I", - }); -}); - -// An agent acting with ME_API_KEY reaches the user RPC; the door admits it and -// the handlers authorize per-method. The account-scoped *reads* work; every -// account-*management* method is user-only (requireUserCaller). -test("an agent caller can whoami (kind 'a', null email)", async () => { - const agentId = await coreStore(sql, coreSchema).createAgent(userId, "bot"); - const me = await call<{ - id: string; - kind: string; - email: string | null; - name: string; - }>("whoami", {}, agentId, { kind: "a", name: "bot" }); - expect(me).toEqual({ id: agentId, kind: "a", email: null, name: "bot" }); -}); - -test("an agent caller can list its own spaces (space.list)", async () => { - const core = coreStore(sql, coreSchema); - const agentId = await core.createAgent(userId, "bot"); - const spaceId = await core.createSpace(rand(12), "Agent Space"); - await core.addPrincipalToSpace(spaceId, agentId); - - const res = await call<{ spaces: { id: string }[] }>( - "space.list", - {}, - agentId, - { kind: "a", name: "bot" }, - ); - expect(res.spaces.some((s) => s.id === spaceId)).toBe(true); -}); - -test("an agent caller is denied every account-management method (FORBIDDEN)", async () => { - const agentId = await coreStore(sql, coreSchema).createAgent(userId, "bot"); - const asAgent = { kind: "a" as const, name: "bot" }; - // The gate's authorize hook (mirrored in `call`) rejects an agent before the - // handler — and, in production, before param validation — so the param shapes - // below are placeholders and the denial is FORBIDDEN regardless of validity. - const denied: [string, unknown][] = [ - ["agent.create", { name: "x" }], - ["agent.list", {}], - ["agent.spaces", { id: agentId }], - ["agent.rename", { id: agentId, name: "x" }], - ["agent.delete", { id: agentId }], - ["serviceAccount.create", { spaceId: agentId, name: "x" }], - ["serviceAccount.list", { spaceId: agentId }], - ["serviceAccount.rename", { id: agentId, name: "x" }], - ["serviceAccount.delete", { id: agentId }], - ["apiKey.create", { memberId: agentId, name: "x" }], - ["apiKey.list", { memberId: agentId }], - ["apiKey.get", { id: agentId }], - ["apiKey.delete", { id: agentId }], - ["space.create", { name: "x" }], - ["space.rename", { slug: "a".repeat(12), name: "x" }], - ["space.delete", { slug: "a".repeat(12) }], - ]; - for (const [method, params] of denied) { - await expectAppError(call(method, params, agentId, asAgent), "FORBIDDEN"); - } -}); - -test("a restricted user PAT is denied account-management methods", async () => { - const restricted = { apiKeyId: userId, apiKeyRestricted: true }; - await call("whoami", {}, userId, restricted); - await call("space.list", {}, userId, restricted); - - for (const [method, params] of [ - ["agent.create", { name: "bot" }], - ["serviceAccount.list", { spaceId: userId }], - ["apiKey.list", { memberId: userId }], - ["space.create", { name: "blocked" }], - ] as const) { - await expectAppError(call(method, params, userId, restricted), "FORBIDDEN"); - } -}); - -test("through the dispatcher, a gated method denies an agent BEFORE param validation", async () => { - // The denial is an `authorize` hook that runs before schema validation, so an - // agent gets the user-only FORBIDDEN even when its params are invalid (rather - // than an INVALID_PARAMS that would leak the param schema). Drive the real - // dispatcher (which validates params) with a deliberately invalid body. - const agentId = await coreStore(sql, coreSchema).createAgent(userId, "bot"); - const request = new Request("http://localhost/api/v1/user/rpc", { - method: "POST", - body: JSON.stringify({ - jsonrpc: "2.0", - method: "agent.create", - params: { not: "the right shape" }, // invalid for agent.create - id: 1, - }), - }); - const response = await handleRpcRequest(request, userMethods, { - core: coreStore(sql, coreSchema), - kind: "a", - userId: agentId, - email: null, - name: "bot", - db: sql, - coreSchema, - } as unknown as HandlerContext); - - const body = JSON.stringify(await response.json()); - // The user-only message proves authorize ran first; a schema-first ordering - // would instead surface the zod "params" validation error. - expect(body).toContain("user-only"); -}); - -test("create / list / rename / delete the caller's agents", async () => { - const { id } = await call<{ id: string }>("agent.create", { name: "bot" }); - - let agents = await call<{ agents: { id: string; name: string }[] }>( - "agent.list", - {}, - ); - expect(agents.agents).toHaveLength(1); - expect(agents.agents[0]?.id).toBe(id); - expect(agents.agents[0]?.name).toBe("bot"); - - expect( - (await call<{ renamed: boolean }>("agent.rename", { id, name: "bot2" })) - .renamed, - ).toBe(true); - agents = await call("agent.list", {}); - expect(agents.agents[0]?.name).toBe("bot2"); - - expect( - (await call<{ deleted: boolean }>("agent.delete", { id })).deleted, - ).toBe(true); - expect( - (await call<{ agents: unknown[] }>("agent.list", {})).agents, - ).toHaveLength(0); -}); - -test("agent.list is scoped to the caller", async () => { - await call("agent.create", { name: "mine" }); - const other = await makeUser(); - const otherList = await call<{ agents: unknown[] }>("agent.list", {}, other); - expect(otherList.agents).toHaveLength(0); -}); - -test("agent.spaces lists spaces for an owned agent", async () => { - const { id: agentId } = await call<{ id: string }>("agent.create", { - name: "bot", - }); - const core = coreStore(sql, coreSchema); - const agentSpaceId = await core.createSpace(rand(12), "Agent Space"); - const userSpaceId = await core.createSpace(rand(12), "User Space"); - await core.addPrincipalToSpace(agentSpaceId, agentId); - await core.addPrincipalToSpace(userSpaceId, userId, true); - - const res = await call<{ - spaces: { id: string; name: string; admin: boolean }[]; - }>("agent.spaces", { id: agentId }); - - const agentSpace = res.spaces.find((s) => s.id === agentSpaceId); - expect(agentSpace).toBeDefined(); - expect(agentSpace?.name).toBe("Agent Space"); - expect(agentSpace?.admin).toBe(false); - expect(res.spaces.some((s) => s.id === userSpaceId)).toBe(false); -}); - -test("agent.spaces requires owning the agent", async () => { - const { id } = await call<{ id: string }>("agent.create", { name: "mine" }); - const intruder = await makeUser(); - await expectAppError(call("agent.spaces", { id }, intruder), "FORBIDDEN"); -}); - -test("agent.spaces rejects non-agent and missing principals", async () => { - await expectAppError(call("agent.spaces", { id: userId }), "NOT_FOUND"); - - const [row] = await sql`select uuidv7() as id`; - await expectAppError( - call("agent.spaces", { id: row?.id as string }), - "NOT_FOUND", - ); -}); - -test("cannot rename/delete another user's agent", async () => { - const { id } = await call<{ id: string }>("agent.create", { name: "mine" }); - const intruder = await makeUser(); - await expectAppError( - call("agent.rename", { id, name: "hijacked" }, intruder), - "FORBIDDEN", - ); - await expectAppError(call("agent.delete", { id }, intruder), "FORBIDDEN"); -}); - -test("rename/delete of a non-existent agent → NOT_FOUND", async () => { - const [row] = await sql`select uuidv7() as id`; - const ghost = row?.id as string; - await expectAppError( - call("agent.rename", { id: ghost, name: "x" }), - "NOT_FOUND", - ); -}); - -test("space.list returns the spaces the user belongs to (with admin flag)", async () => { - const core = coreStore(sql, coreSchema); - const spaceId = await core.createSpace(rand(12), "My Space"); - await core.addPrincipalToSpace(spaceId, userId, true); - - const res = await call<{ - spaces: { id: string; name: string; admin: boolean }[]; - }>("space.list", {}); - const mine = res.spaces.find((s) => s.id === spaceId); - expect(mine).toBeDefined(); - expect(mine?.name).toBe("My Space"); - expect(mine?.admin).toBe(true); - - // a brand-new user with no memberships sees no spaces - const other = await makeUser(); - const otherList = await call<{ spaces: unknown[] }>("space.list", {}, other); - expect(otherList.spaces).toHaveLength(0); -}); - -test("space.list excludes spaces reached only via group membership", async () => { - const core = coreStore(sql, coreSchema); - const spaceId = await core.createSpace(rand(12), "Group Space"); - const groupId = await core.createGroup(spaceId, "team"); - // the user is NOT added to principal_space — only to a group in the space - await core.addGroupMember(spaceId, groupId, userId); - - const res = await call<{ spaces: { id: string; admin: boolean }[] }>( - "space.list", - {}, - ); - // group membership alone does not make the user a space member, so the space - // is not listed - expect(res.spaces.find((s) => s.id === spaceId)).toBeUndefined(); -}); - -test("space.create provisions a space the caller owns + admins", async () => { - const res = await call<{ id: string; slug: string }>("space.create", { - name: "Fresh Space", - }); - createdSpaceSchemas.push(`me_${res.slug}`); - expect(res.slug).toMatch(/^[a-z0-9]{12}$/); - - // the me_ data schema was provisioned - const [row] = await sql.unsafe( - `select exists (select 1 from information_schema.schemata where schema_name = $1) as e`, - [`me_${res.slug}`], - ); - expect(Boolean(row?.e)).toBe(true); - - // it shows up in the caller's spaces, as admin - const list = await call<{ spaces: { id: string; admin: boolean }[] }>( - "space.list", - {}, - ); - expect(list.spaces.find((s) => s.id === res.id)?.admin).toBe(true); - - // the creator owns the shared root (and its home), not owner@root - const ta = await coreStore(sql, coreSchema).buildTreeAccess(userId, res.id); - expect(ta).toContainEqual({ tree_path: "share", access: ACCESS.owner }); - expect(ta).not.toContainEqual({ tree_path: ROOT_PATH, access: ACCESS.owner }); -}); - -test("space.rename renames; space.delete removes the space + schema", async () => { - const created = await call<{ id: string; slug: string }>("space.create", { - name: "Temp Space", - }); - const schema = `me_${created.slug}`; - createdSpaceSchemas.push(schema); - - // rename - expect( - ( - await call<{ renamed: boolean }>("space.rename", { - slug: created.slug, - name: "Renamed Space", - }) - ).renamed, - ).toBe(true); - const after = await call<{ spaces: { id: string; name: string }[] }>( - "space.list", - {}, - ); - expect(after.spaces.find((s) => s.id === created.id)?.name).toBe( - "Renamed Space", - ); - - // delete: core row gone + data schema dropped - expect( - (await call<{ deleted: boolean }>("space.delete", { slug: created.slug })) - .deleted, - ).toBe(true); - const gone = await call<{ spaces: { id: string }[] }>("space.list", {}); - expect(gone.spaces.some((s) => s.id === created.id)).toBe(false); - const [row] = await sql.unsafe( - `select exists (select 1 from information_schema.schemata where schema_name = $1) as e`, - [schema], - ); - expect(Boolean(row?.e)).toBe(false); -}); - -test("space.rename/delete require space admin", async () => { - const created = await call<{ id: string; slug: string }>("space.create", { - name: "Owned Space", - }); - createdSpaceSchemas.push(`me_${created.slug}`); - // a different user who is not a member/admin - const intruder = await makeUser(); - await expectAppError( - call("space.rename", { slug: created.slug, name: "Hijacked" }, intruder), - "FORBIDDEN", - ); - await expectAppError( - call("space.delete", { slug: created.slug }, intruder), - "FORBIDDEN", - ); -}); - -test("space.list lists only direct memberships; admin reflects an admin group", async () => { - const core = coreStore(sql, coreSchema); - const spaceId = await core.createSpace(rand(12), "Admin Group Space"); - const groupId = await core.createGroup(spaceId, "admins"); - // designate the group itself as an admin member of the space - await core.addPrincipalToSpace(spaceId, groupId, true); - // the user is only in that group (no direct principal_space row), so group - // membership alone does not make them a space member - await core.addGroupMember(spaceId, groupId, userId); - - let res = await call<{ spaces: { id: string; admin: boolean }[] }>( - "space.list", - {}, - ); - expect(res.spaces.find((s) => s.id === spaceId)).toBeUndefined(); - - // once a direct member, the space is listed and admin is inherited via the group - await core.addPrincipalToSpace(spaceId, userId); // direct, non-admin - res = await call<{ spaces: { id: string; admin: boolean }[] }>( - "space.list", - {}, - ); - const mine = res.spaces.find((s) => s.id === spaceId); - expect(mine).toBeDefined(); - expect(mine?.admin).toBe(true); // admin via the admin group, now that they're a member -}); diff --git a/packages/server/rpc/user/agent.ts b/packages/server/rpc/user/agent.ts deleted file mode 100644 index 45756deb..00000000 --- a/packages/server/rpc/user/agent.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Agent handlers (agent.*) for the user RPC. - * - * Agents are user-owned non-human principals. The lifecycle here is purely - * user-scoped: create / list / rename / delete the caller's own agents, and - * mint their (global) api keys (apiKey.* — see ./api-key.ts). Bringing an agent - * into a space (principal.add) is a space-endpoint operation. - */ -import type { Principal } from "@memory.build/engine/core"; -import type { - AgentCreateParams, - AgentCreateResult, - AgentDeleteParams, - AgentDeleteResult, - AgentListParams, - AgentListResult, - AgentRenameParams, - AgentRenameResult, - AgentResponse, - AgentSpacesParams, - AgentSpacesResult, -} from "@memory.build/protocol/user"; -import { - agentCreateParams, - agentDeleteParams, - agentListParams, - agentRenameParams, - agentSpacesParams, -} from "@memory.build/protocol/user"; -import { guardCore } from "../core-error"; -import { AppError } from "../errors"; -import { buildRegistry } from "../registry"; -import type { HandlerContext } from "../types"; -import { toMemberSpaceResponse } from "./space"; -import { assertUserRpcContext, type UserRpcContext } from "./types"; - -function toAgentResponse(p: Principal): AgentResponse { - return { - id: p.id, - name: p.name, - createdAt: p.createdAt.toISOString(), - updatedAt: p.updatedAt?.toISOString() ?? null, - }; -} - -/** Assert the caller owns this agent (globally). */ -export async function requireOwnAgent( - ctx: UserRpcContext, - agentId: string, -): Promise { - const principal = await ctx.core.getPrincipal(agentId); - if (!principal || principal.kind !== "a") { - throw new AppError("NOT_FOUND", `Agent not found: ${agentId}`); - } - if (principal.ownerId !== ctx.userId) { - throw new AppError("FORBIDDEN", "Not the owner of this agent"); - } -} - -/** - * Assert the caller may manage api keys for this member — either their own user - * principal (a personal access token) or an agent they own. - */ -export async function requireOwnMember( - ctx: UserRpcContext, - memberId: string, -): Promise { - if (memberId === ctx.userId) return; // the caller's own user principal (PAT) - await requireOwnAgent(ctx, memberId); // else must be an agent they own -} - -async function agentCreate( - params: AgentCreateParams, - context: HandlerContext, -): Promise { - assertUserRpcContext(context); - const ctx = context as UserRpcContext; - const id = await guardCore(() => - ctx.core.createAgent(ctx.userId, params.name), - ); - return { id }; -} - -async function agentList( - _params: AgentListParams, - context: HandlerContext, -): Promise { - assertUserRpcContext(context); - const ctx = context as UserRpcContext; - const agents = await ctx.core.listAgents(ctx.userId); - return { agents: agents.map(toAgentResponse) }; -} - -async function agentSpaces( - params: AgentSpacesParams, - context: HandlerContext, -): Promise { - assertUserRpcContext(context); - const ctx = context as UserRpcContext; - await requireOwnAgent(ctx, params.id); - const spaces = await ctx.core.listSpacesForMember(params.id); - return { spaces: spaces.map(toMemberSpaceResponse) }; -} - -async function agentRename( - params: AgentRenameParams, - context: HandlerContext, -): Promise { - assertUserRpcContext(context); - const ctx = context as UserRpcContext; - await requireOwnAgent(ctx, params.id); - const renamed = await guardCore(() => - ctx.core.renamePrincipal(params.id, params.name), - ); - return { renamed }; -} - -async function agentDelete( - params: AgentDeleteParams, - context: HandlerContext, -): Promise { - assertUserRpcContext(context); - const ctx = context as UserRpcContext; - await requireOwnAgent(ctx, params.id); - const deleted = await guardCore(() => ctx.core.deletePrincipal(params.id)); - return { deleted }; -} - -export const agentMethods = buildRegistry() - .register("agent.create", agentCreateParams, agentCreate) - .register("agent.list", agentListParams, agentList) - .register("agent.spaces", agentSpacesParams, agentSpaces) - .register("agent.rename", agentRenameParams, agentRename) - .register("agent.delete", agentDeleteParams, agentDelete) - .build(); diff --git a/packages/server/rpc/user/api-key.integration.test.ts b/packages/server/rpc/user/api-key.integration.test.ts index cad2b1de..f56c865a 100644 --- a/packages/server/rpc/user/api-key.integration.test.ts +++ b/packages/server/rpc/user/api-key.integration.test.ts @@ -1,7 +1,7 @@ // Integration test for the user RPC api-key handlers (apiKey.* lifecycle). -// A key is minted for a member the caller owns — an agent, or the caller's OWN -// user principal (a personal access token). Keys are global (no space slug) and -// minting needs only ownership, not space membership. Minting/revoking is +// A key is minted for the caller's own user principal (a personal access token) +// or a service account they administer. Keys are global (no space slug) and +// minting/revoking is // session-only: a key-authenticated caller (viaApiKey) can't manage keys. // TEST_DATABASE_URL="postgresql://postgres@127.0.0.1:5432/postgres" \ // bun test --timeout 30000 \ @@ -41,8 +41,7 @@ function call( const context = { request: new Request("http://localhost/api/v1/user/rpc"), core: coreStore(sql, coreSchema), - // These tests exercise the user-PAT carve-out (a key-authenticated *user*); - // the agent-caller denial is covered in agent.integration.test.ts. + // These tests exercise the user-PAT carve-out (a key-authenticated user). kind: "u", userId: asUser, db: sql, @@ -86,14 +85,8 @@ beforeEach(async () => { }); test("create (global, no space needed) / list / get / delete", async () => { - // The agent is owned by the caller but is NOT a member of any space — key - // creation depends only on ownership, not space membership. - const { id: agentId } = await call<{ id: string }>("agent.create", { - name: "bot", - }); - const created = await call<{ id: string; key: string }>("apiKey.create", { - memberId: agentId, + memberId: userId, name: "ci", expiresAt: null, }); @@ -103,7 +96,7 @@ test("create (global, no space needed) / list / get / delete", async () => { const list = await call<{ apiKeys: { id: string; lastUsedOn: string | null }[]; }>("apiKey.list", { - memberId: agentId, + memberId: userId, }); expect(list.apiKeys.map((k) => k.id)).toContain(created.id); expect(list.apiKeys.find((k) => k.id === created.id)?.lastUsedOn).toBeNull(); @@ -248,25 +241,6 @@ test("a key-authenticated caller can't mint or revoke keys (keys can't manage ke expect(got.apiKey?.id).toBe(created.id); }); -test("cannot manage keys for another user's agent", async () => { - const { id: agentId } = await call<{ id: string }>("agent.create", { - name: "mine", - }); - const intruder = await makeUser(); - await expectAppError( - call( - "apiKey.create", - { memberId: agentId, name: "x", expiresAt: null }, - intruder, - ), - "FORBIDDEN", - ); - await expectAppError( - call("apiKey.list", { memberId: agentId }, intruder), - "FORBIDDEN", - ); -}); - test("managing keys for another user is FORBIDDEN, not NOT_FOUND", async () => { // The target principal exists (another user), but the caller may not manage // its keys — the error must be FORBIDDEN, not a misleading NOT_FOUND. diff --git a/packages/server/rpc/user/api-key.ts b/packages/server/rpc/user/api-key.ts index a6024899..14825643 100644 --- a/packages/server/rpc/user/api-key.ts +++ b/packages/server/rpc/user/api-key.ts @@ -1,11 +1,11 @@ /** * Api key handlers (apiKey.*) for the user RPC. * - * The caller manages keys for a member they may administer — an agent they own, - * a service account they administer (a direct user member of its bound admin - * group, or a space admin), or their OWN user principal (a personal access - * token). Keys are global per-principal (not space-bound). The plaintext key is - * returned once by create. Revoke ≡ delete. Minting/revoking is session-only + * The caller manages keys for a service account they administer (a direct user + * member of its bound admin group, or a space admin), or their own user + * principal (a personal access token). Keys are global per-principal (not + * space-bound). The plaintext key is returned once by create. Revoke ≡ delete. + * Minting/revoking is session-only * (`denyApiKeyCaller`): a key can't manage keys. */ import { normalizeTreePath, TreePathError } from "@memory.build/database"; @@ -38,7 +38,6 @@ import { guardCore } from "../core-error"; import { AppError } from "../errors"; import { buildRegistry } from "../registry"; import type { HandlerContext } from "../types"; -import { requireOwnAgent } from "./agent"; import { requireServiceAccountManager } from "./service-account"; import { assertUserRpcContext, type UserRpcContext } from "./types"; @@ -67,10 +66,6 @@ async function requireKeyAuthority( } if (memberId === ctx.userId) return principal; - if (principal.kind === "a") { - await requireOwnAgent(ctx, memberId); - return principal; - } if (principal.kind === "s") { await requireServiceAccountManager(ctx, memberId); return principal; diff --git a/packages/server/rpc/user/index.ts b/packages/server/rpc/user/index.ts index 7001f920..2f7b5af0 100644 --- a/packages/server/rpc/user/index.ts +++ b/packages/server/rpc/user/index.ts @@ -1,10 +1,9 @@ /** * User RPC method registry — served at `/api/v1/user/rpc` (account-scoped): * identity (`whoami`), space discovery (`space.list`), and the user's - * account-management surface (agent lifecycle, api keys, space lifecycle). + * account-management surface (api keys, service accounts, space lifecycle). */ import type { MethodRegistry } from "../types"; -import { agentMethods } from "./agent"; import { apiKeyMethods } from "./api-key"; import { inviteeMethods } from "./invitation"; import { serviceAccountMethods } from "./service-account"; @@ -23,32 +22,30 @@ export { } from "./types"; /** - * Methods any authenticated principal may call — including an agent acting with - * `ME_API_KEY`. These are account-scoped *reads* that manage nothing. + * Methods any authenticated principal may call. These are account-scoped reads + * that manage nothing. * - * This is an ALLOW-LIST: {@link gateAgentAccess} denies a non-user (agent) + * This is an ALLOW-LIST: {@link gateNonUserAccess} denies a service-account * caller on every method NOT listed here. So the safe default for a newly-added - * user-RPC method is "user-only" — forgetting to list it denies agents rather - * than exposing the account. Authentication (authenticateUser) admits any + * user-RPC method is "user-only" — forgetting to list it denies service accounts + * rather than exposing the account. Authentication (authenticateUser) admits any * principal; this is the per-method authorization layered on top. */ -const AGENT_ALLOWED: ReadonlySet = new Set(["whoami", "space.list"]); +const NON_USER_ALLOWED: ReadonlySet = new Set(["whoami", "space.list"]); /** - * Gate a user-RPC registry so every method outside {@link AGENT_ALLOWED} rejects - * a non-user (agent) caller. Account management (agents, api keys, space - * lifecycle) is user-only: an agent is owned by a user, owns no - * agents/spaces/keys, and is never an admin. + * Gate a user-RPC registry so every method outside {@link NON_USER_ALLOWED} + * rejects a service-account caller. Account management is user-only. * * The denial is an `authorize` hook (run by the dispatcher BEFORE param - * validation), not a handler wrapper — so an agent always gets the same + * validation), not a handler wrapper — so a service account always gets the same * `FORBIDDEN` regardless of whether its params happen to be valid, and its * input is never parsed for a call it may not make. */ -function gateAgentAccess(registry: MethodRegistry): MethodRegistry { +function gateNonUserAccess(registry: MethodRegistry): MethodRegistry { const gated: MethodRegistry = new Map(); for (const [method, registered] of registry) { - if (AGENT_ALLOWED.has(method)) { + if (NON_USER_ALLOWED.has(method)) { gated.set(method, registered); continue; } @@ -66,12 +63,11 @@ function gateAgentAccess(registry: MethodRegistry): MethodRegistry { /** * The user-endpoint registry: identity + space discovery (open to any - * principal) + agent/api-key/space management (user-only, gated above). + * principal) + account management (user-only, gated above). */ -export const userMethods: MethodRegistry = gateAgentAccess( +export const userMethods: MethodRegistry = gateNonUserAccess( new Map([ ...whoamiMethods, - ...agentMethods, ...serviceAccountMethods, ...apiKeyMethods, ...spaceMethods, diff --git a/packages/server/rpc/user/invitation.integration.test.ts b/packages/server/rpc/user/invitation.integration.test.ts index c7c5e2fa..3a97f9cc 100644 --- a/packages/server/rpc/user/invitation.integration.test.ts +++ b/packages/server/rpc/user/invitation.integration.test.ts @@ -42,7 +42,7 @@ async function call( opts: { asUser?: string; email?: string | null; - kind?: "u" | "a"; + kind?: "u" | "s"; emailVerified?: boolean; } = {}, ): Promise { @@ -55,9 +55,9 @@ async function call( core: coreStore(sql, coreSchema), kind, userId: asUser, - email: kind === "a" ? null : (opts.email ?? userEmail), + email: kind === "s" ? null : (opts.email ?? userEmail), name: "Test User", - emailVerified: kind === "a" ? false : (opts.emailVerified ?? true), + emailVerified: kind === "s" ? false : (opts.emailVerified ?? true), db: sql, coreSchema, } as unknown as HandlerContext; @@ -199,8 +199,8 @@ test("invitee methods require a verified email", async () => { ); }); -test("an agent caller is denied the invitee methods", async () => { - await expectAppError(call("invite.pending", {}, { kind: "a" }), "FORBIDDEN"); +test("a service-account caller is denied the invitee methods", async () => { + await expectAppError(call("invite.pending", {}, { kind: "s" }), "FORBIDDEN"); }); test("invite.redeem joins via an open magic link (no email match needed)", async () => { diff --git a/packages/server/rpc/user/invitation.ts b/packages/server/rpc/user/invitation.ts index 3e4c8052..16374060 100644 --- a/packages/server/rpc/user/invitation.ts +++ b/packages/server/rpc/user/invitation.ts @@ -7,9 +7,7 @@ * admin side (`invite.create`/`list`/`revoke` for a space) lives on the space RPC. * * Gated on a verified email: invitations are email-keyed, so a caller may only - * act on invitations addressed to their own provider-verified address. Agents - * have no email and are denied by the user-RPC allow-list (these methods are not - * in `AGENT_ALLOWED`). + * act on invitations addressed to their own provider-verified address. */ import type { InviteAcceptParams, @@ -35,7 +33,7 @@ import { assertUserRpcContext, type UserRpcContext } from "./types"; /** * Resolve the caller's verified email, or reject. Invitations are addressed by * email; acting on one requires the caller to control that provider-verified - * address (an unverified or absent email — e.g. an agent — must not). + * address (an unverified or absent email must not). */ function requireVerifiedEmail(ctx: UserRpcContext): string { if (ctx.email === null || !ctx.emailVerified) { @@ -108,7 +106,7 @@ async function inviteDecline( * Redeem a magic-link token. Unlike accept/decline this is NOT email-keyed: an * open link is redeemable by any logged-in user, and an email-constrained link's * email check happens in SQL against the caller's email. So it doesn't require a - * verified email — only a logged-in user (agents are barred by the allow-list). + * verified email — only a logged-in user. * * Only a *verified* email is passed for the constraint check, mirroring `accept`'s * requireVerifiedEmail: an unverified email must not satisfy an email-constrained diff --git a/packages/server/rpc/user/service-account.integration.test.ts b/packages/server/rpc/user/service-account.integration.test.ts index 713409f5..9e1f665e 100644 --- a/packages/server/rpc/user/service-account.integration.test.ts +++ b/packages/server/rpc/user/service-account.integration.test.ts @@ -31,7 +31,7 @@ async function call( method: string, params: unknown, asUser: string = userId, - identity: { kind?: "u" | "a" | "s"; viaApiKey?: boolean } = {}, + identity: { kind?: "u" | "s"; viaApiKey?: boolean } = {}, ): Promise { const registered = userMethods.get(method); if (!registered) throw new Error(`no handler for ${method}`); @@ -47,7 +47,6 @@ async function call( db: sql, coreSchema, viaApiKey: identity.viaApiKey ?? false, - authenticatedAs: null, } as unknown as HandlerContext; registered.authorize?.(context); return registered.handler(params, context) as Promise; @@ -93,6 +92,35 @@ beforeEach(async () => { userId = await makeUser(); }); +test("service-account callers can use whoami and space.list", async () => { + const spaceId = await makeSpace(); + const account = await coreStore(sql, coreSchema).createServiceAccount( + spaceId, + "read-only-bot", + ); + + const whoami = await call<{ + id: string; + kind: string; + email: string | null; + name: string; + }>("whoami", {}, account.id, { kind: "s", viaApiKey: true }); + expect(whoami).toEqual({ + id: account.id, + kind: "s", + email: null, + name: "machine", + }); + + const listed = await call<{ spaces: { id: string }[] }>( + "space.list", + {}, + account.id, + { kind: "s", viaApiKey: true }, + ); + expect(listed.spaces.map((space) => space.id)).toContain(spaceId); +}); + test("space admin can create, list, rename, and delete service accounts", async () => { const spaceId = await makeSpace(); const created = await call<{ @@ -401,7 +429,6 @@ test("non-user callers are denied before serviceAccount param validation", async db: sql, coreSchema, viaApiKey: true, - authenticatedAs: null, } as unknown as HandlerContext); const body = JSON.stringify(await response.json()); diff --git a/packages/server/rpc/user/types.ts b/packages/server/rpc/user/types.ts index 7cf222e1..404a52c9 100644 --- a/packages/server/rpc/user/types.ts +++ b/packages/server/rpc/user/types.ts @@ -1,8 +1,8 @@ /** * User RPC context — populated by authenticateUser. Account-scoped (no space): * identity (`whoami`) and space discovery (`space.list`) for any authenticated - * principal, plus the account-management surface (agents, api keys, space - * lifecycle) which is user-only — see {@link requireUserCaller}. + * principal, plus the account-management surface (api keys, service accounts, + * and space lifecycle) which is user-only — see {@link requireUserCaller}. */ import type { CoreStore } from "@memory.build/engine/core"; import type { Sql } from "postgres"; @@ -12,9 +12,9 @@ import type { HandlerContext } from "../types"; export interface UserRpcContext extends HandlerContext { /** Core control-plane store. */ core: CoreStore; - /** The authenticated principal's kind: user, agent, or service account. */ - kind: "u" | "a" | "s"; - /** The authenticated principal id (user, agent, or service account). */ + /** The authenticated principal's kind: user or service account. */ + kind: "u" | "s"; + /** The authenticated principal id (user or service account). */ userId: string; /** The caller's email (powers whoami); null for non-users. */ email: string | null; @@ -27,7 +27,7 @@ export interface UserRpcContext extends HandlerContext { /** * The caller's name: a human display name from a session / OAuth token, or * the core principal's name on the api-key path — the user's email for a user - * PAT, or the principal handle for an agent/service account. + * PAT, or the service-account handle. */ name: string; /** New-model pool — for transactional provisioning (space.create). */ @@ -35,8 +35,8 @@ export interface UserRpcContext extends HandlerContext { /** The core control-plane schema name. */ coreSchema: string; /** - * True when the caller authenticated with an api key (a user PAT, agent key, - * or service-account key) rather than a session / OAuth token. Gates the + * True when the caller authenticated with an api key (a user PAT or + * service-account key) rather than a session / OAuth token. Gates the * credential-management ops: a key can't mint or revoke keys (preserves * revocability), so apiKey.create/delete reject a key-authenticated caller. */ @@ -45,12 +45,6 @@ export interface UserRpcContext extends HandlerContext { apiKeyId: string | null; /** Whether the API key carries declarations that restrict its authority. */ apiKeyRestricted: boolean; - /** - * When a human is acting as one of their own agents (via `X-Me-As-Agent`), - * the human's principal id; null otherwise. Observability only — authorization - * reads the (already switched) `kind` / `userId`. - */ - authenticatedAs: string | null; } export function isUserRpcContext(ctx: HandlerContext): ctx is UserRpcContext { @@ -72,11 +66,11 @@ export function assertUserRpcContext( } /** - * Reject a non-user (agent/service account) caller from an account-management method. + * Reject a service-account caller from an account-management method. * * The user RPC admits any authenticated principal so non-user keys can run the * account-scoped *reads* (`whoami`, `space.list`) — but managing the account is - * user-only. The user-RPC gate (`gateAgentAccess` in ./index) calls this for + * user-only. The user-RPC gate (`gateNonUserAccess` in ./index) calls this for * every method outside its allow-list, so the denial is default-on and lives in * one place rather than relying on each handler to incidentally reject a * non-user. diff --git a/packages/server/rpc/user/whoami.ts b/packages/server/rpc/user/whoami.ts index 250168a4..22ec91c4 100644 --- a/packages/server/rpc/user/whoami.ts +++ b/packages/server/rpc/user/whoami.ts @@ -2,9 +2,9 @@ * whoami handler for the user RPC — the identity behind the credential. * * Open to any authenticated principal (it manages nothing): a human reports - * `kind: "u"` with their email; an agent/service account acting with - * `ME_API_KEY` reports `kind: "a"`/`kind: "s"` with a null email, so the CLI can - * show whose context it's in. + * `kind: "u"` with their email; a service account acting with `ME_API_KEY` + * reports `kind: "s"` with a null email, so the CLI can show whose context it is + * in. */ import type { WhoamiParams, WhoamiResult } from "@memory.build/protocol/user"; import { whoamiParams } from "@memory.build/protocol/user"; From b34dae135c987d3e4d87d9d7e66fcf038e69284f Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Tue, 28 Jul 2026 22:00:48 +0000 Subject: [PATCH 2/2] feat(cli): remove agent identity flows --- CLAUDE.md | 42 ++- TNT-244_RELEASE.md | 153 +++++++++ docs/access-control.md | 45 +-- docs/cli/me-access.md | 10 +- docs/cli/me-agent.md | 139 --------- docs/cli/me-apikey.md | 26 +- docs/cli/me-claude.md | 12 +- docs/cli/me-codex.md | 11 +- docs/cli/me-gemini.md | 9 +- docs/cli/me-group.md | 8 +- docs/cli/me-login.md | 2 +- docs/cli/me-logout.md | 2 +- docs/cli/me-mcp.md | 6 +- docs/cli/me-opencode.md | 5 +- docs/cli/me-project.md | 21 +- docs/cli/me-service.md | 6 +- docs/cli/me-space.md | 19 +- docs/cli/me-whoami.md | 4 +- docs/concepts.md | 4 +- docs/mcp-integration.md | 10 +- docs/mcp/agent-instructions.md | 12 +- docs/mcp/me_memory_context.md | 23 +- docs/project-config.md | 68 +--- docs/projects.md | 21 +- docs/typescript-client.md | 26 +- e2e/cli.e2e.test.ts | 259 +++------------ .../claude-plugin/.claude-plugin/plugin.json | 2 +- packages/claude-plugin/README.md | 22 +- packages/cli/agent/default-agent.test.ts | 240 -------------- packages/cli/agent/default-agent.ts | 218 ------------- packages/cli/agent/provision.test.ts | 62 ---- packages/cli/agent/provision.ts | 55 ---- packages/cli/claude/capture.ts | 8 - packages/cli/claude/settings.test.ts | 72 ----- packages/cli/claude/settings.ts | 70 ----- packages/cli/codex/env-hook.test.ts | 18 +- packages/cli/codex/env-hook.ts | 16 +- packages/cli/commands/access.ts | 4 +- packages/cli/commands/agent.ts | 294 ------------------ packages/cli/commands/apikey.ts | 64 +--- packages/cli/commands/capture-prompt.ts | 8 +- packages/cli/commands/claude-env.test.ts | 61 +--- packages/cli/commands/claude.ts | 72 +---- packages/cli/commands/codex-env-hook.test.ts | 28 -- packages/cli/commands/codex.ts | 46 +-- packages/cli/commands/gemini-env-hook.test.ts | 28 -- packages/cli/commands/gemini.ts | 18 +- packages/cli/commands/group.ts | 12 +- packages/cli/commands/import.test.ts | 29 +- packages/cli/commands/import.ts | 14 +- packages/cli/commands/login.ts | 5 +- packages/cli/commands/logout.ts | 3 - packages/cli/commands/mcp.ts | 50 +-- packages/cli/commands/opencode.ts | 38 +-- packages/cli/commands/project.test.ts | 98 +----- packages/cli/commands/project.ts | 223 +------------ packages/cli/commands/serve.ts | 1 - packages/cli/commands/service.ts | 4 +- packages/cli/commands/space.test.ts | 4 +- packages/cli/commands/space.ts | 50 ++- packages/cli/commands/whoami.ts | 4 +- packages/cli/credentials.test.ts | 195 ++---------- packages/cli/credentials.ts | 188 +---------- packages/cli/docs-cli-links.test.ts | 1 - packages/cli/failsafe.test.ts | 267 ---------------- packages/cli/failsafe.ts | 211 ------------- packages/cli/gemini/env-hook.test.ts | 18 +- packages/cli/gemini/env-hook.ts | 17 +- packages/cli/harness-contract.test.ts | 37 +-- packages/cli/harness-contract.ts | 65 +--- packages/cli/harness-detect.test.ts | 95 ------ packages/cli/harness-detect.ts | 60 ---- packages/cli/harness-shape-log.ts | 14 +- packages/cli/harness-smoke/_shared.ts | 21 +- packages/cli/harness-smoke/claude.smoke.ts | 6 +- packages/cli/harness-smoke/gemini.smoke.ts | 2 - packages/cli/harness-smoke/opencode.smoke.ts | 6 +- packages/cli/identity.test.ts | 8 +- packages/cli/identity.ts | 16 +- .../cli/importers/import-transcript.test.ts | 2 - packages/cli/importers/index.test.ts | 2 - packages/cli/importers/index.ts | 6 - packages/cli/index.ts | 62 +--- packages/cli/legacy-flags.test.ts | 52 ++++ packages/cli/mcp/agent-install.ts | 10 +- packages/cli/mcp/server.ts | 21 +- packages/cli/opencode/capture.ts | 14 +- packages/cli/opencode/plugin-template.test.ts | 51 +-- packages/cli/opencode/plugin-template.ts | 28 +- packages/cli/project-config.test.ts | 32 +- packages/cli/project-config.ts | 68 +--- packages/cli/serve/http-server.test.ts | 25 +- packages/cli/serve/http-server.ts | 11 +- packages/cli/session.ts | 8 +- packages/cli/util.test.ts | 59 ---- packages/cli/util.ts | 114 +------ .../core/migrate/migrate.integration.test.ts | 105 +++++-- packages/docs-site/lib/nav.ts | 1 - 98 files changed, 742 insertions(+), 4040 deletions(-) create mode 100644 TNT-244_RELEASE.md delete mode 100644 docs/cli/me-agent.md delete mode 100644 packages/cli/agent/default-agent.test.ts delete mode 100644 packages/cli/agent/default-agent.ts delete mode 100644 packages/cli/agent/provision.test.ts delete mode 100644 packages/cli/agent/provision.ts delete mode 100644 packages/cli/claude/settings.test.ts delete mode 100644 packages/cli/claude/settings.ts delete mode 100644 packages/cli/commands/agent.ts delete mode 100644 packages/cli/failsafe.test.ts delete mode 100644 packages/cli/failsafe.ts delete mode 100644 packages/cli/harness-detect.test.ts delete mode 100644 packages/cli/harness-detect.ts create mode 100644 packages/cli/legacy-flags.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8095d71c..8bb9ab97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,25 +48,24 @@ Read the relevant docs before starting work on a subsystem. - **Schemas** (three, one database): `auth` (better-auth + its oauth-provider plugin: `users`, `sessions`, `accounts`, `verifications`, `jwks`, `oauth_client`, `oauth_access_token`, `oauth_refresh_token`, `oauth_consent`), `core` (control plane: `principal`, `space`, `principal_space`, `group_member`, `tree_access`, `api_key`), and per-space `me_` (data plane: the single `memory` table). `auth.users.id == core.principal.id` for user principals. - **Memory table** (per space): `content`, `name` (text — optional filename-like leaf slug, unique within `(tree, name)` via a partial unique index where name is not null), `meta` (JSONB), `tree` (ltree), `temporal` (tstzrange), `embedding` (halfvec(1536)). Addressed by immutable `id` (`memory.get`/`delete`) or by `folder/name` path (`memory.getByPath`/`deleteByPath`, split at the final `/`); `deleteTree` removes a subtree. Wire/display paths are canonical leading-slash (`/share/auth`, `~/notes`, root `/`); the leading slash is optional on input. ltree storage and the access-grant shorthand below (`owner@home.`) stay dotted because that is the literal ltree representation. - **Search**: hybrid BM25 + semantic via Reciprocal Rank Fusion, computed in SQL functions. -- **Access**: no RLS. `core.build_tree_access(principalId, spaceId)` produces a `_tree_access` jsonb (rows of `tree_path` + `access`) passed into the space SQL functions (`search_memory`, `get_memory`, …). Three additive levels: **1 = read, 2 = write, 3 = owner**; `owner@root` (the empty ltree path) owns the whole space, and an owner grant at any path delegates access-management within that subtree. Two axes: **structural** authority (`principal_space.admin` — roster mutations, groups, invitations) vs **data** authority (owner@path); an admin may also grant data and can self-grant `owner@root`. Granting normally requires owner@path, with one exception: a member may grant/revoke their **own agents** at any path (`callerOwnsAgent` short-circuits `requireGrantAuthority`) — safe because `agent_tree_access` clamps an agent to `least(agent, owner)` at every path, so an over-grant clamps **down** to the owner's level rather than escalating or vanishing. Service accounts have no owner clamp, so granting them access uses the ordinary owner/admin rule; revocation is also allowed for direct user members of the service account's bound admin group. Endpoint admission is direct `principal_space` membership, so a member may authenticate with zero tree grants; data access is still filtered by `build_tree_access`. -- **Tree conventions**: two reserved roots — per-member `home.` (`~` is input sugar for it; a joining **user** is granted `owner@home.`, and a joining **agent** `owner@home..` — nested under its owner's home so the owner's home grant covers it and the `agent_tree_access` clamp keeps it effective) and the shared `share`. Service accounts have no home grant and no defined `~` home; they get zero tree access until explicitly granted access directly or through a group. A space **creator** gets `admin` + `owner@home` + `owner@share`, **not** `owner@root` — so it sees `share` and its own `~` but not other members' homes (as an admin it can self-grant `owner@root`). **Custom spaces** vary these provisioning defaults via `me space create` flags, resolved in `addSpaceCreator`: `--no-home-grants` sets the space column `space.auto_grant_home = false` (read by `add_principal_to_space`, so **every** join path stops seeding `owner@~` for users/agents) and flips the creator to **god mode** (`admin` + `owner@/`); the default group is controlled by whether `provision_default_group` is called (`--no-default-group`), its name (`--default-group `), and whether it's seeded with grants (`--no-default-group-grants`) — the chosen group is flagged `principal.is_default_group` (one per space, partial-unique; rename/delete-robust; surfaced on the space read as `defaultGroup` and used as the `me space invite` default). There is no `auto_grant_team` column — group grants are just `tree_access` rows. `memory.create`/`batchCreate` **require** an explicit `tree` (callers choose `share` vs `~` deliberately); only the file importers (`me import memories`, the `me_memory_import` MCP tool) default a tree-less record to `share` (`SHARE_NAMESPACE`, canonically defined in `@memory.build/protocol` and re-exported by `@memory.build/database`). +- **Access**: no RLS. `core.build_tree_access(principalId, spaceId)` produces a `_tree_access` jsonb (rows of `tree_path` + `access`) passed into the space SQL functions (`search_memory`, `get_memory`, …). Three additive levels: **1 = read, 2 = write, 3 = owner**; `owner@root` (the empty ltree path) owns the whole space, and an owner grant at any path delegates access-management within that subtree. Two axes: **structural** authority (`principal_space.admin` — roster mutations, groups, invitations) vs **data** authority (owner@path); an admin may also grant data and can self-grant `owner@root`. Granting requires owner@path (or space admin). Service accounts have no owner clamp; revocation of a service-account grant is also allowed for direct user members of the service account's bound admin group. Endpoint admission is direct `principal_space` membership, so a member may authenticate with zero tree grants; data access is still filtered by `build_tree_access`. +- **Tree conventions**: two reserved roots — per-member `home.` (`~` is input sugar for it; a joining **user** is granted `owner@home.`) and the shared `share`. Service accounts have no home grant and no defined `~` home; they get zero tree access until explicitly granted access directly or through a group. A space **creator** gets `admin` + `owner@home` + `owner@share`, **not** `owner@root` — so it sees `share` and its own `~` but not other members' homes (as an admin it can self-grant `owner@root`). **Custom spaces** vary these provisioning defaults via `me space create` flags, resolved in `addSpaceCreator`: `--no-home-grants` sets the space column `space.auto_grant_home = false` (read by `add_principal_to_space`, so **every** join path stops seeding `owner@~` for users) and flips the creator to **god mode** (`admin` + `owner@/`); the default group is controlled by whether `provision_default_group` is called (`--no-default-group`), its name (`--default-group `), and whether it's seeded with grants (`--no-default-group-grants`) — the chosen group is flagged `principal.is_default_group` (one per space, partial-unique; rename/delete-robust; surfaced on the space read as `defaultGroup` and used as the `me space invite` default). There is no `auto_grant_team` column — group grants are just `tree_access` rows. `memory.create`/`batchCreate` **require** an explicit `tree` (callers choose `share` vs `~` deliberately); only the file importers (`me import memories`, the `me_memory_import` MCP tool) default a tree-less record to `share` (`SHARE_NAMESPACE`, canonically defined in `@memory.build/protocol` and re-exported by `@memory.build/database`). - **API**: JSON-RPC 2.0 over HTTP, two endpoints: - - `/api/v1/memory/rpc` — OAuth access token **or** api-key bearer (agent or user PAT) **or** cookie session, + required `X-Me-Space: ` header. Memory data plane (`memory.*`) + space management (`principal.*`, `group.*`, `grant.*`, `invite.*`). - - `/api/v1/user/rpc` — OAuth token, cookie, **or the user's own api key (PAT)**; an **agent** key is barred here (agents can't manage the account), and `apiKey.create`/`delete` are session-only (keys can't mint keys). `whoami`, `agent.*`, `apiKey.*`, `space.*`. - - `/api/v1/auth/*` is owned end-to-end by **better-auth** (social sign-in, OAuth 2.1 authorize/token, sessions/sign-out) — see `AUTH_DESIGN.md`. The **web UI is served at root `/`** (any non-`/api` GET → static assets / SPA fallback), including the `/login` page the CLI authorize flow redirects to. -- **Auth**: humans authenticate via **OAuth (GitHub/Google)** → a **better-auth session** — an httpOnly cookie for the web UI, and for the CLI an OAuth 2.1 authorization-code + PKCE loopback flow (RFC 8252) yielding access/refresh tokens. For **headless** CLI use (a sandboxed harness / no local browser), `me login --device` runs the OAuth 2.0 **Device Authorization Grant** (RFC 8628) via better-auth's `deviceAuthorization` plugin: the CLI polls `/api/v1/auth/device/{code,token}` while the human approves the `user_code` at the web `/device` page; on approval it mints a better-auth **session** (no refresh token — a 7-day sliding session), presented back as an `Authorization: Bearer` token and accepted by the resource-server middleware via the `bearer` plugin (`getSession`). Users headlessly, agents, and service accounts can also use an **api key** (`me..`: user PAT, agent key, or service-account key). Api keys are **global** per-principal credentials, not space-bound: the same key works in any space the principal has been admitted to (the space comes from `X-Me-Space`, gated by `build_tree_access`). Service accounts are space-scoped principals, so their keys are useful only in that service account's space. better-auth owns session + OAuth-token storage (OAuth access/refresh tokens hashed sha256 at rest); api-key secrets are **core** sha256 (compared by equality in SQL), not argon2. Full design: `AUTH_DESIGN.md`. -- **Act as agent (`X-Me-As-Agent`)**: honored on **both** RPC endpoints. A human credential (session / OAuth / user PAT) may send `X-Me-As-Agent: ` to run as one of its **own** agents — the server resolves it against `core.listAgents(caller)` by id or case-insensitive name, rejects ambiguous matches, overwrites the resolved principal to the agent, and authorizes the request as that agent (the **parity invariant**: exactly what the agent's own `ME_API_KEY` could do — memory RPC clamps `treeAccess`/`admin`; user RPC's `AGENT_ALLOWED` allow-list means **management ops fail**). For a bare `--as-agent`/`ME_AS_AGENT` on the CLI, agent mode is **ambient** and always **explicit**: turn it on with `--as-agent ` (a required-value global flag) or `ME_AS_AGENT`, where the literal **`.me` sentinel** sources the id from `.me/config.yaml`'s `agent`, else the global config's `agent` (client-side; never sent). A `.me`/global `agent` alone never activates *this* mode. An **agent api key trumps the header** (the bearer already *is* an agent → header ignored); an unowned/unknown value → 403 `INVALID_AGENT`. The human is recorded as `authenticatedAs` for observability only (never gates authz). Header constant: `AS_AGENT_HEADER` in `@memory.build/protocol/headers`; carried on `ResolvedCredentials.asAgent`. -- **Agent-by-config (harness surfaces)**: `me mcp` and the capture hooks (`me hook`) are the one exception to "activation is always explicit" above — they resolve `resolveHarnessAgent()` UNCONDITIONALLY (project `agent:` → global `agent:` → fatal), as if `--as-agent .me` were passed, because a harness surface has no human caller for "no agent" to safely default to. `me mcp` validates the resolved agent eagerly at startup (one `whoami` round trip) so a dead config fails at launch, not on every tool call. A plain `me` call from a harness's own shell (Claude, opencode, Codex, Gemini CLI) gets the same resolution via an **injected environment contract** (`ME_INJECT_V`/`AI_AGENT`/`ME_AS_AGENT=.me`/`ME_PROJECT_DIR`, see `packages/cli/harness-contract.ts`) that each harness adapter writes into every shell command — Claude via a SessionStart hook appending to `$CLAUDE_ENV_FILE` (`me claude env`), opencode via its plugin's `shell.env` hook, and Codex/Gemini by REWRITING the command string itself (`me codex env-hook` / `me gemini env-hook`, prepending an `export …; ` prefix via `renderExportPrefix` — see `packages/cli/codex/env-hook.ts` / `packages/cli/gemini/env-hook.ts`; both fail open and log an unrecognized payload's STRUCTURE, never its content, via `packages/cli/harness-shape-log.ts`, for a later `me doctor` to surface). `packages/cli/failsafe.ts` + `packages/cli/harness-detect.ts` (wrapping `@vercel/detect-agent`) hard-error a harness-run `me` when that contract is missing (an uninstalled adapter, untrusted Codex hooks, an unintegrated harness), except in an interactive TTY (an IDE integrated terminal — treated as human) or with an explicit `--as-agent`/agent api key. `.me/config.yaml`'s `agent: .user` and the global config's `agent: .user` both mean "run as the user, deliberately"; **only** the committed `.me/config.yaml` rejects `.user` (fatal `ProjectConfigError` — see `PROJECT_USER_SENTINEL` in `project-config.ts`), since that's the one value that would silently *raise* a cloning teammate's privilege. `ensureDefaultAgent()` (`packages/cli/agent/default-agent.ts`) provisions-or-adopts a `coder` agent and writes it as the global fallback at each harness's `install` time (skippable with `--no-default-agent`), so "no agent anywhere" is rare in practice. Codex/Gemini capture hooks and `me doctor` are deferred to a later PR. Full design: `HARNESS_DESIGN.md`. + - `/api/v1/memory/rpc` — OAuth access token **or** api-key bearer (user PAT or service-account key) **or** cookie session, + required `X-Me-Space: ` header. Memory data plane (`memory.*`) + space management (`principal.*`, `group.*`, `grant.*`, `invite.*`). + - `/api/v1/user/rpc` — OAuth token, cookie, **or the user's own api key (PAT)**; a **service-account** key is admitted only for allow-listed reads (`whoami`, `space.list`), and `apiKey.create`/`delete` are session-only (keys can't mint keys). `whoami`, `apiKey.*`, `serviceAccount.*`, `space.*`. + - `/api/v1/auth/*` is owned end-to-end by **better-auth** (social sign-in, OAuth 2.1 authorize/token, sessions/sign-out). The **web UI is served at root `/`** (any non-`/api` GET → static assets / SPA fallback), including the `/login` page the CLI authorize flow redirects to. +- **Auth**: humans authenticate via **OAuth (GitHub/Google)** → a **better-auth session** — an httpOnly cookie for the web UI, and for the CLI an OAuth 2.1 authorization-code + PKCE loopback flow (RFC 8252) yielding access/refresh tokens. For **headless** CLI use (a sandboxed harness / no local browser), `me login --device` runs the OAuth 2.0 **Device Authorization Grant** (RFC 8628) via better-auth's `deviceAuthorization` plugin: the CLI polls `/api/v1/auth/device/{code,token}` while the human approves the `user_code` at the web `/device` page; on approval it mints a better-auth **session** (no refresh token — a 7-day sliding session), presented back as an `Authorization: Bearer` token and accepted by the resource-server middleware via the `bearer` plugin (`getSession`). Users headlessly and service accounts can also use an **api key** (`me..`: user PAT or service-account key). Api keys are **global** per-principal credentials, not space-bound: the same key works in any space the principal has been admitted to (the space comes from `X-Me-Space`, gated by `build_tree_access`). Service accounts are space-scoped principals, so their keys are useful only in that service account's space. better-auth owns session + OAuth-token storage (OAuth access/refresh tokens hashed sha256 at rest); api-key secrets are **core** sha256 (compared by equality in SQL), not argon2. +- **Harness surfaces (`me mcp`, capture hooks, harness shells)**: a plain `me` invocation always runs as the principal represented by the presented credential (session, OAuth token, user PAT, or service-account key). There is no impersonation, no `--as-agent`, no `ME_AS_AGENT`, and no `X-Me-As-Agent`. The one thing a harness still injects into every shell command is the discovery anchor `ME_PROJECT_DIR` — set by Claude's SessionStart hook (`me claude env` → `$CLAUDE_ENV_FILE`), opencode's `shell.env` plugin hook, and Codex/Gemini's command-string rewrites (`me codex env-hook` / `me gemini env-hook`, prepending an `export …; ` prefix via `renderExportPrefix` — see `packages/cli/codex/env-hook.ts` / `packages/cli/gemini/env-hook.ts`; both fail open and log an unrecognized payload's STRUCTURE, never its content, via `packages/cli/harness-shape-log.ts`, for a later `me doctor` to surface). The contract lives in `packages/cli/harness-contract.ts` and consists of two vars: `AI_AGENT` (inert identity metadata, per the `@vercel/detect-agent` convention) and `ME_PROJECT_DIR` (the discovery anchor `me` walks up from). Legacy compat: `X-Me-As-Agent` is silently ignored server-side; a legacy `agent:` field in `.me/config.yaml` or the global `~/.config/me/config.yaml` still validates and is preserved on write, but has no effect. - **Embedding**: Vercel AI SDK; OpenAI `text-embedding-3-small` (1536-dim) in production; Ollama supported for local dev. -- **CLI**: `me` binary — `login`, `logout`, `whoami`, `space`, `group`, `access`, `agent`, `service`, `apikey`, `memory` (+ top-level aliases like `me search`, `me create` — except `import`), `import` (the source group: `memories`/`claude`/`codex`/`opencode`/`granola`/`git`/`docs`/`slab`; `me memory import` and `me import` remain as aliases), `mcp`, `project` (per-project setup: `me project init`), `claude`/`codex`/`gemini`/`opencode`, `serve`, `pack`. +- **CLI**: `me` binary — `login`, `logout`, `whoami`, `space`, `group`, `access`, `service`, `apikey`, `memory` (+ top-level aliases like `me search`, `me create` — except `import`), `import` (the source group: `memories`/`claude`/`codex`/`opencode`/`granola`/`git`/`docs`/`slab`; `me memory import` and `me import` remain as aliases), `mcp`, `project` (per-project setup: `me project init`), `claude`/`codex`/`gemini`/`opencode`, `serve`, `pack`. ## Principals, members, spaces (terminology) -- **Principal** = the union **user | agent | group** (`principal.kind` = `'u'` | `'a'` | `'g'`). The space roster (`principal_space`) holds principals — users, agents, **and groups** (a group is rostered into its space on creation, so `principal_space` is the single source of truth for who/what belongs to a space). `principal.member_id` is a generated column equal to `id` for users/agents (NOT groups). -- **Member** = the **user/agent** sense only — group members and api-key holders. So params split as `principalId` (roster / grants, any kind) vs `memberId` (group membership, api keys; u|a only). The space-roster surface is principal-centric (`principal.*` methods, `SpacePrincipal` type), reserving "member" for u|a. +- **Principal** = the union **user | group | service account** (`principal.kind` = `'u'` | `'g'` | `'s'`). The space roster (`principal_space`) holds principals — users, service accounts, **and groups** (a group is rostered into its space on creation, so `principal_space` is the single source of truth for who/what belongs to a space). `principal.member_id` is a generated column equal to `id` for users/service accounts (NOT groups). +- **Member** = the **user/service-account** sense only — group members and api-key holders. So params split as `principalId` (roster / grants, any kind) vs `memberId` (group membership, api keys; u|s only). The space-roster surface is principal-centric (`principal.*` methods, `SpacePrincipal` type), reserving "member" for u|s. - **Space**: identified by an immutable 12-char `slug` (which is the `me_` schema name and the `X-Me-Space` value) and a renamable `name`. `me space rename` changes only the name. No org / engine / shard concepts. -- **Admin**: `principal_space.admin` is *structural* authority — roster mutations (`principal.add`/`remove`), groups, and invitations (`invite.*`) — distinct from data ownership (owner@path via `tree_access`). Enumerating the **full** roster (`principal.list` — includes groups plus each principal's `admin`/`owner_id`/timestamps) is admin-only; **any member** may `principal.resolve`/`lookup` (a targeted name↔id lookup) and `space.listMembers` (a **minimal** member listing — `id`/`kind`/`name`, u/a/s only, groups excluded). Member-level enumeration is deliberate: any member can already grant a path they own to any other member (`me access grant`) and a group admin (who need not be a space admin) must find who's addable (`me group add`), so discovering fellow members — but not admin metadata or group roster entries — is a member capability. Admin transfers through an admin group (a group whose own `principal_space.admin` is true) to its members who are **also direct space members**; agents are never admins, and service accounts may be made direct space admins but are discouraged and never count toward the last-admin invariant. A group is created non-admin and promoted/demoted via `set_group_is_space_admin` (`group.setIsSpaceAdmin` / `me group set-space-admin`), or created admin directly (`create_group(..., _is_space_admin)` / `me group create --space-admin`); demotion is gated by `enforce_last_admin`. (Distinct from a group *member's* admin flag, `group_member.admin`, which governs the group's own membership; service accounts may hold this flag on ordinary groups, agents cannot exercise it.) A space must always keep ≥1 *effective* admin (a **user** who is a direct admin or a direct member of an admin group — a group with no direct-member users doesn't count) — the `enforce_last_admin` trigger on `principal_space` + `group_member` rejects any remove/demote/group-member-removal that would drop the last one (SQLSTATE `ME001` → `LAST_ADMIN`), but exempts whole-space deletion. -- **Membership is explicit, not transitive**: a user/agent/service-account's space membership is their own `principal_space` row, full stop — group membership alone never confers it. (Groups themselves *are* rostered — a group gets its own `principal_space` row on creation, which is what makes it resolvable/grantable by name — but that is the group's **own** roster entry, independent of conferral: being in a group never makes a *user/agent/service-account* a space member.) A group's grants (and admin, if it's an admin group) are *effective* only for members who **also** hold a `principal_space` row; the gate lives in `member_tree_access`, so `build_tree_access` is empty for a non-member even if group rows were pre-staged. Joining (invite redemption / direct add / service-account creation) is the membership path. An admin can still **pre-stage** a member into a group before they join (`add_group_member` doesn't require membership); the group's grants stay dormant until they join. `remove_principal_from_space` scrubs the member's `group_member` rows along with the membership, and — when the removed principal is a **user** — cascades to the agents that user owns, deprovisioning them **from that space** too (their `principal_space`/`tree_access`/`group_member` rows; the agent `principal` rows and their other spaces are untouched), so an owner leaving never orphans a still-rostered agent. That cascade lives in the DB function, so every caller inherits it: an admin `principal.remove` (`me space remove-member`) and a member's own `me space leave`. Service accounts are space-scoped and deleted through `serviceAccount.delete`, which also deletes their bound admin group. Removal is admin-gated with two self-service exceptions mirroring `principal.add`'s own-agent carve-out — a user removing **themselves** (`me space leave`) and a member removing **their own agent** (`me agent remove`) — both still bounded by the `enforce_last_admin` guard (a sole admin's self-leave → `LAST_ADMIN`). Groups are never removed this way (they leave only via deletion). +- **Admin**: `principal_space.admin` is *structural* authority — roster mutations (`principal.add`/`remove`), groups, and invitations (`invite.*`) — distinct from data ownership (owner@path via `tree_access`). Enumerating the **full** roster (`principal.list` — includes groups plus each principal's `admin`/timestamps) is admin-only; **any member** may `principal.resolve`/`lookup` (a targeted name↔id lookup) and `space.listMembers` (a **minimal** member listing — `id`/`kind`/`name`, u/s only, groups excluded). Member-level enumeration is deliberate: any member can already grant a path they own to any other member (`me access grant`) and a group admin (who need not be a space admin) must find who's addable (`me group add`), so discovering fellow members — but not admin metadata or group roster entries — is a member capability. Admin transfers through an admin group (a group whose own `principal_space.admin` is true) to its members who are **also direct space members**; service accounts may be made direct space admins but are discouraged and never count toward the last-admin invariant. A group is created non-admin and promoted/demoted via `set_group_is_space_admin` (`group.setIsSpaceAdmin` / `me group set-space-admin`), or created admin directly (`create_group(..., _is_space_admin)` / `me group create --space-admin`); demotion is gated by `enforce_last_admin`. (Distinct from a group *member's* admin flag, `group_member.admin`, which governs the group's own membership.) A space must always keep ≥1 *effective* admin (a **user** who is a direct admin or a direct member of an admin group — a group with no direct-member users doesn't count) — the `enforce_last_admin` trigger on `principal_space` + `group_member` rejects any remove/demote/group-member-removal that would drop the last one (SQLSTATE `ME001` → `LAST_ADMIN`), but exempts whole-space deletion. +- **Membership is explicit, not transitive**: a user/service-account's space membership is their own `principal_space` row, full stop — group membership alone never confers it. (Groups themselves *are* rostered — a group gets its own `principal_space` row on creation, which is what makes it resolvable/grantable by name — but that is the group's **own** roster entry, independent of conferral: being in a group never makes a *user/service-account* a space member.) A group's grants (and admin, if it's an admin group) are *effective* only for members who **also** hold a `principal_space` row; the gate lives in `member_tree_access`, so `build_tree_access` is empty for a non-member even if group rows were pre-staged. Joining (invite redemption / direct add / service-account creation) is the membership path. An admin can still **pre-stage** a member into a group before they join (`add_group_member` doesn't require membership); the group's grants stay dormant until they join. `remove_principal_from_space` scrubs the member's `group_member` rows along with the membership. Service accounts are space-scoped and deleted through `serviceAccount.delete`, which also deletes their bound admin group. Removal is admin-gated with one self-service exception — a user removing **themselves** (`me space leave`) — still bounded by the `enforce_last_admin` guard (a sole admin's self-leave → `LAST_ADMIN`). Groups are never removed this way (they leave only via deletion). ## Project Structure @@ -190,11 +189,10 @@ reset when nothing else is using the database. `packages/cli/harness-smoke/*.smoke.ts` launch a real harness binary (`claude` and `opencode` verified live; `gemini` is scaffolded but unverified — see each file's module doc) non-interactively and check that -the injected environment contract -(`ME_INJECT_V`/`AI_AGENT`/`ME_AS_AGENT`/`ME_PROJECT_DIR`) actually lands in a -real shell command's real environment. They exist because `./bun run -check`/`check:full`/CI only exercise the decision logic (what a hook -*should* output) — nothing runs an actual harness end-to-end. +the injected environment contract (`AI_AGENT`/`ME_PROJECT_DIR`) actually +lands in a real shell command's real environment. They exist because +`./bun run check`/`check:full`/CI only exercise the decision logic (what +a hook *should* output) — nothing runs an actual harness end-to-end. **No `codex.smoke.ts`, deliberately**: Codex trusts a hook by the hash of its definition text and gates a new/changed one behind an interactive @@ -224,7 +222,7 @@ you expected to run reports 0 tests exercised. Each test builds a scratch project dir and a scratch `me` binary (shadowing whatever `me` is actually installed on `PATH`, via `writeMeWrapper()` in `_shared.ts`) so it always exercises the CURRENT checkout's code, never a stale global install — and -strips the four contract vars from its own process env before spawning +strips the contract vars from its own process env before spawning (`cleanEnv()`), since this very test suite may itself be running inside a live-injected harness session. @@ -276,10 +274,10 @@ wrap a deliberately overloaded name like `count_tree`. - **Single memory table per space**: all memory lives in `me_.memory`. Complexity comes from conventions in `meta` and `tree`, not schema proliferation. - **Database-native**: PostgreSQL extensions (ltree, pgvector/halfvec, JSONB GIN, tstzrange, BM25) instead of application-layer abstractions. - **Access via `tree_access`, not RLS**: RLS was unperformant. `build_tree_access` produces a `_tree_access` jsonb passed into the space functions; there is no `me.user_id` GUC. Three levels (read/write/owner); an owner grant delegates access-management within its subtree (owner@root = the whole space). -- **Two endpoints, two auth modes**: memory RPC (any bearer — OAuth token or api key — or cookie, + `X-Me-Space`) vs user RPC (OAuth token, cookie, or the user's own PAT; agent keys barred; key mint/revoke session-only). The middleware resolves the credential via `extractBearerToken` (api key → `core.validateApiKey`, else → `verifyOAuthToken`) and falls back to `betterAuth.api.getSession` for the cookie (Origin-CSRF-gated). +- **Two endpoints, two auth modes**: memory RPC (any bearer — OAuth token or api key — or cookie, + `X-Me-Space`) vs user RPC (OAuth token, cookie, or the user's own PAT; service-account keys are admitted only for allow-listed reads; key mint/revoke session-only). The middleware resolves the credential via `extractBearerToken` (api key → `core.validateApiKey`, else → `verifyOAuthToken`) and falls back to `betterAuth.api.getSession` for the cookie (Origin-CSRF-gated). - **Hosted web UI** (same `packages/web` build, two modes): `me serve` proxies `/rpc` locally (browser carries no creds); the API server also serves the UI at root (`https://api.memory.build/` in production) with **httpOnly-cookie** browser login (cookie carries the same session token; Origin-allowlist CSRF gate for cookie creds). Full design + runbook: `DEVELOPMENT.md` → "Hosted web UI" and the `DECISIONS_FOR_REVIEW.md` entry. -- **Principal vs member** terminology (see above): principal = u|a|s|g; member/`memberId` = u|a|s. -- **CLI credentials**: split across `~/.config/me/` — **`config.yaml`** (non-secret: default server + per-server **active space** / the X-Me-Space) and **`credentials.yaml`** (0600, secret session-token *fallback* only). The **session token** lives in the OS keychain when available (macOS `security`, Linux `secret-tool` via libsecret; `ME_NO_KEYCHAIN=1` forces off), else in `credentials.yaml` (empty/absent on keychain hosts); a pre-split `credentials.yaml` is migrated on first read. `me logout` clears the session secret but keeps the non-secret config (so re-login resumes). **Api keys are never persisted** — user PATs, agent keys, and service-account keys only ever come from `ME_API_KEY`/`--api-key`; `apiKey.create` prints the key once for the operator to place where it runs. Env: `ME_SERVER` / `ME_API_KEY` / `ME_SPACE` / `ME_SESSION_TOKEN` / `ME_NO_KEYCHAIN` / `ME_CONFIG_DIR` / `ME_PROJECT_DIR`. A per-project **`.me/config.yaml`** (walk-up from cwd, or `--config-dir`/`ME_CONFIG_DIR` for an exact dir, or `--project-dir`/`ME_PROJECT_DIR` — an ANCHOR that replaces cwd as the walk-up origin, set by harness adapters into every shell command they run) pins `server` + `space` + `agent` (+ optional full no-slug `tree` for integrations); a gitignored `.me/config.local.yaml` overrides it per-field. Below cwd walk-up sits a validated last-resort backstop (today: Claude's `CLAUDE_PROJECT_DIR`, accepted only if it contains `.me/` — it mis-resolves to the main checkout under `claude -w`, so it's demoted below cwd on purpose). Precedence: `--flag > ME_* env > .me (.local > committed) > global config.yaml > default`. Resolution lives in `packages/cli/project-config.ts`, wired into `resolveServer`/`resolveSpace`/`resolveCredentials` (so `me mcp` inherits it) and the capture hooks / `me import git` (which use the `tree` as the project root, no slug appended). **Credential-safety gate**: a `.me`-sourced `server` is only honored if it's in a **trusted list** (`DEFAULT_TRUSTED_SERVERS` = prod + dev, extendable via `server_whitelist` in the global config, auto-extended by `me login --server`) — otherwise a fatal `ProjectConfigError`, so an untrusted repo's `.me` can't redirect a global api key / `ME_SESSION_TOKEN` to an attacker (`--server`/`ME_SERVER`/stored `default_server` are the user's own choice, ungated). See `docs/project-config.md`. +- **Principal vs member** terminology (see above): principal = u|s|g; member/`memberId` = u|s. +- **CLI credentials**: split across `~/.config/me/` — **`config.yaml`** (non-secret: default server + per-server **active space** / the X-Me-Space) and **`credentials.yaml`** (0600, secret session-token *fallback* only). The **session token** lives in the OS keychain when available (macOS `security`, Linux `secret-tool` via libsecret; `ME_NO_KEYCHAIN=1` forces off), else in `credentials.yaml` (empty/absent on keychain hosts); a pre-split `credentials.yaml` is migrated on first read. `me logout` clears the session secret but keeps the non-secret config (so re-login resumes). **Api keys are never persisted** — user PATs and service-account keys only ever come from `ME_API_KEY`/`--api-key`; `apiKey.create` prints the key once for the operator to place where it runs. Env: `ME_SERVER` / `ME_API_KEY` / `ME_SPACE` / `ME_SESSION_TOKEN` / `ME_NO_KEYCHAIN` / `ME_CONFIG_DIR` / `ME_PROJECT_DIR`. A per-project **`.me/config.yaml`** (walk-up from cwd, or `--config-dir`/`ME_CONFIG_DIR` for an exact dir, or `--project-dir`/`ME_PROJECT_DIR` — an ANCHOR that replaces cwd as the walk-up origin, set by harness adapters into every shell command they run) pins `server` + `space` (+ optional full no-slug `tree` for integrations); a gitignored `.me/config.local.yaml` overrides it per-field. A legacy `agent:` field is still accepted and preserved on write for backwards compatibility, but has no effect. Below cwd walk-up sits a validated last-resort backstop (today: Claude's `CLAUDE_PROJECT_DIR`, accepted only if it contains `.me/` — it mis-resolves to the main checkout under `claude -w`, so it's demoted below cwd on purpose). Precedence: `--flag > ME_* env > .me (.local > committed) > global config.yaml > default`. Resolution lives in `packages/cli/project-config.ts`, wired into `resolveServer`/`resolveSpace`/`resolveCredentials` (so `me mcp` inherits it) and the capture hooks / `me import git` (which use the `tree` as the project root, no slug appended). **Credential-safety gate**: a `.me`-sourced `server` is only honored if it's in a **trusted list** (`DEFAULT_TRUSTED_SERVERS` = prod + dev, extendable via `server_whitelist` in the global config, auto-extended by `me login --server`) — otherwise a fatal `ProjectConfigError`, so an untrusted repo's `.me` can't redirect a global api key / `ME_SESSION_TOKEN` to an attacker (`--server`/`ME_SERVER`/stored `default_server` are the user's own choice, ungated). See `docs/project-config.md`. - **Header constants** (`CLIENT_VERSION_HEADER`, `SPACE_HEADER`) live in `@memory.build/protocol/headers`. - **Error reporting**: always import `reportError` from `@memory.build/database/telemetry`, never `@pydantic/logfire-node` directly. The wrapper writes the full error (stack + nested pg `cause`) to **stderr synchronously** *and* forwards to logfire — so a crash that exits before logfire's buffered OTLP exporter flushes (e.g. a failed boot migration) still leaves the cause in `kubectl logs`. (logfire's console processor is span-only, so a bare `reportError` is invisible on a crash.) `info` / `span` / `warning` stay imported directly from logfire. - **MCP compatibility**: all tool parameters are required (nullable for optional). Uses `z.record(z.string(), z.any())` for meta instead of `z.record(z.unknown())` (which crashes the MCP SDK). diff --git a/TNT-244_RELEASE.md b/TNT-244_RELEASE.md new file mode 100644 index 00000000..d92b8d20 --- /dev/null +++ b/TNT-244_RELEASE.md @@ -0,0 +1,153 @@ +# TNT-244 Release Runbook + +TNT-244 removes agent principals, agent API keys, and impersonation from Memory +Engine. Release all committed phases together. Do not deploy an intermediate +commit. + +## Before Release + +1. Confirm normal production backups have completed. +2. Notify the four holders of retired agent API keys. Their keys will stop + working after the migration; direct them to use a user PAT or service-account + key as appropriate. +3. Record the current agent and agent-key inventory: + + ```sql + -- (a) the agents themselves + select p.id, p.name, p.owner_id + from core.principal p + where p.kind = 'a' + order by p.name; + + -- (b) the api keys bound to those agents + select k.id, k.name, k.member_id, k.restricted + from core.api_key k + join core.principal p on p.id = k.member_id + where p.kind = 'a' + order by k.name; + + -- (c) restricted-key declarations that will cascade with the key row + select s.api_key_id, s.space_id, s.space_admin + from core.api_key_space_access s + join core.api_key k on k.id = s.api_key_id + join core.principal p on p.id = k.member_id + where p.kind = 'a'; + + select t.api_key_id, t.space_id, t.tree_path::text, t.access + from core.api_key_tree_access t + join core.api_key k on k.id = t.api_key_id + join core.principal p on p.id = k.member_id + where p.kind = 'a'; + ``` + +4. Record the memory count at every retired agent-home path. These rows must + remain in their existing paths after the migration. For each `me_` + space schema and each `(owner_id, agent_id)` from step 3(a), run a variant + of the following (agent-home paths follow `home..` + with hyphens stripped): + + ```sql + -- Replace , , as needed. + -- `<@` is ltree "descendant of", so this counts the home node itself + -- plus everything beneath it. + select tree::text, count(*) as memory_count + from me_.memory + where tree <@ 'home..'::ltree + group by tree + order by tree; + ``` + + For a scripted sweep across every space, iterate over + `select slug from core.space` and paste the resulting counts into the + release ticket for step 3 of "After Release" to compare against. + +5. Confirm expected user, group, and service-account access with a representative + account in each production space. + +6. **Effective-admin preflight — must return 0.** Application code has never + granted an agent `principal_space.admin`, but the DB does not forbid it. + Migration 018 deletes agent principals in one statement, which fires the + deferred `enforce_last_admin` trigger; if any space's *only* remaining + effective admin were an agent, the trigger would abort the migration with + `LAST_ADMIN`. Verify pre-emptively: + + ```sql + select count(*) + from core.principal_space ps + join core.principal p on p.id = ps.principal_id + where ps.admin and p.kind = 'a'; + ``` + + Any non-zero result means a space would otherwise be left without a user + admin — resolve by promoting a user admin (`me group set-space-admin`, or + a direct roster admin row) before the migration runs. + +## Release + +1. Merge the complete TNT-244 change set. +2. Run the server release as `0.7.0`. **At the `Bump MIN_CLIENT_VERSION?` + prompt, enter `0.7.0`** — do not press Enter to keep the default. This is + what makes older 0.6.x clients incompatible with the new server, matching + step 4's expectation. +3. Run the client release as `0.7.0`. **At the `Bump MIN_SERVER_VERSION?` + prompt, enter `0.7.0`** — again, do not press Enter to keep. +4. Treat the two releases as one maintenance-window deployment. Versioned + `0.6.2` clients are intentionally incompatible with the new server; + clients without a version header remain subject to the existing lenient + behavior. + +**Maintenance-window lock note.** Migration 018 issues +`ALTER TABLE core.principal ALTER COLUMN member_id SET EXPRESSION AS (...)`, +which takes an ACCESS EXCLUSIVE lock on `core.principal` and rewrites the +table (all row values recompute to the same values in practice, so it is +short — but it is a full-table event). This is why the two package releases +are done inside one maintenance window. + +## After Release + +1. Verify no agent principals remain: + + ```sql + select count(*) from core.principal where kind = 'a'; + ``` + +2. Verify no API keys belong to retired agents (and no scoped declarations + linger): + + ```sql + select count(*) + from core.api_key k + join core.principal p on p.id = k.member_id + where p.kind = 'a'; + + -- Both should also be 0 (cascaded from api_key.id): + select count(*) from core.api_key_space_access s + where not exists (select 1 from core.api_key k where k.id = s.api_key_id); + select count(*) from core.api_key_tree_access t + where not exists (select 1 from core.api_key k where k.id = t.api_key_id); + ``` + +3. Recheck the recorded agent-home memory counts and paths (step 4 above). + Each `(tree, count)` pair must match the pre-release capture; no rows + should have moved or been rewritten. + +4. Verify no `core.tree_access` row references a deleted principal (a smoke + check on the cascade — should always be 0): + + ```sql + select count(*) from core.tree_access ta + where not exists (select 1 from core.principal p where p.id = ta.principal_id); + ``` + +5. Verify normal user-session, restricted-PAT, and service-account-key access + with MCP and CLI operations. + +6. Confirm legacy `X-Me-As-Agent` is ignored, and that old agent keys receive + the normal invalid-key error. + +7. Watch error logs and authentication failures during the maintenance window. + +## Rollback + +Do not attempt a schema-only rollback after migration 018 has run. Restore from +the normal production backup if rollback is required. diff --git a/docs/access-control.md b/docs/access-control.md index 141cafcf..fda8e8ea 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -4,24 +4,23 @@ Memory Engine organizes knowledge into **spaces**. Access within a space is gran ## Principals -A **principal** is anything that can be granted access. There are four kinds: +A **principal** is anything that can be granted access. There are three kinds: | Kind | What it is | |------|------------| | **user** (`u`) | A human, authenticated by a session token (OAuth via GitHub or Google). | -| **agent** (`a`) | A non-human agent owned by one user, authenticated by an API key. Its effective access is clamped to its owner's. | | **service account** (`s`) | A space-scoped operational identity for CI/CD, webhooks, and team-owned integrations. It authenticates by API key and has no owner clamp. | -| **group** (`g`) | A named bundle of users, agents, and service accounts. | +| **group** (`g`) | A named bundle of users and service accounts. | -A **member** is the user/agent/service-account sense only — the things that can be put into a group or hold an API key. Group membership does **not** by itself confer space membership: a group's grants (and its admin, if it's an admin group) apply to you only once you have **also** joined the space directly. So an admin can add you to a group before you join — the group's access stays dormant until you do. +A **member** is the user/service-account sense only — the things that can be put into a group or hold an API key. Group membership does **not** by itself confer space membership: a group's grants (and its admin, if it's an admin group) apply to you only once you have **also** joined the space directly. So an admin can add you to a group before you join — the group's access stays dormant until you do. -A group is itself part of its space's roster (it gets a roster entry when created), which is what lets you grant access to it or reference it **by name**. That roster entry is the group's own; it is separate from membership conferral, which still depends on each user/agent/service-account having joined the space directly. +A group is itself part of its space's roster (it gets a roster entry when created), which is what lets you grant access to it or reference it **by name**. That roster entry is the group's own; it is separate from membership conferral, which still depends on each user/service-account having joined the space directly. ### Service accounts A **service account** is a durable operational identity administered by a team, not by one human owner. Create one with [`me service`](cli/me-service.md), then mint a key with `me apikey create --service `. Service accounts are useful for CI/CD jobs, importers, webhooks, and other team-owned integrations. -Each service account has a **bound admin group**. Space admins can manage all service accounts; direct user members of that bound admin group can administer that service account — renaming it, deleting it, and managing its API keys. Users, agents, and service accounts may all be members of the bound group, but only users count as service-account admins. The service account is not automatically added to its own bound admin group, and grants to that group behave like ordinary group grants. +Each service account has a **bound admin group**. Space admins can manage all service accounts; direct user members of that bound admin group can administer that service account — renaming it, deleting it, and managing its API keys. Users and service accounts may be members of the bound group, but only users count as service-account admins. The service account is not automatically added to its own bound admin group, and grants to that group behave like ordinary group grants. Service accounts start with **zero tree access**: no home grant, no default-group membership, and no owner clamp. Grant access to the service account directly, or add it to ordinary groups. A service-account key can use the memory and group/grant authorities the service account actually holds. By default it cannot create invitations; if you explicitly make the service account a space admin, it can use admin-gated invitation operations. It still cannot mint or revoke API keys or delete spaces. @@ -35,7 +34,7 @@ An API key created with [`me apikey create --allow`](cli/me-apikey.md#me-apikey- - `--space-admin ` permits space-admin authority only when the holder is also an effective admin in that space. - Restricted declarations are immutable. Rotate the key: create a replacement, deploy and verify it, then revoke the old key. -Agent keys cannot be restricted; use an agent's regular grants to limit an agent. Restricted user PATs cannot manage the account, including minting, revoking, or inspecting API keys. +Restricted user PATs cannot manage the account, including minting, revoking, or inspecting API keys. ## Spaces @@ -50,7 +49,7 @@ A user can belong to many spaces; each memory lives in exactly one space. There Access splits into two independent axes: -- **Structural authority** — `me space invite`, the roster (`me agent add`, `me service ...`, `me group ...`), and invitations. This is the space **admin** flag. Admin transfers through an **admin group** to its members who are also direct space members. Designate one with `me group create --space-admin` or `me group set-space-admin ` (revoke with `--off`); `me group list` shows which groups are admin groups. Agents are never admins. Service accounts can be made space admins explicitly, but this is discouraged and they do not count toward the last-admin safeguard. +- **Structural authority** — `me space invite`, the roster (`me service ...`, `me group ...`), and invitations. This is the space **admin** flag. Admin transfers through an **admin group** to its members who are also direct space members. Designate one with `me group create --space-admin` or `me group set-space-admin ` (revoke with `--off`); `me group list` shows which groups are admin groups. Service accounts can be made space admins explicitly, but this is discouraged and they do not count toward the last-admin safeguard. - **Data authority** — who can read/write/own memories at a given tree path. This is a **tree-access grant**. A space must always keep at least one *effective* human admin (a user who is a direct admin or a direct member of an admin group). The last-admin safeguard rejects any removal or demotion that would drop it (error code `LAST_ADMIN`). @@ -88,32 +87,24 @@ me access rm-grant bob@example.com /share/work/backend The level argument accepts `r` (read), `w` (write), or `o` (owner). -### Granting your own agents - -Managing a grant normally requires **owner** at the path. The exception is your -own **agents**: an agent's effective access is always clamped to its owner's, so -you can never give an agent more than you hold. Because of that you may grant or -revoke access for an agent you own at **any** path — even one you don't own — -without holding an owner grant there. The clamp keeps it honest: grant the agent -a higher level than you hold and it clamps down to yours; grant it a path you -have no access to and the agent simply gets nothing. This lets you scope an -agent to just the part of a subtree it needs, even on shared trees you don't own. - -Service accounts do **not** use this exception. They have no owner clamp, so granting access to a service account requires the normal authority: space admin or `owner` at the path. Revoking access from a service account is also allowed for space admins and direct user members of that service account's bound admin group. +Managing a grant requires **space admin** or **`owner`** at the path (admin +bypasses the path-owner check; an admin can always self-grant `owner@root`). +Revoking access from a service account is additionally allowed for direct user +members of that service account's bound admin group. ## Reserved tree roots Every space has two conventional roots: - **`/share`** — the shared root. Memories everyone in the space should see go here. This is where the file importers default a tree-less record, and where `me memory create` / `me_memory_create` callers usually place memories. -- **`/home/`** — a per-member private root. The input shortcut **`~`** expands to your own home, so `~/notes` means `/home//notes` and displays back as `~/notes`. An **agent**'s home nests under its owner's — `/home//` — so its owner can see what the agent stores under `~` (an agent's access is capped at its owner's regardless). Service accounts do not get a home grant; put their memories under an explicitly granted path such as `/share/...`. +- **`/home/`** — a user's private root. The input shortcut **`~`** expands to your own home, so `~/notes` means `/home//notes` and displays back as `~/notes`. Service accounts do not get a home grant; put their memories under an explicitly granted path such as `/share/...`. `/` is the canonical path separator (the leading slash is optional on input). Labels must match `[A-Za-z0-9_-]`. ### Default grants - A space **creator** gets `admin` + `owner@home` + `owner@share` — **not** `owner@root`. So the creator sees `share` and their own `~`, but not other members' homes. Because they're an admin, they can self-grant `owner@root` if they need the whole space. -- A **user** who joins a space is granted `owner@home` (their own private root). An **agent** who joins is likewise granted owner over its home — nested under its owner's (`/home//`) — so it's usable immediately and the grant isn't clamped away. Their **shared** access comes from the group they join (the default `team` group — see below), not from a per-invite grant. +- A **user** who joins a space is granted `owner@home` (their own private root). Their **shared** access comes from the group they join (the default `team` group — see below), not from a per-invite grant. - A **service account** starts with no tree grants and is not added to the default group. Grant it access deliberately with `me access grant ` or by adding it to an ordinary group. ### The default `team` group @@ -141,7 +132,7 @@ A **space admin** owns these defaults and can change them at any time: The provisioning defaults above are for a standard space. `me space create` flags let you shape a space's default access up front — e.g. a curated space you write to while others only read, or one where members can read without running up write (embedding) costs: -- `--no-home-grants` — joining users **and** agents get no `owner@~`. Service accounts never get `owner@~`. You (the creator) get **god mode** instead of the standard grants: `admin` + `owner@/` (the whole space). +- `--no-home-grants` — joining users get no `owner@~`. Service accounts never get `owner@~`. You (the creator) get **god mode** instead of the standard grants: `admin` + `owner@/` (the whole space). - `--default-group ` — name the default/invite group (default `team`). - `--no-default-group-grants` — create the default group **grantless** (no `read@/share` + `write@/share/projects`); you grant it by hand. - `--no-default-group` — don't create a default group at all. @@ -183,14 +174,10 @@ me group add backend bob@example.com # Grant the group write access to a subtree (members inherit it) me access grant backend /share/work/backend w -# Add one of your agents to the space and give it write access to share -me agent add ci-bot -me access grant ci-bot /share w - -# Or create a team-owned service account for CI and grant it write access +# Create a team-owned service account for CI and grant it write access me service create deploy-bot --admin ops@example.com me apikey create --service deploy-bot ci-key me access grant deploy-bot /share w ``` -See [`me access`](cli/me-access.md), [`me space`](cli/me-space.md), [`me group`](cli/me-group.md), [`me agent`](cli/me-agent.md), and [`me service`](cli/me-service.md) for full command references. +See [`me access`](cli/me-access.md), [`me space`](cli/me-space.md), [`me group`](cli/me-group.md), and [`me service`](cli/me-service.md) for full command references. diff --git a/docs/cli/me-access.md b/docs/cli/me-access.md index 6115bc83..a1d3308d 100644 --- a/docs/cli/me-access.md +++ b/docs/cli/me-access.md @@ -2,7 +2,7 @@ Manage tree-access grants in the active space. -A grant attaches an access **level** to a principal (user, agent, service account, or group) at a **tree path**. Levels are additive and hierarchical — a grant at `/share/work` also covers everything below it: +A grant attaches an access **level** to a principal (user, service account, or group) at a **tree path**. Levels are additive and hierarchical — a grant at `/share/work` also covers everything below it: | Level | Flag | Capabilities | |-------|------|--------------| @@ -33,7 +33,7 @@ me access grant | Argument | Required | Description | |----------|----------|-------------| -| `principal` | yes | Principal id or name (user email, agent name, service-account name, or group name). | +| `principal` | yes | Principal id or name (user email, service-account name, or group name). | | `path` | yes | Tree path; use an empty string `""` for the space root. | | `level` | yes | Access level: `r` (read), `w` (write), or `o` (owner). | @@ -64,9 +64,9 @@ me access rm-grant List grants in the active space, optionally scoped to one principal and/or a path subtree. Alias: `me access ls`. -Listing the whole space or another principal's grants requires **space admin** or **owner** of the path being listed (an admin can self-grant `owner@root`). Listing your own grants (or an agent you own) is self-service; [`me access mine`](#me-access-mine) is a convenient shortcut. +Listing the whole space or another principal's grants requires **space admin** or **owner** of the path being listed (an admin can self-grant `owner@root`). Listing your own grants is self-service; [`me access mine`](#me-access-mine) is a convenient shortcut. -Pass `--effective` to show the resolved access a member actually executes with instead of raw grant rows. Effective access includes direct grants, inherited group grants, service-account group grants, and agent grants clamped by the owner's access. `--effective` cannot be combined with `--path`. +Pass `--effective` to show the resolved access a member actually executes with instead of raw grant rows. Effective access includes direct grants and inherited group grants. `--effective` cannot be combined with `--path`. ``` me access list [principal] [--path ] [--effective] @@ -87,7 +87,7 @@ me access list [principal] [--path ] [--effective] List the access grants **you** hold in the active space. Available to **any member** (no admin or path-owner rights required). Fresh service accounts may have no grants until one is explicitly added. -Pass `--effective` to show the paths you can actually read, write, or own after group inheritance and agent clamping are applied. +Pass `--effective` to show the paths you can actually read, write, or own after group inheritance is applied. ``` me access mine [--effective] diff --git a/docs/cli/me-agent.md b/docs/cli/me-agent.md deleted file mode 100644 index 3b274727..00000000 --- a/docs/cli/me-agent.md +++ /dev/null @@ -1,139 +0,0 @@ -# me agent - -Manage agents. - -An **agent** is a service account you own — a non-human principal that authenticates with an API key. Agents are **global** (owned by you, names unique per user), independent of any space. Create an agent, add it to the spaces it should work in, then mint it an API key with [`me apikey`](me-apikey.md). - -These commands authenticate with your **session** (`me login`). Lifecycle commands (`create`/`list`/`rename`/`delete`) are global; `spaces` lists one of your agent's space memberships; `add`, `remove`, and `groups` operate on the active space. - -## Commands - -- [me agent list](#me-agent-list) -- list your agents -- [me agent create](#me-agent-create) -- create an agent -- [me agent rename](#me-agent-rename) -- rename an agent -- [me agent delete](#me-agent-delete) -- delete an agent -- [me agent spaces](#me-agent-spaces) -- list the spaces an agent belongs to -- [me agent add](#me-agent-add) -- add an agent to the active space -- [me agent remove](#me-agent-remove) -- remove an agent from the active space -- [me agent groups](#me-agent-groups) -- list an agent's groups in the space - ---- - -## me agent list - -List your agents. Alias: `me agent ls`. - -``` -me agent list -``` - ---- - -## me agent create - -Create an agent (a global service account you own). - -``` -me agent create -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `name` | yes | Agent name (unique among your agents). | - ---- - -## me agent rename - -Rename an agent. - -``` -me agent rename -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | -| `new-name` | yes | New name. | - ---- - -## me agent delete - -Delete an agent **globally**. Its API keys are deleted with it, and it leaves every space. Alias: `me agent rm`. To take an agent out of a *single* space while keeping it alive elsewhere, use [`me agent remove`](#me-agent-remove) instead. - -``` -me agent delete -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | - ---- - -## me agent add - -Add one of your agents to the active space's roster. It joins with owner over its own home — nested under yours (`/home//`), so you can see what it stores under `~`. Grant it shared access (e.g. on `share`) with [`me access`](me-access.md). - -``` -me agent add -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | - ---- - -## me agent remove - -Remove one of your agents from the active space's roster — the inverse of [`me agent add`](#me-agent-add). Its access grants and group memberships **in this space** are scrubbed; the agent itself, its API keys, and its memberships in other spaces are untouched. Removing your **own** agent is self-service — **no space-admin needed**. - -``` -me agent remove [-y] -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | - -| Option | Description | -|--------|-------------| -| `-y, --yes` | Skip the confirmation prompt. | - -To remove someone else's agent (as an admin), use [`me space remove-member`](me-space.md#me-space-remove-member). Note that leaving a space with [`me space leave`](me-space.md#me-space-leave) already removes your agents in that space automatically. - ---- - -## me agent spaces - -List the spaces one of your agents belongs to. This uses your logged-in session to verify ownership of the agent; it does not authenticate with the agent's `ME_API_KEY`. - -``` -me agent spaces -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | - ---- - -## me agent groups - -List the groups an agent belongs to in the active space. - -``` -me agent groups -``` - -| Argument | Required | Description | -|----------|----------|-------------| -| `agent` | yes | Agent id or name. | - -## See also - -- [`me apikey`](me-apikey.md) -- mint, list, and revoke an agent's API keys. -- [`me access`](me-access.md) -- grant the agent access to tree paths. -- [MCP Integration](../mcp-integration.md) -- run an agent against a space over MCP. diff --git a/docs/cli/me-apikey.md b/docs/cli/me-apikey.md index 427643d5..6ad908f8 100644 --- a/docs/cli/me-apikey.md +++ b/docs/cli/me-apikey.md @@ -1,6 +1,6 @@ # me apikey -Manage API keys — your own **personal access token (PAT)**, a key for one of your **agents** (`--agent`), or a key for a **service account** (`--service`). +Manage API keys — your own **personal access token (PAT)** or a key for a **service account** (`--service`). An API key is a global, per-principal credential — **not** inherently bound to a space. An unrestricted key works in any space its principal has been admitted to. A restricted key works only in its declared spaces and is capped to any declared tree grants; the space comes from the `X-Me-Space` header (`--space` / `ME_SPACE`). Keys are formatted `me..`. @@ -9,15 +9,14 @@ current authority, while a restricted key is capped to declared spaces and optional tree grants. Both modes can be held by the following principals: - **Personal access token** (default) — acts as **you**, for headless/CLI use (a VM, SSH, CI) where your `me login` session isn't available. It can be unrestricted or restricted with `--allow`, but it **cannot** manage keys (minting/revoking always needs a session). -- **Agent key** (`--agent `) — acts as one of your agents, for a dedicated/unattended agent install. - **Service-account key** (`--service `) — acts as a space-scoped service account, for CI/CD jobs, webhooks, and other team-owned integrations. Treat it like a production secret. Minting and revoking keys authenticate with your **session** (`me login`); an API key can't mint or revoke keys. The CLI never persists API keys — a created key is printed **once** for you to place where it's used (typically the `ME_API_KEY` environment variable). The alias `me apikey revoke` is equivalent to `me apikey delete`. ## Commands -- [me apikey create](#me-apikey-create) -- mint a personal access token, agent key, or service-account key -- [me apikey list](#me-apikey-list) -- list your keys, an agent's, or a service account's +- [me apikey create](#me-apikey-create) -- mint a personal access token or service-account key +- [me apikey list](#me-apikey-list) -- list your keys or a service account's - [me apikey get](#me-apikey-get) -- show key metadata - [me apikey delete](#me-apikey-delete) -- delete (revoke) a key @@ -25,10 +24,10 @@ Minting and revoking keys authenticate with your **session** (`me login`); an AP ## me apikey create -Mint a new API key. With no target option, mints a **personal access token** for yourself; with `--agent`, mints a key for that agent; with `--service`, mints a key for that service account in the active space. The raw key is shown only once — store it securely. +Mint a new API key. With no target option, mints a **personal access token** for yourself; with `--service`, mints a key for that service account in the active space. The raw key is shown only once — store it securely. ``` -me apikey create [name] [--agent | --service ] [--expires | --ttl ] [--allow ] [--space-admin ] +me apikey create [name] [--service ] [--expires | --ttl ] [--allow ] [--space-admin ] ``` | Argument | Required | Description | @@ -37,7 +36,6 @@ me apikey create [name] [--agent | --service ] [--expires