@consiliency/agent-board-schema 1.5.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.
@@ -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.5.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.5.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": 73,
50
- "aggregateSha256": "a43737edf382d8aee59069d6a9aec9e39fce17b31f092a3d7fcd96303b6f70e9",
49
+ "migrationCount": 78,
50
+ "aggregateSha256": "4b6a5435c11341549636dfec4273c5bdc5773be23f8b8667ea816927e9412f87",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -705,6 +705,51 @@
705
705
  "sourceRef": "message-board",
706
706
  "bytes": 3716,
707
707
  "sha256": "40c9f585af16211fe9ab1d35efca6787e49b31afc7c85f208da22b9566a65575"
708
+ },
709
+ {
710
+ "order": 73,
711
+ "id": "20260724000100_agent_board_roomlink_invite_tokens",
712
+ "filename": "20260724000100_agent_board_roomlink_invite_tokens.sql",
713
+ "origin": "message-board-forward",
714
+ "sourceRef": "message-board",
715
+ "bytes": 22491,
716
+ "sha256": "98870e44a71eb648cfefd550f32eb30cc7a25e65ae1a7aa3852a3f9234eb98e2"
717
+ },
718
+ {
719
+ "order": 74,
720
+ "id": "20260724000200_agent_board_roomlink_enroll_binding",
721
+ "filename": "20260724000200_agent_board_roomlink_enroll_binding.sql",
722
+ "origin": "message-board-forward",
723
+ "sourceRef": "message-board",
724
+ "bytes": 12092,
725
+ "sha256": "60e289d99a212ff63cb99ea275b545f7e9c15ad8cc4e0ba42c98492bae342adc"
726
+ },
727
+ {
728
+ "order": 75,
729
+ "id": "20260725000100_agent_board_roomdeleg_role_gate",
730
+ "filename": "20260725000100_agent_board_roomdeleg_role_gate.sql",
731
+ "origin": "message-board-forward",
732
+ "sourceRef": "message-board",
733
+ "bytes": 9521,
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"
708
753
  }
709
754
  ],
710
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 forward migrations (migrationCount = prior 71 + 2 = 73)', () => {
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(73);
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,
@@ -78,6 +78,19 @@ describe('manifest integrity', () => {
78
78
  // bootstrap; grant_board_role activated for authenticated). schema stays 8.
79
79
  '20260723000100_agent_board_roomown_owner_seed.sql',
80
80
  '20260723000200_agent_board_roomown_grant_role_live.sql',
81
+ // ROOMLINK: shareable board invite links + enroll-on-redeem binding. schema stays 8.
82
+ '20260724000100_agent_board_roomlink_invite_tokens.sql',
83
+ '20260724000200_agent_board_roomlink_enroll_binding.sql',
84
+ // ROOMDELEG Lane A: owner-gated set_board_role_gate + creator-identity gate-bypass
85
+ // fix on invite_board_member/resolve_board_request. schema stays 8.
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',
81
94
  ]) {
82
95
  expect(names.has(f)).toBe(true);
83
96
  }
@@ -15,11 +15,11 @@ begin;
15
15
 
16
16
  select plan(28);
17
17
 
18
- -- New 13-arg signature exists (the old 8-arg was dropped by 20260719001100). --------
18
+ -- New 14-arg signature exists (ROOMLINK 20260724000200 added a trailing invite code). -
19
19
  select has_function(
20
20
  'agent_board', 'enroll_federated_identity',
21
- array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]'],
22
- 'enroll_federated_identity exists with the reworked 13-arg PoP signature');
21
+ array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]','text'],
22
+ 'enroll_federated_identity exists with the ROOMLINK-extended 14-arg PoP signature');
23
23
  select hasnt_function(
24
24
  'agent_board', 'enroll_federated_identity',
25
25
  array['text','text','text','jsonb','text','text','text[]','text[]'],
@@ -92,12 +92,12 @@ select is(
92
92
  and not policy_approved),
93
93
  1, 'the mock issuer row is untouched (present, supported, inert)');
94
94
 
95
- -- enroll_federated_identity is service_role-only (reworked 13-arg signature) ----
95
+ -- enroll_federated_identity is service_role-only (ROOMLINK-extended 14-arg signature)
96
96
  select function_privs_are(
97
97
  'agent_board', 'enroll_federated_identity',
98
- array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]'],
98
+ array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]','text'],
99
99
  'authenticated', array[]::text[],
100
- 'reworked enroll_federated_identity execute is revoked from authenticated');
100
+ 'ROOMLINK-extended enroll_federated_identity execute is revoked from authenticated');
101
101
 
102
102
  select finish();
103
103
 
@@ -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,167 @@
1
+ -- ROOMDELEG Lane A pgTAP: owner-gated set_board_role_gate + R-DELEG-1 creator-identity
2
+ -- gate-bypass fix on invite_board_member / resolve_board_request.
3
+ --
4
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
5
+ -- (''/service_role => true, 'authenticated' => false) and request.jwt.claim.sub picks
6
+ -- the acting agent. Fixture style mirrors agent_board_roomown.test.sql.
7
+ create extension if not exists pgtap with schema extensions;
8
+ set search_path = extensions, public, agent_board;
9
+
10
+ begin;
11
+
12
+ select plan(11);
13
+
14
+ create temp table _roomdeleg_fixture (
15
+ board uuid,
16
+ alice uuid, -- creator, seeded owner
17
+ bob uuid, -- role 'member'
18
+ target uuid, -- invite target (existing agent, no membership needed)
19
+ requester uuid, -- has a pending 'requested' membership (resolve target)
20
+ nomember uuid, -- existing agent, no membership on the board
21
+ suspended uuid, -- suspended agent WITH a valid auth user
22
+ alice_user uuid,
23
+ bob_user uuid,
24
+ suspended_user uuid
25
+ ) on commit drop;
26
+
27
+ do $$
28
+ declare
29
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
30
+ v_alice_user uuid;
31
+ v_bob_user uuid;
32
+ v_suspended_user uuid;
33
+ v_alice uuid := gen_random_uuid();
34
+ v_bob uuid := gen_random_uuid();
35
+ v_target uuid := gen_random_uuid();
36
+ v_requester uuid := gen_random_uuid();
37
+ v_nomember uuid := gen_random_uuid();
38
+ v_suspended uuid := gen_random_uuid();
39
+ v_board uuid;
40
+ v_slug text := 'roomdeleg-board-' || v_suffix;
41
+ begin
42
+ v_alice_user := public.ensure_github_user('rd-alice-' || v_suffix, 'rd-alice-' || v_suffix || '@example.test', 'rd-alice-' || v_suffix, 'RD Alice');
43
+ v_bob_user := public.ensure_github_user('rd-bob-' || v_suffix, 'rd-bob-' || v_suffix || '@example.test', 'rd-bob-' || v_suffix, 'RD Bob');
44
+ v_suspended_user := public.ensure_github_user('rd-susp-' || v_suffix, 'rd-susp-' || v_suffix || '@example.test', 'rd-susp-' || v_suffix, 'RD Suspended');
45
+
46
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
47
+ values
48
+ (v_alice, 'agent', 'rd-alice-' || v_suffix, 'RD Alice', 'active', v_alice_user, v_alice_user),
49
+ (v_bob, 'agent', 'rd-bob-' || v_suffix, 'RD Bob', 'active', v_bob_user, v_bob_user),
50
+ -- Suspended agent WITH a valid auth user: current_agent_id() filters status='active'
51
+ -- so it resolves to null while auth.uid() still maps.
52
+ (v_suspended, 'agent', 'rd-susp-' || v_suffix, 'RD Suspended', 'suspended', v_suspended_user, v_suspended_user),
53
+ (v_target, 'agent', 'rd-target-' || v_suffix, 'RD Target', 'active', null, null),
54
+ (v_requester, 'agent', 'rd-requester-' || v_suffix, 'RD Requester', 'active', null, null),
55
+ (v_nomember, 'agent', 'rd-nomember-' || v_suffix, 'RD NoMember', 'active', null, null);
56
+
57
+ -- Alice creates board (service_role null-actor here -> supplied arg honored):
58
+ -- create_board seeds Alice's joined owner membership (ROOMOWN).
59
+ select board_id into v_board
60
+ from agent_board.create_board(v_alice, v_slug, 'ROOMDELEG Board', 'roomdeleg test', '{}'::jsonb, 'private');
61
+
62
+ -- Bob is a genuine NON-creator member (role member). Requester has a pending
63
+ -- 'requested' membership so resolve_board_request reaches the gate check.
64
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
65
+ values
66
+ (v_board, v_bob, 'joined', v_alice, 'member'),
67
+ (v_board, v_requester, 'requested', v_alice, 'member');
68
+
69
+ insert into _roomdeleg_fixture values
70
+ (v_board, v_alice, v_bob, v_target, v_requester, v_nomember, v_suspended,
71
+ v_alice_user, v_bob_user, v_suspended_user);
72
+ end
73
+ $$;
74
+
75
+ select set_config('request.jwt.claim.role', 'authenticated', true);
76
+
77
+ -- === Part A: set_board_role_gate authz (gate starts false) ========================
78
+ -- 1. non-owner member (Bob) cannot flip the gate -> 42501 (gate unchanged) ----------
79
+ select set_config('request.jwt.claim.sub', (select bob_user from _roomdeleg_fixture)::text, true);
80
+ select throws_ok(
81
+ (select format($q$select agent_board.set_board_role_gate(%L::uuid, true)$q$, board) from _roomdeleg_fixture),
82
+ '42501', 'only a board owner may set the role gate',
83
+ 'set_board_role_gate: a non-owner member is rejected');
84
+
85
+ -- 2. non-member cannot flip the gate -> 42501 --------------------------------------
86
+ select set_config('request.jwt.claim.sub', gen_random_uuid()::text, true);
87
+ select throws_ok(
88
+ (select format($q$select agent_board.set_board_role_gate(%L::uuid, true)$q$, board) from _roomdeleg_fixture),
89
+ '42501', 'only a board owner may set the role gate',
90
+ 'set_board_role_gate: a non-member (unmapped sub) is rejected');
91
+
92
+ -- 3. suspended principal (current_agent_id() null) cannot flip the gate -> 42501 ----
93
+ select set_config('request.jwt.claim.sub', (select suspended_user from _roomdeleg_fixture)::text, true);
94
+ select throws_ok(
95
+ (select format($q$select agent_board.set_board_role_gate(%L::uuid, true)$q$, board) from _roomdeleg_fixture),
96
+ '42501', 'only a board owner may set the role gate',
97
+ 'set_board_role_gate: a suspended principal (null actor) is rejected');
98
+
99
+ -- 4. owner (Alice) flips false->true (pinned to a single assertion) -----------------
100
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomdeleg_fixture)::text, true);
101
+ select is(
102
+ (select role_gate_enforced from agent_board.set_board_role_gate((select board from _roomdeleg_fixture), true)),
103
+ true, 'set_board_role_gate: the owner flips role_gate_enforced false->true');
104
+
105
+ -- === Part B: the flipped gate restricts invite/resolve (gate now ON) ==============
106
+ -- 5. gate ON -> non-owner/admin member (Bob) invite rejected -> 42501 ---------------
107
+ select set_config('request.jwt.claim.sub', (select bob_user from _roomdeleg_fixture)::text, true);
108
+ select throws_ok(
109
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
110
+ '42501', 'board invite requires owner or admin role',
111
+ 'gate ON: a role-member actor is denied invite');
112
+
113
+ -- 6. gate ON -> non-owner/admin member (Bob) resolve rejected -> 42501 --------------
114
+ select throws_ok(
115
+ (select format($q$select agent_board.resolve_board_request(%L::uuid, %L::uuid, 'joined', %L::uuid, '{}'::jsonb)$q$, board, requester, alice) from _roomdeleg_fixture),
116
+ '42501', 'board request resolution requires owner or admin role',
117
+ 'gate ON: a role-member actor is denied resolve');
118
+
119
+ -- === Part C: R-DELEG-1 creator-identity exemption dropped under an enforced gate ===
120
+ -- 7. creator-who-is-still-owner (Alice) + gate ON -> invite still succeeds ----------
121
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomdeleg_fixture)::text, true);
122
+ select lives_ok(
123
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
124
+ 'gate ON: the creator who still holds owner membership may invite');
125
+
126
+ -- Demote the creator to member (bare statement, not an assertion). created_by is
127
+ -- immutable, so this models a creator who has lost owner authority.
128
+ update agent_board.board_memberships bm
129
+ set role = 'member'
130
+ from _roomdeleg_fixture f
131
+ where bm.board_id = f.board and bm.agent_id = f.alice;
132
+
133
+ -- 8. demoted creator + gate ON -> invite rejected -> 42501 (R-DELEG-1) --------------
134
+ select throws_ok(
135
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
136
+ '42501', 'board invite requires owner or admin role',
137
+ 'gate ON: a DEMOTED creator no longer bypasses invite (creator-identity gate closed)');
138
+
139
+ -- 9. demoted creator + gate ON -> resolve rejected -> 42501 (R-DELEG-1) -------------
140
+ select throws_ok(
141
+ (select format($q$select agent_board.resolve_board_request(%L::uuid, %L::uuid, 'joined', %L::uuid, '{}'::jsonb)$q$, board, requester, alice) from _roomdeleg_fixture),
142
+ '42501', 'board request resolution requires owner or admin role',
143
+ 'gate ON: a DEMOTED creator no longer bypasses resolve (creator-identity gate closed)');
144
+
145
+ -- === Part D: gate OFF regression (creator/any active member bypass unchanged) ======
146
+ -- Flip the gate back OFF via a direct update (demoted Alice can no longer use the RPC).
147
+ update agent_board.boards b
148
+ set role_gate_enforced = false
149
+ where b.id = (select board from _roomdeleg_fixture);
150
+
151
+ -- 10. gate OFF + demoted creator -> invite still bypasses (creator exemption intact) -
152
+ select lives_ok(
153
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
154
+ 'gate OFF: the demoted creator still bypasses (gate-OFF behavior unchanged)');
155
+
156
+ -- 11. gate OFF + any active member (Bob) -> invite succeeds -------------------------
157
+ select set_config('request.jwt.claim.sub', (select bob_user from _roomdeleg_fixture)::text, true);
158
+ select lives_ok(
159
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid, '{}'::jsonb)$q$, board, target, alice) from _roomdeleg_fixture),
160
+ 'gate OFF: any active member may invite (any-active-member behavior preserved)');
161
+
162
+ select set_config('request.jwt.claim.role', '', true);
163
+ select set_config('request.jwt.claim.sub', '', true);
164
+
165
+ select finish();
166
+
167
+ rollback;