@consiliency/agent-board-schema 1.7.0 → 1.10.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.
@@ -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.10.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": 79,
50
+ "aggregateSha256": "6e82ca1943a3848b3f7c871e97cdf82387194ac361e965111785e236a0e850fd",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -732,6 +732,33 @@
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"
753
+ },
754
+ {
755
+ "order": 78,
756
+ "id": "20260728000100_agent_board_enroll_federated_human",
757
+ "filename": "20260728000100_agent_board_enroll_federated_human.sql",
758
+ "origin": "message-board-forward",
759
+ "sourceRef": "message-board",
760
+ "bytes": 15404,
761
+ "sha256": "5f300449c29ec05c2cd3c2cd7eca622ca41e8b32b8856c5bc0695b5750a196e7"
735
762
  }
736
763
  ],
737
764
  "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 + one FEDSESSION forward migrations (migrationCount = prior 78 + 1 = 79)', () => {
41
41
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
42
- expect(manifest.migrationCount).toBe(76);
42
+ expect(manifest.migrationCount).toBe(79);
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,17 @@ 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',
94
+ // FEDSESSION: service_role no-PoP federated-HUMAN provisioning (enroll_federated_human)
95
+ // backing the /portal-session Option A self-signed access-token minter. Additive
96
+ // (adds a nullable mode column to enrollment_federation_audit); schema stays 8.
97
+ '20260728000100_agent_board_enroll_federated_human.sql',
87
98
  ]) {
88
99
  expect(names.has(f)).toBe(true);
89
100
  }
@@ -76,10 +76,13 @@ select is(
76
76
  -- ("X is an aggregate function"); restrict to callable functions +
77
77
  -- procedures (the RPC surface this invariant is about).
78
78
  and p.prokind in ('f', 'p')
79
- and p.proname <> 'enroll_federated_identity'
79
+ -- The federation enroll RPCs WRITE the audit (enroll_federated_human added by
80
+ -- FEDSESSION, F-6); the invariant is that nothing on the send/session read path
81
+ -- touches it (asserted specifically in the next case).
82
+ and p.proname not in ('enroll_federated_identity', 'enroll_federated_human')
80
83
  ) d
81
84
  where lower(d.def) like '%enrollment_federation_audit%'),
82
- 0, 'no agent_board RPC other than enroll_federated_identity references enrollment_federation_audit');
85
+ 0, 'no agent_board RPC other than the federation enroll RPCs references enrollment_federation_audit');
83
86
  select is(
84
87
  (select count(*)::int from (
85
88
  select pg_get_functiondef(p.oid) as def
@@ -0,0 +1,314 @@
1
+ -- FEDSESSION pgTAP: enroll_federated_human() — service_role no-PoP federated-HUMAN
2
+ -- provisioning (Option A minter substrate).
3
+ --
4
+ -- Proves: the 5-arg signature + service_role-only privs; one provision mints a kind=human
5
+ -- SELF-owned agent (auth_user_id = owner_user_id = a synthetic uid) with a real auth.users
6
+ -- row + NO signing key + a mode='fedsession' audit row; the verified email lives ONLY in
7
+ -- auth.users.raw_user_meta_data (provenance-tagged, MERGED preserving slug) with just the
8
+ -- boolean email_verified in agents.metadata (no '@' anywhere in agents.metadata);
9
+ -- idempotency + L-4b (one agent per synthetic uid); current_agent_id() resolves the
10
+ -- no-credential session to the agent; the IDLINK shared-uid invariant against
11
+ -- enroll_federated_identity; and the provider/kind/subject/non-service rejection matrix.
12
+ -- Runs as postgres (service_role-equivalent), mirroring
13
+ -- agent_board_enroll_federated_identity.test.sql + agent_board_localauth.test.sql.
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(36);
21
+
22
+ -- ---------------------------------------------------------------------------
23
+ -- Phase 0 (superuser): function metadata + privileges
24
+ -- ---------------------------------------------------------------------------
25
+ -- 1
26
+ select has_function(
27
+ 'agent_board', 'enroll_federated_human',
28
+ array['text','text','text','jsonb','text'],
29
+ 'enroll_federated_human(text,text,text,jsonb,text) exists');
30
+ -- 2
31
+ select function_privs_are(
32
+ 'agent_board', 'enroll_federated_human', array['text','text','text','jsonb','text'],
33
+ 'service_role', array['EXECUTE'], 'service_role may EXECUTE enroll_federated_human');
34
+ -- 3
35
+ select function_privs_are(
36
+ 'agent_board', 'enroll_federated_human', array['text','text','text','jsonb','text'],
37
+ 'authenticated', array[]::text[], 'authenticated may NOT EXECUTE enroll_federated_human');
38
+ -- 4
39
+ select function_privs_are(
40
+ 'agent_board', 'enroll_federated_human', array['text','text','text','jsonb','text'],
41
+ 'anon', array[]::text[], 'anon may NOT EXECUTE enroll_federated_human');
42
+
43
+ -- ---------------------------------------------------------------------------
44
+ -- Fixtures + provision identity A (email_verified) and B (unverified)
45
+ -- ---------------------------------------------------------------------------
46
+ create temp table _f (
47
+ s text, iss text, aud text,
48
+ sub_a text, email_a text, sub_b text
49
+ ) on commit drop;
50
+ grant all on table _f to authenticated, service_role;
51
+
52
+ create temp table _r (
53
+ label text, agent_id uuid, auth_user_id uuid, owner_user_id uuid,
54
+ handle text, kind text, status text, created_at timestamptz
55
+ ) on commit drop;
56
+ grant all on table _r to authenticated, service_role;
57
+
58
+ do $$
59
+ declare
60
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
61
+ v_iss text; v_agent uuid; v_auth uuid; v_owner uuid; v_handle text;
62
+ v_kind text; v_status text; v_created timestamptz;
63
+ begin
64
+ v_iss := 'https://fed-hs-' || s || '.test';
65
+ insert into _f values (
66
+ s, v_iss, 'message-board',
67
+ 'portal-user-' || s, 'alice-' || s || '@example.test', 'portal-user2-' || s);
68
+
69
+ -- Approved provider that permits BOTH agent + human (agent needed for the IDLINK case).
70
+ insert into agent_board.identity_providers
71
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
72
+ values
73
+ (v_iss, 'message-board', true, true, '{board:send}', '{board:x}', '{agent,human}');
74
+
75
+ -- Provision A (verified email present in the claim).
76
+ select e.agent_id, e.auth_user_id, e.handle, e.kind, e.status, e.created_at
77
+ into v_agent, v_auth, v_handle, v_kind, v_status, v_created
78
+ from agent_board.enroll_federated_human(
79
+ v_iss, 'message-board', 'portal-user-' || s,
80
+ jsonb_build_object('sub', 'portal-user-' || s, 'email', 'alice-' || s || '@example.test',
81
+ 'email_verified', true, 'name', 'Alice ' || s),
82
+ 'Alice ' || s) e;
83
+ select a.owner_user_id into v_owner from agent_board.agents a where a.id = v_agent;
84
+ insert into _r values ('A', v_agent, v_auth, v_owner, v_handle, v_kind, v_status, v_created);
85
+
86
+ -- Provision B (DIFFERENT identity, email_verified=false).
87
+ select e.agent_id, e.auth_user_id, e.handle, e.kind, e.status, e.created_at
88
+ into v_agent, v_auth, v_handle, v_kind, v_status, v_created
89
+ from agent_board.enroll_federated_human(
90
+ v_iss, 'message-board', 'portal-user2-' || s,
91
+ jsonb_build_object('sub', 'portal-user2-' || s, 'email', 'bob-' || s || '@example.test',
92
+ 'email_verified', false),
93
+ null) e;
94
+ select a.owner_user_id into v_owner from agent_board.agents a where a.id = v_agent;
95
+ insert into _r values ('B', v_agent, v_auth, v_owner, v_handle, v_kind, v_status, v_created);
96
+ end
97
+ $$;
98
+
99
+ -- One provision mints a kind=human, self-owned, active agent -----------------------------
100
+ -- 5
101
+ select is((select count(*)::int from _r where label = 'A'), 1, 'provision A returned exactly one row');
102
+ -- 6
103
+ select is((select kind from _r where label = 'A'), 'human', 'agent A kind = human');
104
+ -- 7
105
+ select is(
106
+ (select a.agent_type from agent_board.agents a where a.id = (select agent_id from _r where label='A')),
107
+ 'human', 'agent A agent_type = human');
108
+ -- 8
109
+ select is((select status from _r where label = 'A'), 'active', 'agent A status = active');
110
+ -- 9
111
+ select is(
112
+ (select auth_user_id from _r where label='A'),
113
+ (select owner_user_id from _r where label='A'),
114
+ 'agent A is self-owned (auth_user_id = owner_user_id = the synthetic uid)');
115
+ -- 10
116
+ select is(
117
+ (select count(*)::int from auth.users u where u.id = (select auth_user_id from _r where label='A')),
118
+ 1, 'the synthetic auth.users row for agent A exists');
119
+ -- 11
120
+ select is(
121
+ (select handle from _r where label='A'),
122
+ 'fed-human-' || substr(
123
+ encode(extensions.digest(convert_to((select iss from _f) || '|' || (select sub_a from _f), 'UTF8'), 'sha256'), 'hex'),
124
+ 1, 32),
125
+ 'handle = fed-human-<sha256(issuer|subject)[:32]> (byte-identical to enroll_federated_identity)');
126
+
127
+ -- metadata carries the federation snapshot, boolean-only email, no PII --------------------
128
+ -- 12
129
+ select is(
130
+ (select a.metadata->'federation'->>'issuer' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
131
+ (select iss from _f), 'metadata.federation.issuer round-trips');
132
+ -- 13
133
+ select is(
134
+ (select a.metadata->'federation'->>'subject' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
135
+ (select sub_a from _f), 'metadata.federation.subject round-trips');
136
+ -- 14
137
+ select is(
138
+ (select a.metadata->'federation'->>'mode' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
139
+ 'fedsession', 'metadata.federation.mode = fedsession');
140
+ -- 15
141
+ select is(
142
+ (select a.metadata->'federation'->>'email_verified' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
143
+ 'true', 'verified identity -> metadata.federation.email_verified = true');
144
+ -- 16
145
+ select ok(
146
+ (select a.metadata::text not like '%@%' from agent_board.agents a where a.id=(select agent_id from _r where label='A')),
147
+ 'agent A metadata carries NO plaintext email/@ (PII stays out of fleet-readable metadata)');
148
+
149
+ -- verified email + slug live (merged) in auth.users.raw_user_meta_data --------------------
150
+ -- 17
151
+ select is(
152
+ (select u.raw_user_meta_data->>'fedsession_email' from auth.users u where u.id=(select auth_user_id from _r where label='A')),
153
+ (select email_a from _f), 'verified email is stored in auth.users.raw_user_meta_data');
154
+ -- 18
155
+ select is(
156
+ (select u.raw_user_meta_data->>'fedsession_email_provenance' from auth.users u where u.id=(select auth_user_id from _r where label='A')),
157
+ 'portal-claim-unverified', 'stored email is tagged provenance=portal-claim-unverified (F-12)');
158
+ -- 19 (F-4 merge preserved _ensure_agent_auth_user's slug)
159
+ select is(
160
+ (select u.raw_user_meta_data->>'slug' from auth.users u where u.id=(select auth_user_id from _r where label='A')),
161
+ (select handle from _r where label='A'),
162
+ 'raw_user_meta_data merge preserved the _ensure_agent_auth_user slug (no overwrite)');
163
+
164
+ -- NO signing key; audit row written ------------------------------------------------------
165
+ -- 20
166
+ select is(
167
+ (select count(*)::int from agent_board.agent_signing_keys k where k.agent_id=(select agent_id from _r where label='A')),
168
+ 0, 'agent A holds NO agent_signing_keys row (humans hold no key)');
169
+ -- 21
170
+ select is(
171
+ (select count(*)::int from agent_board.enrollment_federation_audit e
172
+ where e.agent_id=(select agent_id from _r where label='A') and e.mode='fedsession'),
173
+ 1, 'provision A wrote exactly one mode=fedsession audit row');
174
+
175
+ -- Identity B: unverified boolean + PII guard ---------------------------------------------
176
+ -- 22
177
+ select is(
178
+ (select a.metadata->'federation'->>'email_verified' from agent_board.agents a where a.id=(select agent_id from _r where label='B')),
179
+ 'false', 'unverified identity -> metadata.federation.email_verified = false');
180
+ -- 23
181
+ select ok(
182
+ (select a.metadata::text not like '%@%' from agent_board.agents a where a.id=(select agent_id from _r where label='B')),
183
+ 'agent B metadata carries NO plaintext email/@');
184
+
185
+ -- Idempotency + L-4b ---------------------------------------------------------------------
186
+ -- 24
187
+ select is(
188
+ (select e.agent_id from agent_board.enroll_federated_human(
189
+ (select iss from _f), 'message-board', (select sub_a from _f),
190
+ jsonb_build_object('sub', (select sub_a from _f), 'email_verified', true), 'Alice again') e),
191
+ (select agent_id from _r where label='A'),
192
+ 're-provision of the same (issuer,sub) is idempotent (same agent_id)');
193
+ -- 25
194
+ select is(
195
+ (select count(*)::int from agent_board.agents a where a.auth_user_id=(select auth_user_id from _r where label='A')),
196
+ 1, 'still exactly one agent on the synthetic uid (L-4b)');
197
+ -- 26
198
+ select is(
199
+ (select count(*)::int from agent_board.agents a
200
+ where a.owner_user_id=(select auth_user_id from _r where label='A') and a.kind <> 'human'),
201
+ 0, 'no non-human agent was ever created on this owner uid (kind is structural)');
202
+
203
+ -- IDLINK: enroll_federated_identity for the SAME (issuer,sub) shares the owner uid --------
204
+ create temp table _idlink (owner uuid) on commit drop;
205
+ do $$
206
+ declare
207
+ f record;
208
+ v_pub text; v_fp text; v_agent uuid;
209
+ begin
210
+ select * into f from _f;
211
+ -- A deterministic canonical 32-byte raw key + its raw-byte fingerprint (same construction
212
+ -- as the enroll_federated_identity B1 harness).
213
+ v_pub := rtrim(translate(encode(extensions.digest(convert_to('idlink-'||f.s,'UTF8'),'sha256'),'base64'),'+/','-_'),'=');
214
+ v_fp := 'sha256:' || rtrim(translate(encode(extensions.digest(decode(translate(v_pub,'-_','+/')||'=','base64'),'sha256'),'base64'),'+/','-_'),'=');
215
+ select e.agent_id into v_agent
216
+ from agent_board.enroll_federated_identity(
217
+ f.iss, 'message-board', f.sub_a, jsonb_build_object('sub', f.sub_a),
218
+ 'idlink-'||f.s, v_pub, 'raw_ed25519_base64url', v_fp,
219
+ true, null, 'agent', '{board:send}', '{}') e;
220
+ insert into _idlink select a.owner_user_id from agent_board.agents a where a.id = v_agent;
221
+ end
222
+ $$;
223
+ -- 27
224
+ select is(
225
+ (select owner from _idlink),
226
+ (select auth_user_id from _r where label='A'),
227
+ 'IDLINK: enroll_federated_identity for the same (issuer,sub) shares the fed-human owner uid');
228
+
229
+ -- current_agent_id() resolves the no-credential session to the fed-human agent -----------
230
+ set local role authenticated;
231
+ select set_config('request.jwt.claim.role', 'authenticated', true);
232
+ select set_config('request.jwt.claim.sub', (select auth_user_id::text from _r where label='A'), true);
233
+ -- 28
234
+ select is(
235
+ agent_board.current_agent_id(),
236
+ (select agent_id from _r where label='A'),
237
+ 'current_agent_id() (no agent_credential_id claim) resolves to the fed-human agent');
238
+ -- 29 (non-service caller: authenticated lacks EXECUTE -> 42501)
239
+ select throws_ok(
240
+ $$ select agent_board.enroll_federated_human('https://x.test','message-board','portal-x','{}'::jsonb,null) $$,
241
+ '42501', null, 'a non-service (authenticated) caller is rejected with 42501');
242
+ reset role;
243
+ -- Clear BOTH request.jwt GUCs: reset role alone leaves request.jwt.claim.role set (it
244
+ -- persists as is_local), so auth.role() would stay 'authenticated' and _is_service_role()
245
+ -- would wrongly reject the following superuser calls (they must run as service_role-equiv).
246
+ select set_config('request.jwt.claim.role', '', true);
247
+ select set_config('request.jwt.claim.sub', '', true);
248
+
249
+ -- Provider / kind / subject rejection matrix (superuser) ---------------------------------
250
+ -- 30 unknown (issuer, audience)
251
+ select throws_ok(
252
+ (select format($q$select agent_board.enroll_federated_human('https://nope-%s.test','message-board',%L,%L::jsonb,null)$q$,
253
+ s, sub_a, jsonb_build_object('sub', sub_a)::text) from _f),
254
+ '42501', null, 'unknown (issuer,audience) is rejected');
255
+ -- 31 null verified subject
256
+ select throws_ok(
257
+ (select format($q$select agent_board.enroll_federated_human(%L,'message-board',NULL,%L::jsonb,null)$q$,
258
+ iss, jsonb_build_object('x', 1)::text) from _f),
259
+ '22023', null, 'a null verified subject is rejected');
260
+ -- 32 subject contains a '|' (F-11 preimage-injection guard)
261
+ select throws_ok(
262
+ (select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
263
+ iss, 'portal|evil', jsonb_build_object('sub','portal|evil')::text) from _f),
264
+ '22023', null, 'a subject containing a pipe separator is rejected (F-11)');
265
+ -- 32b subject is email-shaped ('@') (F-11 PII backstop; subject is stored fleet-readable)
266
+ select throws_ok(
267
+ (select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
268
+ iss, 'evil@example.test', jsonb_build_object('sub','evil@example.test')::text) from _f),
269
+ '22023', null, 'an email-shaped (@) subject is rejected before it reaches fleet-readable metadata (F-11)');
270
+
271
+ -- provider present but NOT policy_approved -> 42501
272
+ do $$
273
+ declare s2 text := replace(substr(gen_random_uuid()::text,1,8),'-','');
274
+ begin
275
+ insert into agent_board.identity_providers
276
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
277
+ values ('https://fed-unapproved-'||s2||'.test','message-board', true, false, '{}','{}','{human}');
278
+ insert into _f(s,iss,aud,sub_a,email_a,sub_b)
279
+ values ('U'||s2, 'https://fed-unapproved-'||s2||'.test','message-board','portal-u-'||s2,null,null);
280
+ end $$;
281
+ -- 33
282
+ select throws_ok(
283
+ (select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
284
+ iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where s like 'U%'),
285
+ '42501', null, 'an un-approved (policy_approved=false) provider is rejected');
286
+
287
+ -- provider approved but allowed_agent_kinds EXCLUDES human (non-empty {agent}) -> 42501
288
+ do $$
289
+ declare s3 text := replace(substr(gen_random_uuid()::text,1,8),'-','');
290
+ begin
291
+ insert into agent_board.identity_providers
292
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
293
+ values ('https://fed-nohuman-'||s3||'.test','message-board', true, true, '{}','{}','{agent}');
294
+ insert into _f(s,iss,aud,sub_a,email_a,sub_b)
295
+ values ('N'||s3, 'https://fed-nohuman-'||s3||'.test','message-board','portal-n-'||s3,null,null);
296
+ end $$;
297
+ -- 34
298
+ select throws_ok(
299
+ (select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
300
+ iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where s like 'N%'),
301
+ '42501', null, 'a non-empty allowed_agent_kinds that excludes human is rejected');
302
+
303
+ -- Non-active identity cannot re-provision (no self-reactivation; F-13 lever) --------------
304
+ update agent_board.agents set status='suspended', updated_at=now()
305
+ where id = (select agent_id from _r where label='A');
306
+ -- 35
307
+ select throws_ok(
308
+ (select format($q$select agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
309
+ iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where sub_a like 'portal-user-%'),
310
+ '42501', null, 'a suspended (non-active) fed-human cannot re-provision a session (42501)');
311
+
312
+ select finish();
313
+
314
+ rollback;
@@ -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;