@consiliency/agent-board-schema 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -304,6 +304,14 @@
304
304
  {
305
305
  "id": "20260725000100_agent_board_roomdeleg_role_gate",
306
306
  "sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
307
+ },
308
+ {
309
+ "id": "20260726000100_agent_board_localauth_self_enroll",
310
+ "sha256": "38bcc86f0e6d4ad38bc76f5abe719456f5faa2ac5bd2ab24813c61d0160da664"
311
+ },
312
+ {
313
+ "id": "20260727000100_agent_board_roomuiapi",
314
+ "sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
307
315
  }
308
316
  ]
309
317
  }
@@ -0,0 +1,172 @@
1
+ -- LOCALAUTH: Portal-free standalone human self-enrollment.
2
+ --
3
+ -- WHAT: self_enroll_human() lets a browser human who has signed in with the
4
+ -- target Supabase project's OWN auth (magic-link/OTP/OAuth/passkey) bind their
5
+ -- auth.uid() to a kind='human' agent_board.agents row from their own
6
+ -- authenticated session — no Portal, no service_role, no operator step per user.
7
+ --
8
+ -- IDENTITY MODEL: the human is SELF-owned (owner_user_id = auth_user_id =
9
+ -- auth.uid()). Contrast enroll_federated_identity, where auth_user_id is a
10
+ -- synthetic per-agent uid and owner_user_id is the real human. The only trusted
11
+ -- identity input is auth.uid(); there is NO auth_user_id/agent_id argument, so a
12
+ -- caller cannot self-assign someone else's identity. Spoofing auth.uid() would
13
+ -- require the JWT-signing secret or the service_role key — that is inherent to
14
+ -- Supabase, not a boundary introduced here.
15
+ --
16
+ -- PII: metadata.localauth stores ONLY a boolean email_verified (derived from
17
+ -- auth.users.email_confirmed_at), never the plaintext email. The agents SELECT
18
+ -- RLS is not row-scoped, so a stored email would be a fleet-wide harvestable
19
+ -- directory. The canonical human email lives in auth.users (bound via the
20
+ -- agent's auth_user_id); an IDLINK flow reads it there via service_role AND MUST
21
+ -- RE-VERIFY email ownership at link time (email_confirmed_at can be admin- or
22
+ -- auto-set, so it is not by itself proof of possession).
23
+ --
24
+ -- OPERATOR / SCOPE (L-10): enabling a Supabase Auth sign-in method in the target
25
+ -- project is a net-new OPERATOR config task. The sign-in UI / browser app is
26
+ -- EXTERNAL / consumer-owned — there is no in-repo frontend. LOCALAUTH ships the
27
+ -- RPC + the zero-node `@consiliency/agent-board-client/localauth` helper ONLY.
28
+ --
29
+ -- Additive, forward-only. Writes no schema_version (stays 8).
30
+
31
+ create or replace function agent_board.self_enroll_human(
32
+ p_display_name text default null
33
+ )
34
+ returns table(
35
+ agent_id uuid,
36
+ handle text,
37
+ kind text,
38
+ status text,
39
+ auth_user_id uuid,
40
+ created_at timestamptz
41
+ )
42
+ language plpgsql
43
+ security definer
44
+ set search_path = agent_board, public
45
+ as $$
46
+ declare
47
+ v_uid uuid := auth.uid();
48
+ v_existing agent_board.agents%rowtype;
49
+ v_email_confirmed_at timestamptz;
50
+ v_email_verified boolean;
51
+ v_handle text;
52
+ v_agent agent_board.agents%rowtype;
53
+ begin
54
+ -- Fail closed: no authenticated Supabase user -> no identity to bind. This also
55
+ -- covers service_role and any superuser/operator path (auth.uid() is null for
56
+ -- them), which is intentional: LOCALAUTH is a self-service authenticated path.
57
+ if v_uid is null then
58
+ raise exception 'self_enroll_human requires an authenticated Supabase user (auth.uid() is null)'
59
+ using errcode = '42501';
60
+ end if;
61
+
62
+ -- Idempotency + no-self-reactivation guard, keyed on auth_user_id (ANY status).
63
+ -- An ACTIVE row -> return it (idempotent, no duplicate). A NON-active row
64
+ -- (status <> 'active', i.e. inactive or suspended per the agents status enum)
65
+ -- -> fail closed: a deactivated/suspended human must NOT self-reactivate; an
66
+ -- operator must restore them.
67
+ select * into v_existing
68
+ from agent_board.agents a
69
+ where a.auth_user_id = v_uid
70
+ order by (a.status = 'active') desc, a.created_at asc
71
+ limit 1;
72
+ if found then
73
+ if v_existing.status = 'active' then
74
+ return query
75
+ select v_existing.id, v_existing.handle, v_existing.kind::text,
76
+ v_existing.status, v_existing.auth_user_id, v_existing.created_at;
77
+ return;
78
+ else
79
+ raise exception 'a non-active identity already exists for this user; contact an operator'
80
+ using errcode = '42501';
81
+ end if;
82
+ end if;
83
+
84
+ -- Verified-email as a BOOLEAN only. Read email_confirmed_at server-side (never
85
+ -- trust an unverified JWT email claim); store the derived boolean, never the
86
+ -- address.
87
+ select u.email_confirmed_at into v_email_confirmed_at
88
+ from auth.users u
89
+ where u.id = v_uid;
90
+ v_email_verified := v_email_confirmed_at is not null;
91
+
92
+ -- Deterministic globally-unique handle: 16 hex (64-bit) of sha256(uid).
93
+ v_handle := 'local-human-' || substr(encode(extensions.digest(v_uid::text, 'sha256'), 'hex'), 1, 16);
94
+
95
+ begin
96
+ insert into agent_board.agents(
97
+ agent_type,
98
+ kind,
99
+ handle,
100
+ display_name,
101
+ status,
102
+ auth_user_id,
103
+ owner_user_id,
104
+ metadata
105
+ )
106
+ values (
107
+ 'human',
108
+ 'human'::agent_board.agent_kind,
109
+ v_handle,
110
+ coalesce(nullif(trim(p_display_name), ''), 'Human'),
111
+ 'active',
112
+ v_uid,
113
+ v_uid,
114
+ jsonb_build_object(
115
+ 'localauth', jsonb_build_object(
116
+ 'self_enrolled_at', now(),
117
+ 'email_verified', v_email_verified
118
+ )
119
+ )
120
+ )
121
+ returning * into v_agent;
122
+ exception when unique_violation then
123
+ -- Concurrent first-enroll race: another session inserted the active row for
124
+ -- this uid between our select and insert. Re-select and return it.
125
+ select * into v_existing
126
+ from agent_board.agents a
127
+ where a.auth_user_id = v_uid
128
+ and a.status = 'active'
129
+ limit 1;
130
+ if found then
131
+ return query
132
+ select v_existing.id, v_existing.handle, v_existing.kind::text,
133
+ v_existing.status, v_existing.auth_user_id, v_existing.created_at;
134
+ return;
135
+ end if;
136
+ -- Not the uid race -> a genuine handle collision (astronomically rare for a
137
+ -- 64-bit hash). Surface a clean, distinct, retryable error rather than the
138
+ -- opaque raw unique_violation.
139
+ raise exception 'could not self-enroll (handle collision), please retry'
140
+ using errcode = '40001';
141
+ end;
142
+
143
+ return query
144
+ select v_agent.id, v_agent.handle, v_agent.kind::text,
145
+ v_agent.status, v_agent.auth_user_id, v_agent.created_at;
146
+ end;
147
+ $$;
148
+
149
+ comment on function agent_board.self_enroll_human(text) is
150
+ 'Portal-free self-service: binds auth.uid() to a kind=human agent (self-owned: '
151
+ 'owner_user_id = auth_user_id = auth.uid()). Idempotent for an active identity; '
152
+ 'a non-active identity fails closed (42501, no self-reactivation). Stores only a '
153
+ 'BOOLEAN metadata.localauth.email_verified (never the plaintext email). Fails '
154
+ 'closed with 42501 when auth.uid() is null (unauthenticated / service_role / '
155
+ 'operator). authenticated-only; not a signed write.';
156
+
157
+ -- authenticated-only. service_role is explicitly revoked (belt-and-suspenders on
158
+ -- top of the auth.uid()-null fail-close): the service path is
159
+ -- enroll_federated_identity, not this self-service RPC.
160
+ revoke all on function agent_board.self_enroll_human(text) from public, anon, service_role;
161
+ grant execute on function agent_board.self_enroll_human(text) to authenticated;
162
+
163
+ -- L-4b DB backstop: one agent per REAL auth user, enforced at the DB (not just the
164
+ -- app guard above). SAFE because federated agents use SYNTHETIC per-agent uids
165
+ -- (_ensure_agent_auth_user), so only real-uid rows are ever constrained. This is a
166
+ -- DIFFERENT guarantee than the pre-existing partial-unique
167
+ -- idx_agent_board_agents_auth_user_id_active_unique (which is scoped to
168
+ -- status='active'); this one admits at most one row per uid EVER. On real data a
169
+ -- failure here means a genuine duplicate — correct fail-loud.
170
+ create unique index if not exists idx_agent_board_agents_auth_user_id_unique
171
+ on agent_board.agents(auth_user_id)
172
+ where auth_user_id is not null;
@@ -0,0 +1,122 @@
1
+ -- ROOMUIAPI: two read RPCs the room console/CLI need. Additive, no schema_version
2
+ -- bump (stays 8). Adds:
3
+ -- * agent_board.list_board_pending_memberships(p_board_id) — owner/admin-gated
4
+ -- reader that returns the board's pending (requested + invited) memberships,
5
+ -- latest-row-per-agent. board_memberships SELECT RLS is self-scoped, so a board
6
+ -- owner/admin cannot see EITHER pending state today; this reader is the sole
7
+ -- window. FLAT owner/admin gate ALWAYS, fail-closed on a null actor (mirrors
8
+ -- set_board_role_gate 20260725000100:46-55; NOT the write-RPC `if v_actor is not
9
+ -- null` bypass). Deliberately STRICTER than resolve_board_request: enumerating
10
+ -- requesters is disclosure, so no creator-identity exemption and no gate-off
11
+ -- relaxation. service_role (current_agent_id() = null) hits 42501 here; operators
12
+ -- read the table directly, bypassing RLS.
13
+ -- * agent_board.resolve_agent_handle(p_handle) — SECURITY INVOKER handle->identity
14
+ -- resolver. Runs under caller RLS, so by construction it cannot exceed
15
+ -- agent_board_authenticated_select_agents (20260512000003:49-54, which already
16
+ -- lets any authenticated active agent read every handle) — zero new exposure. The
17
+ -- status='active' filter is a UI affordance (invite accepts any status), not a
18
+ -- security boundary. Returns 0-or-1 row.
19
+
20
+ -- 1. list_board_pending_memberships (owner/admin-gated pending reader) --------------
21
+ create or replace function agent_board.list_board_pending_memberships(
22
+ p_board_id uuid
23
+ )
24
+ returns table(
25
+ membership_id uuid,
26
+ board_id uuid,
27
+ agent_id uuid,
28
+ handle text,
29
+ display_name text,
30
+ agent_type text,
31
+ membership_state text,
32
+ role text,
33
+ created_by_agent_id uuid,
34
+ requested_at timestamptz,
35
+ updated_at timestamptz
36
+ )
37
+ language plpgsql
38
+ stable
39
+ security definer
40
+ set search_path = agent_board, public
41
+ as $$
42
+ declare
43
+ v_actor uuid := agent_board.current_agent_id();
44
+ begin
45
+ -- FLAT owner/admin gate, ALWAYS. Fail closed on a null actor (suspended/unmapped
46
+ -- principal, or service_role) — mirrors set_board_role_gate, NOT the write-RPC
47
+ -- `if v_actor is not null` bypass. Authz precedes disclosure.
48
+ if v_actor is null
49
+ or not exists (
50
+ select 1
51
+ from agent_board.board_memberships bm
52
+ where bm.board_id = p_board_id
53
+ and bm.agent_id = v_actor
54
+ and bm.role in ('owner', 'admin')
55
+ and bm.membership_state in ('joined', 'subscribed')
56
+ ) then
57
+ raise exception 'only a board owner or admin may list pending memberships' using errcode = '42501';
58
+ end if;
59
+
60
+ -- Latest row per agent (in-place upserts append; the newest created_at is current
61
+ -- state), scoped to this board, filtered to the pending set. The dedup subquery is
62
+ -- ALSO scoped to p_board_id so no other board's rows can leak in.
63
+ return query
64
+ select
65
+ latest.id,
66
+ latest.board_id,
67
+ latest.agent_id,
68
+ a.handle,
69
+ a.display_name,
70
+ a.agent_type,
71
+ latest.membership_state,
72
+ latest.role,
73
+ latest.created_by_agent_id,
74
+ latest.created_at as requested_at,
75
+ latest.updated_at
76
+ from (
77
+ select distinct on (bm.agent_id) bm.*
78
+ from agent_board.board_memberships bm
79
+ where bm.board_id = p_board_id
80
+ order by bm.agent_id, bm.created_at desc
81
+ ) latest
82
+ join agent_board.agents a on a.id = latest.agent_id
83
+ where latest.membership_state in ('requested', 'invited')
84
+ order by latest.created_at desc;
85
+ end;
86
+ $$;
87
+
88
+ comment on function agent_board.list_board_pending_memberships(uuid) is
89
+ 'Owner/admin-gated reader for a board''s pending (requested + invited) memberships, latest-row-per-agent. FLAT owner/admin gate ALWAYS, fail-closed on a null actor (else 42501). Board-scoped: gate + dedup subquery both filter p_board_id. service_role hits 42501 (null current_agent_id()); operators read the table directly.';
90
+
91
+ revoke all on function agent_board.list_board_pending_memberships(uuid) from public, anon;
92
+ grant execute on function agent_board.list_board_pending_memberships(uuid) to authenticated, service_role;
93
+
94
+ -- 2. resolve_agent_handle (SECURITY INVOKER handle resolver) ------------------------
95
+ create or replace function agent_board.resolve_agent_handle(
96
+ p_handle text
97
+ )
98
+ returns table(
99
+ agent_id uuid,
100
+ handle text,
101
+ display_name text,
102
+ agent_type text
103
+ )
104
+ language sql
105
+ stable
106
+ security invoker
107
+ set search_path = agent_board, public
108
+ as $$
109
+ -- limit 1 is deterministic only via the UNIQUE handle constraint
110
+ -- (20260502000000:8); at most one active agent can own a given handle.
111
+ select a.id, a.handle, a.display_name, a.agent_type
112
+ from agent_board.agents a
113
+ where a.handle = p_handle
114
+ and a.status = 'active'
115
+ limit 1;
116
+ $$;
117
+
118
+ comment on function agent_board.resolve_agent_handle(text) is
119
+ 'SECURITY INVOKER handle->identity resolver: returns the 0-or-1 active agent for a handle, under caller RLS (cannot exceed agent_board_authenticated_select_agents). status=active is a UI affordance, not a security boundary. limit 1 deterministic via the UNIQUE handle constraint.';
120
+
121
+ revoke all on function agent_board.resolve_agent_handle(text) from public, anon;
122
+ grant execute on function agent_board.resolve_agent_handle(text) to authenticated, service_role;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.7.0",
5
+ "packageVersion": "1.9.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 76,
50
- "aggregateSha256": "fca8d220460ac16309d9a2d76cfa6f9ccc395e1c52c90c7cbd4bd5f04bbc42dc",
49
+ "migrationCount": 78,
50
+ "aggregateSha256": "4b6a5435c11341549636dfec4273c5bdc5773be23f8b8667ea816927e9412f87",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -732,6 +732,24 @@
732
732
  "sourceRef": "message-board",
733
733
  "bytes": 9521,
734
734
  "sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
735
+ },
736
+ {
737
+ "order": 76,
738
+ "id": "20260726000100_agent_board_localauth_self_enroll",
739
+ "filename": "20260726000100_agent_board_localauth_self_enroll.sql",
740
+ "origin": "message-board-forward",
741
+ "sourceRef": "message-board",
742
+ "bytes": 7323,
743
+ "sha256": "38bcc86f0e6d4ad38bc76f5abe719456f5faa2ac5bd2ab24813c61d0160da664"
744
+ },
745
+ {
746
+ "order": 77,
747
+ "id": "20260727000100_agent_board_roomuiapi",
748
+ "filename": "20260727000100_agent_board_roomuiapi.sql",
749
+ "origin": "message-board-forward",
750
+ "sourceRef": "message-board",
751
+ "bytes": 5217,
752
+ "sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
735
753
  }
736
754
  ],
737
755
  "excludedBridge": {
@@ -37,9 +37,9 @@ describe('manifest integrity', () => {
37
37
  expect(manifest.schemaVersion).toBe(8);
38
38
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
39
39
  });
40
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG forward migrations (migrationCount = prior 75 + 1 = 76)', () => {
40
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI forward migrations (migrationCount = prior 77 + 1 = 78)', () => {
41
41
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
42
- expect(manifest.migrationCount).toBe(76);
42
+ expect(manifest.migrationCount).toBe(78);
43
43
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
44
44
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
45
45
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -84,6 +84,13 @@ describe('manifest integrity', () => {
84
84
  // ROOMDELEG Lane A: owner-gated set_board_role_gate + creator-identity gate-bypass
85
85
  // fix on invite_board_member/resolve_board_request. schema stays 8.
86
86
  '20260725000100_agent_board_roomdeleg_role_gate.sql',
87
+ // LOCALAUTH: Portal-free standalone human self-enroll (self_enroll_human RPC +
88
+ // one-agent-per-real-auth-user backstop). schema stays 8.
89
+ '20260726000100_agent_board_localauth_self_enroll.sql',
90
+ // ROOMUIAPI: two read RPCs for the room console/CLI (owner/admin-gated
91
+ // list_board_pending_memberships + SECURITY INVOKER resolve_agent_handle).
92
+ // schema stays 8.
93
+ '20260727000100_agent_board_roomuiapi.sql',
87
94
  ]) {
88
95
  expect(names.has(f)).toBe(true);
89
96
  }
@@ -0,0 +1,171 @@
1
+ -- LOCALAUTH pgTAP: self_enroll_human() — Portal-free human self-enrollment.
2
+ --
3
+ -- Harness mirrors agent_board_authbound_boundary.test.sql: seed auth.users via
4
+ -- public.ensure_github_user, fake an authenticated caller with
5
+ -- set local role authenticated + set_config('request.jwt.claim.sub', <uid>),
6
+ -- so auth.uid() = sub. reset role -> superuser (auth.uid() null / service_role).
7
+ --
8
+ -- CRITICAL harness notes:
9
+ -- * L-7: `reset role` does NOT null auth.uid(); request.jwt.claim.sub is set
10
+ -- is_local=true and PERSISTS. Cases 12/13 explicitly clear the sub GUC before
11
+ -- asserting 42501, else the RPC finds the prior uid's agent (vacuous pass).
12
+ -- * L-8: ensure_github_user sets email_confirmed_at = now() unconditionally, so
13
+ -- the unverified case nulls it first.
14
+
15
+ create extension if not exists pgtap with schema extensions;
16
+ set search_path = extensions, public, agent_board;
17
+
18
+ begin;
19
+
20
+ select plan(17);
21
+
22
+ -- ---------------------------------------------------------------------------
23
+ -- Phase 0 (superuser): function metadata + seed
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ -- 1
27
+ select has_function('agent_board', 'self_enroll_human', array['text'], 'self_enroll_human(text) exists');
28
+ -- 2
29
+ select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'authenticated', array['EXECUTE'],
30
+ 'authenticated may EXECUTE self_enroll_human');
31
+ -- 3
32
+ select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'anon', array[]::text[],
33
+ 'anon may NOT EXECUTE self_enroll_human');
34
+
35
+ create temp table _lfix (
36
+ user_a uuid,
37
+ user_b uuid,
38
+ user_c uuid,
39
+ agent_a uuid,
40
+ agent_a2 uuid
41
+ ) on commit drop;
42
+ grant all on table _lfix to authenticated, service_role;
43
+
44
+ do $$
45
+ declare
46
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
47
+ v_a uuid;
48
+ v_b uuid;
49
+ v_c uuid;
50
+ begin
51
+ -- verified user (email_confirmed_at defaults to now())
52
+ v_a := public.ensure_github_user(
53
+ 'localauth-a-' || v_suffix,
54
+ 'localauth-a-' || v_suffix || '@example.test',
55
+ 'localauth-a-' || v_suffix,
56
+ 'LOCALAUTH A ' || v_suffix);
57
+
58
+ -- unverified user: L-8 — null out the unconditionally-set email_confirmed_at
59
+ v_b := public.ensure_github_user(
60
+ 'localauth-b-' || v_suffix,
61
+ 'localauth-b-' || v_suffix || '@example.test',
62
+ 'localauth-b-' || v_suffix,
63
+ 'LOCALAUTH B ' || v_suffix);
64
+ update auth.users set email_confirmed_at = null where id = v_b;
65
+
66
+ -- user with a pre-existing SUSPENDED human agent (no self-reactivation case)
67
+ v_c := public.ensure_github_user(
68
+ 'localauth-c-' || v_suffix,
69
+ 'localauth-c-' || v_suffix || '@example.test',
70
+ 'localauth-c-' || v_suffix,
71
+ 'LOCALAUTH C ' || v_suffix);
72
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
73
+ values ('human', 'human', 'localauth-suspended-' || v_suffix, 'Suspended Human', 'suspended', v_c, v_c);
74
+
75
+ insert into _lfix(user_a, user_b, user_c) values (v_a, v_b, v_c);
76
+ end
77
+ $$;
78
+
79
+ -- ---------------------------------------------------------------------------
80
+ -- Phase 1 (authenticated as user_a): enroll + auth-context assertions
81
+ -- ---------------------------------------------------------------------------
82
+ set local role authenticated;
83
+ select set_config('request.jwt.claim.role', 'authenticated', true);
84
+ select set_config('request.jwt.claim.sub', (select user_a::text from _lfix), true);
85
+
86
+ do $$
87
+ declare
88
+ v_first uuid;
89
+ v_second uuid;
90
+ begin
91
+ select s.agent_id into v_first from agent_board.self_enroll_human('Alice') s;
92
+ -- idempotent second call for the same uid returns the SAME agent
93
+ select s.agent_id into v_second from agent_board.self_enroll_human('Alice Again') s;
94
+ update _lfix set agent_a = v_first, agent_a2 = v_second;
95
+ end
96
+ $$;
97
+
98
+ -- 4 (part 1 of case 8): current_agent_id() resolves to the freshly-enrolled agent
99
+ select is(agent_board.current_agent_id(), (select agent_a from _lfix),
100
+ 'current_agent_id() (no agent_credential_id claim) resolves to the self-enrolled agent');
101
+ -- 5 (case 9): idempotent — second enroll returned the same agent_id
102
+ select is((select agent_a2 from _lfix), (select agent_a from _lfix),
103
+ 'second self_enroll_human for the same uid is idempotent (same agent_id)');
104
+
105
+ -- ---------------------------------------------------------------------------
106
+ -- Phase 2 (authenticated as user_b): enroll unverified user
107
+ -- ---------------------------------------------------------------------------
108
+ select set_config('request.jwt.claim.sub', (select user_b::text from _lfix), true);
109
+ select agent_board.self_enroll_human(null);
110
+
111
+ -- ---------------------------------------------------------------------------
112
+ -- Phase 3 (superuser): row-shape assertions without RLS in the way
113
+ -- ---------------------------------------------------------------------------
114
+ reset role;
115
+
116
+ -- 6/7/8 (case 4): the enrolled row is a human bound to the uid
117
+ select is((select kind::text from agent_board.agents where id = (select agent_a from _lfix)),
118
+ 'human', 'self-enrolled agent kind = human');
119
+ select is((select agent_type from agent_board.agents where id = (select agent_a from _lfix)),
120
+ 'human', 'self-enrolled agent agent_type = human');
121
+ select is((select auth_user_id from agent_board.agents where id = (select agent_a from _lfix)),
122
+ (select user_a from _lfix), 'self-enrolled agent auth_user_id = auth.uid()');
123
+ -- 9 (case 5): self-owned
124
+ select is((select owner_user_id from agent_board.agents where id = (select agent_a from _lfix)),
125
+ (select user_a from _lfix), 'self-enrolled agent owner_user_id = auth.uid() (self-owned)');
126
+ -- 10 (case 6): active
127
+ select is((select status from agent_board.agents where id = (select agent_a from _lfix)),
128
+ 'active', 'self-enrolled agent status = active');
129
+ -- 11 (case 7): verified user -> email_verified BOOLEAN true (never a plaintext email)
130
+ select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where id = (select agent_a from _lfix)),
131
+ 'true', 'verified user -> metadata.localauth.email_verified = true');
132
+ -- 12 (case 10): exactly one active row for the uid
133
+ select is((select count(*) from agent_board.agents where auth_user_id = (select user_a from _lfix) and status = 'active'),
134
+ 1::bigint, 'exactly one active agent per uid');
135
+ -- 13 (case 11): unverified user -> email_verified BOOLEAN false
136
+ select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where auth_user_id = (select user_b from _lfix)),
137
+ 'false', 'unverified user -> metadata.localauth.email_verified = false');
138
+ -- 14 (case 15 / L-4b): the DB backstop unique index exists
139
+ select ok(exists(
140
+ select 1 from pg_indexes
141
+ where schemaname = 'agent_board'
142
+ and indexname = 'idx_agent_board_agents_auth_user_id_unique'),
143
+ 'L-4b one-agent-per-real-auth-user backstop index exists');
144
+
145
+ -- ---------------------------------------------------------------------------
146
+ -- Phase 4: fail-closed cases
147
+ -- ---------------------------------------------------------------------------
148
+
149
+ -- 15 (case 12): authenticated role but NO subject (L-7: clear the sub GUC) -> 42501
150
+ set local role authenticated;
151
+ select set_config('request.jwt.claim.role', 'authenticated', true);
152
+ select set_config('request.jwt.claim.sub', '', true);
153
+ select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
154
+ null, 'unauthenticated (no auth.uid()) is rejected with 42501');
155
+
156
+ -- 16 (case 13): service-role / superuser (reset role) + cleared sub -> 42501
157
+ reset role;
158
+ select set_config('request.jwt.claim.sub', '', true);
159
+ select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
160
+ null, 'service-role / superuser (auth.uid() null) is rejected with 42501');
161
+
162
+ -- 17 (case 14): a pre-existing non-active identity cannot self-reactivate -> 42501
163
+ set local role authenticated;
164
+ select set_config('request.jwt.claim.role', 'authenticated', true);
165
+ select set_config('request.jwt.claim.sub', (select user_c::text from _lfix), true);
166
+ select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
167
+ null, 'a suspended (non-active) identity cannot self-reactivate (42501)');
168
+
169
+ select finish();
170
+
171
+ rollback;
@@ -0,0 +1,225 @@
1
+ -- ROOMUIAPI pgTAP: owner/admin-gated list_board_pending_memberships reader +
2
+ -- SECURITY INVOKER resolve_agent_handle resolver.
3
+ --
4
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
5
+ -- ('authenticated' => false) and request.jwt.claim.sub picks the acting agent.
6
+ -- Fixture style mirrors agent_board_roomdeleg.test.sql (ensure_github_user,
7
+ -- set_config role/sub, suspended-agent-as-null-actor trick).
8
+ --
9
+ -- Pending fixture rows (requested/invited/admin/board-B/dedup/demote-leave) are
10
+ -- seeded by DIRECT INSERT into board_memberships (runs as postgres, bypassing RLS +
11
+ -- the owner-gate coupling of request_join_board/invite_board_member). The owner
12
+ -- membership comes from create_board's ROOMOWN seed.
13
+ --
14
+ -- HAND-COUNTED plan: R1,R2,R3,R4,R5,R6,R7,R8,U-4,H1,H2,H3 = 12 assertions, one each.
15
+ create extension if not exists pgtap with schema extensions;
16
+ set search_path = extensions, public, agent_board;
17
+
18
+ begin;
19
+
20
+ select plan(12);
21
+
22
+ create temp table _roomuiapi_fixture (
23
+ board_a uuid,
24
+ board_b uuid,
25
+ alice uuid, -- creator, seeded owner of board A (and B)
26
+ admin uuid, -- role 'admin' on board A
27
+ plainmember uuid, -- role 'member', joined on board A (PRIVACY CRUX actor)
28
+ nomember uuid, -- active agent, no membership on board A
29
+ suspended uuid, -- suspended agent WITH a valid auth user (null actor)
30
+ demoteleave uuid, -- current row role=member/state=left (U-4 gate reject)
31
+ requester uuid, -- pending 'requested' on board A
32
+ invitee uuid, -- pending 'invited' on board A
33
+ dedup uuid, -- older 'requested' + newer 'joined' (R8: not returned)
34
+ boardb_requester uuid, -- pending 'requested' on board B (R7 isolation)
35
+ requester_handle text,
36
+ suspended_handle text,
37
+ alice_user uuid,
38
+ admin_user uuid,
39
+ plainmember_user uuid,
40
+ nomember_user uuid,
41
+ suspended_user uuid,
42
+ demoteleave_user uuid
43
+ ) on commit drop;
44
+
45
+ do $$
46
+ declare
47
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
48
+ v_alice_user uuid;
49
+ v_admin_user uuid;
50
+ v_plainmember_user uuid;
51
+ v_nomember_user uuid;
52
+ v_suspended_user uuid;
53
+ v_demoteleave_user uuid;
54
+ v_alice uuid := gen_random_uuid();
55
+ v_admin uuid := gen_random_uuid();
56
+ v_plainmember uuid := gen_random_uuid();
57
+ v_nomember uuid := gen_random_uuid();
58
+ v_suspended uuid := gen_random_uuid();
59
+ v_demoteleave uuid := gen_random_uuid();
60
+ v_requester uuid := gen_random_uuid();
61
+ v_invitee uuid := gen_random_uuid();
62
+ v_dedup uuid := gen_random_uuid();
63
+ v_boardb_requester uuid := gen_random_uuid();
64
+ v_board_a uuid;
65
+ v_board_b uuid;
66
+ begin
67
+ v_alice_user := public.ensure_github_user('ru-alice-' || v_suffix, 'ru-alice-' || v_suffix || '@example.test', 'ru-alice-' || v_suffix, 'RU Alice');
68
+ v_admin_user := public.ensure_github_user('ru-admin-' || v_suffix, 'ru-admin-' || v_suffix || '@example.test', 'ru-admin-' || v_suffix, 'RU Admin');
69
+ v_plainmember_user := public.ensure_github_user('ru-member-' || v_suffix, 'ru-member-' || v_suffix || '@example.test', 'ru-member-' || v_suffix, 'RU Member');
70
+ v_nomember_user := public.ensure_github_user('ru-nomember-' || v_suffix, 'ru-nomember-' || v_suffix || '@example.test', 'ru-nomember-' || v_suffix, 'RU NoMember');
71
+ v_suspended_user := public.ensure_github_user('ru-susp-' || v_suffix, 'ru-susp-' || v_suffix || '@example.test', 'ru-susp-' || v_suffix, 'RU Suspended');
72
+ v_demoteleave_user := public.ensure_github_user('ru-demo-' || v_suffix, 'ru-demo-' || v_suffix || '@example.test', 'ru-demo-' || v_suffix, 'RU DemoteLeave');
73
+
74
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
75
+ values
76
+ (v_alice, 'agent', 'ru-alice-' || v_suffix, 'RU Alice', 'active', v_alice_user, v_alice_user),
77
+ (v_admin, 'agent', 'ru-admin-' || v_suffix, 'RU Admin', 'active', v_admin_user, v_admin_user),
78
+ (v_plainmember, 'agent', 'ru-member-' || v_suffix, 'RU Member', 'active', v_plainmember_user, v_plainmember_user),
79
+ (v_nomember, 'agent', 'ru-nomember-' || v_suffix, 'RU NoMember', 'active', v_nomember_user, v_nomember_user),
80
+ -- Suspended agent WITH a valid auth user: current_agent_id() filters status='active'
81
+ -- so it resolves to null while auth.uid() still maps (fail-closed test).
82
+ (v_suspended, 'agent', 'ru-susp-' || v_suffix, 'RU Suspended', 'suspended', v_suspended_user, v_suspended_user),
83
+ (v_demoteleave, 'agent', 'ru-demo-' || v_suffix, 'RU DemoteLeave', 'active', v_demoteleave_user, v_demoteleave_user),
84
+ (v_requester, 'agent', 'ru-requester-' || v_suffix, 'RU Requester', 'active', null, null),
85
+ (v_invitee, 'agent', 'ru-invitee-' || v_suffix, 'RU Invitee', 'active', null, null),
86
+ (v_dedup, 'agent', 'ru-dedup-' || v_suffix, 'RU Dedup', 'active', null, null),
87
+ (v_boardb_requester, 'agent', 'ru-brequester-' || v_suffix, 'RU BRequester', 'active', null, null);
88
+
89
+ -- Alice creates both boards (service_role null-actor here -> supplied arg honored);
90
+ -- create_board seeds Alice's joined owner membership (ROOMOWN) on each.
91
+ select board_id into v_board_a
92
+ from agent_board.create_board(v_alice, 'roomuiapi-a-' || v_suffix, 'ROOMUIAPI Board A', 'reader test', '{}'::jsonb, 'private');
93
+ select board_id into v_board_b
94
+ from agent_board.create_board(v_alice, 'roomuiapi-b-' || v_suffix, 'ROOMUIAPI Board B', 'isolation', '{}'::jsonb, 'private');
95
+
96
+ -- Direct-insert the remaining board A memberships (bypasses RLS + owner-gate).
97
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
98
+ values
99
+ (v_board_a, v_admin, 'joined', v_alice, 'admin'),
100
+ (v_board_a, v_plainmember, 'joined', v_alice, 'member'),
101
+ (v_board_a, v_requester, 'requested', v_requester, 'member'),
102
+ (v_board_a, v_invitee, 'invited', v_alice, 'member');
103
+
104
+ -- U-4: demote-and-leave. Under in-place upsert an agent keeps ONE row; here the
105
+ -- current row is role=member/state=left, so the plain-exists owner/admin gate must
106
+ -- reject it (no stale owner/joined row lingers to falsely satisfy exists()).
107
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, left_at)
108
+ values
109
+ (v_board_a, v_demoteleave, 'left', v_alice, 'member', now());
110
+
111
+ -- R8 dedup: EXPLICIT distinct created_at (the whole suite runs in one begin; so
112
+ -- default now() is constant). Older 'requested' + newer 'joined' -> latest row is
113
+ -- 'joined' -> the reader's distinct-on-latest drops this agent from the pending set.
114
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, created_at)
115
+ values
116
+ (v_board_a, v_dedup, 'requested', v_dedup, 'member', now() - interval '1 hour'),
117
+ (v_board_a, v_dedup, 'joined', v_alice, 'member', now());
118
+
119
+ -- Board B: its own pending requester (R7 cross-board isolation).
120
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
121
+ values
122
+ (v_board_b, v_boardb_requester, 'requested', v_boardb_requester, 'member');
123
+
124
+ insert into _roomuiapi_fixture values
125
+ (v_board_a, v_board_b, v_alice, v_admin, v_plainmember, v_nomember, v_suspended,
126
+ v_demoteleave, v_requester, v_invitee, v_dedup, v_boardb_requester,
127
+ 'ru-requester-' || v_suffix, 'ru-susp-' || v_suffix,
128
+ v_alice_user, v_admin_user, v_plainmember_user, v_nomember_user, v_suspended_user, v_demoteleave_user);
129
+ end
130
+ $$;
131
+
132
+ select set_config('request.jwt.claim.role', 'authenticated', true);
133
+
134
+ -- === list_board_pending_memberships ===============================================
135
+ -- R1: owner (Alice) reads board A -> exactly the 2 pending rows (requested+invited).
136
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
137
+ select is(
138
+ (select count(*)::int from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture))),
139
+ 2, 'R1: the owner sees exactly the two pending memberships (requested + invited)');
140
+
141
+ -- R2: admin reads board A -> same 2 pending rows.
142
+ select set_config('request.jwt.claim.sub', (select admin_user from _roomuiapi_fixture)::text, true);
143
+ select is(
144
+ (select count(*)::int from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture))),
145
+ 2, 'R2: an admin sees the two pending memberships');
146
+
147
+ -- R3: the owner sees the requester's handle (content, not just count).
148
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
149
+ select is(
150
+ (select p.handle
151
+ from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
152
+ where p.agent_id = (select requester from _roomuiapi_fixture)),
153
+ (select requester_handle from _roomuiapi_fixture),
154
+ 'R3: the owner sees the requester''s handle in the pending row');
155
+
156
+ -- R4: a plain joined member is rejected -> 42501 (PRIVACY CRUX).
157
+ select set_config('request.jwt.claim.sub', (select plainmember_user from _roomuiapi_fixture)::text, true);
158
+ select throws_ok(
159
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
160
+ '42501', 'only a board owner or admin may list pending memberships',
161
+ 'R4: a plain joined member cannot enumerate pending memberships');
162
+
163
+ -- R5: a non-member (unmapped sub) is rejected -> 42501.
164
+ select set_config('request.jwt.claim.sub', gen_random_uuid()::text, true);
165
+ select throws_ok(
166
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
167
+ '42501', 'only a board owner or admin may list pending memberships',
168
+ 'R5: a non-member is rejected');
169
+
170
+ -- R6: a suspended principal (current_agent_id() null) is rejected -> 42501 (fail-closed).
171
+ select set_config('request.jwt.claim.sub', (select suspended_user from _roomuiapi_fixture)::text, true);
172
+ select throws_ok(
173
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
174
+ '42501', 'only a board owner or admin may list pending memberships',
175
+ 'R6: a suspended principal (null actor) is rejected (fail-closed)');
176
+
177
+ -- R7: owner of A reading board A does NOT see board B's requester (cross-board isolation).
178
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
179
+ select is(
180
+ (select count(*)::int
181
+ from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
182
+ where p.agent_id = (select boardb_requester from _roomuiapi_fixture)),
183
+ 0, 'R7: board B''s requester is absent from board A''s pending list (cross-board isolation)');
184
+
185
+ -- R8: the dedup agent (latest row 'joined', older 'requested') is NOT returned.
186
+ select is(
187
+ (select count(*)::int
188
+ from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
189
+ where p.agent_id = (select dedup from _roomuiapi_fixture)),
190
+ 0, 'R8: an agent whose latest row is joined (older requested) is not returned (dedup)');
191
+
192
+ -- U-4: demote-and-leave actor (current row role=member/state=left) is rejected -> 42501.
193
+ select set_config('request.jwt.claim.sub', (select demoteleave_user from _roomuiapi_fixture)::text, true);
194
+ select throws_ok(
195
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
196
+ '42501', 'only a board owner or admin may list pending memberships',
197
+ 'U-4: a demoted-and-left actor (latest row member/left) is rejected by the gate');
198
+
199
+ -- === resolve_agent_handle (SECURITY INVOKER) ======================================
200
+ -- H1: exact active handle -> the agent_id.
201
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
202
+ select is(
203
+ (select r.agent_id
204
+ from agent_board.resolve_agent_handle((select requester_handle from _roomuiapi_fixture)) r),
205
+ (select requester from _roomuiapi_fixture),
206
+ 'H1: the resolver maps an exact active handle to the agent_id');
207
+
208
+ -- H2: unknown handle -> 0 rows.
209
+ select is(
210
+ (select count(*)::int
211
+ from agent_board.resolve_agent_handle('ru-does-not-exist-' || gen_random_uuid()::text)),
212
+ 0, 'H2: an unknown handle resolves to zero rows');
213
+
214
+ -- H3: a suspended agent's handle -> 0 rows (status='active' filter).
215
+ select is(
216
+ (select count(*)::int
217
+ from agent_board.resolve_agent_handle((select suspended_handle from _roomuiapi_fixture))),
218
+ 0, 'H3: a suspended agent''s handle resolves to zero rows (active filter)');
219
+
220
+ select set_config('request.jwt.claim.role', '', true);
221
+ select set_config('request.jwt.claim.sub', '', true);
222
+
223
+ select finish();
224
+
225
+ rollback;