@consiliency/agent-board-schema 1.9.0 → 1.12.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,352 @@
1
+ -- GATENULL (#58) + MEMBSTATE (#75) pgTAP.
2
+ --
3
+ -- Proves: (a) an AUTHENTICATED principal with no ACTIVE agent no longer takes the service_role
4
+ -- null-actor bypass on join_board / request_join_board / invite_board_member /
5
+ -- resolve_board_request; (b) update_board_membership — which previously had NO authorization at
6
+ -- all — now refuses another agent's row without owner/admin and refuses self-promotion into a
7
+ -- board; and (c) NO regression for service_role, for an authenticated caller with an ACTIVE agent,
8
+ -- or for legitimate self-service transitions.
9
+ --
10
+ -- HAND-COUNTED plan: 4 metadata/overload + 2 service-path controls + 5 GATENULL/MEMBSTATE
11
+ -- class-2 rejections + 3 MEMBSTATE active-caller rejections + 5 no-regression
12
+ -- + 4 CR-added (kick-laundering, rank superiority, archived-board self-leave, delivery toggle)
13
+ -- + 4 NULLFIX (class-1 private-join blocked, open-join still ok, resolve-without-request blocked,
14
+ -- genuine resolve still ok) = 27 assertions.
15
+ --
16
+ -- CONTROLS (panel requirement): every authenticated block asserts _is_service_role() = false and
17
+ -- every service block asserts true. Without these, a fixture that forgets request.jwt.claim.role
18
+ -- silently runs the SERVICE branch and the "hole closed" assertions pass vacuously.
19
+ -- Uses the house SINGULAR GUCs (request.jwt.claim.sub / .role), per agent_board_roomdeleg.test.sql.
20
+
21
+ create extension if not exists pgtap with schema extensions;
22
+ set search_path = extensions, public, agent_board;
23
+
24
+ begin;
25
+
26
+ select plan(27);
27
+
28
+ -- ---------------------------------------------------------------------------
29
+ -- Phase 0: exactly one pg_proc row per name (a changed arg list would OVERLOAD, not replace)
30
+ -- ---------------------------------------------------------------------------
31
+ -- 1
32
+ select is((select count(*)::int from pg_proc p join pg_namespace n on n.oid = p.pronamespace
33
+ where n.nspname = 'agent_board' and p.proname = 'join_board'), 1,
34
+ 'join_board has exactly one overload');
35
+ -- 2
36
+ select is((select count(*)::int from pg_proc p join pg_namespace n on n.oid = p.pronamespace
37
+ where n.nspname = 'agent_board' and p.proname = 'invite_board_member'), 1,
38
+ 'invite_board_member has exactly one overload');
39
+ -- 3
40
+ select is((select count(*)::int from pg_proc p join pg_namespace n on n.oid = p.pronamespace
41
+ where n.nspname = 'agent_board' and p.proname = 'resolve_board_request'), 1,
42
+ 'resolve_board_request has exactly one overload');
43
+ -- 4
44
+ select is((select count(*)::int from pg_proc p join pg_namespace n on n.oid = p.pronamespace
45
+ where n.nspname = 'agent_board' and p.proname = 'update_board_membership'), 1,
46
+ 'update_board_membership has exactly one overload');
47
+
48
+ -- ---------------------------------------------------------------------------
49
+ -- Fixtures (as postgres == service_role-equivalent)
50
+ -- ---------------------------------------------------------------------------
51
+ create temp table _f (
52
+ board uuid, owner_agent uuid,
53
+ c2_uid uuid, -- authenticated principal with NO agent row (class 2)
54
+ atk uuid, atk_uid uuid, -- ACTIVE authenticated agent, not a member
55
+ member uuid, member_uid uuid, -- ACTIVE agent, joined member
56
+ victim uuid
57
+ ) on commit drop;
58
+ grant all on table _f to authenticated, service_role;
59
+
60
+ do $$
61
+ declare
62
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
63
+ v_board uuid; v_owner uuid; v_c2 uuid; v_atk uuid; v_atk_uid uuid;
64
+ v_mem uuid; v_mem_uid uuid; v_victim uuid;
65
+ begin
66
+ -- NOTE: kind must NOT be 'system' — sync_agent_principal_columns() nulls auth_user_id for
67
+ -- system agents, so current_agent_id() could never resolve this fixture.
68
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status)
69
+ values ('human', 'human'::agent_board.agent_kind, 'gn-owner-' || s, 'Owner', 'active')
70
+ returning id into v_owner;
71
+
72
+ insert into agent_board.boards(slug, name, created_by_agent_id, visibility, status)
73
+ values ('gn-board-' || s, 'GN Board', v_owner, 'invite_only', 'active')
74
+ returning id into v_board;
75
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
76
+ values (v_board, v_owner, 'owner', 'joined', v_owner);
77
+
78
+ -- class 2: an auth user with NO agents row at all
79
+ v_c2 := gen_random_uuid();
80
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
81
+ values (v_c2, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
82
+ 'gn-c2-' || s || '@example.test', now(), now());
83
+
84
+ -- an ACTIVE agent that is NOT a member of the board
85
+ v_atk_uid := gen_random_uuid();
86
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
87
+ values (v_atk_uid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
88
+ 'gn-atk-' || s || '@example.test', now(), now());
89
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
90
+ values ('human', 'human'::agent_board.agent_kind, 'gn-atk-' || s, 'Atk', 'active', v_atk_uid, v_atk_uid)
91
+ returning id into v_atk;
92
+
93
+ -- an ACTIVE joined member
94
+ v_mem_uid := gen_random_uuid();
95
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
96
+ values (v_mem_uid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
97
+ 'gn-mem-' || s || '@example.test', now(), now());
98
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
99
+ values ('human', 'human'::agent_board.agent_kind, 'gn-mem-' || s, 'Mem', 'active', v_mem_uid, v_mem_uid)
100
+ returning id into v_mem;
101
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
102
+ values (v_board, v_mem, 'member', 'joined', v_owner);
103
+
104
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status)
105
+ values ('human', 'human'::agent_board.agent_kind, 'gn-vic-' || s, 'Victim', 'active')
106
+ returning id into v_victim;
107
+
108
+ -- victim holds a genuine 'invited' row so the resolve no-regression assertion has a target
109
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
110
+ values (v_board, v_victim, 'member', 'invited', v_owner);
111
+
112
+ insert into _f values (v_board, v_owner, v_c2, v_atk, v_atk_uid, v_mem, v_mem_uid, v_victim);
113
+ end
114
+ $$;
115
+
116
+ -- 5 — service-path control: as postgres with no claims, the bypass is active.
117
+ select is(agent_board._is_service_role(), true,
118
+ 'CONTROL: bare postgres (no claims) is service_role-equivalent');
119
+
120
+ -- 6 — no-regression: service_role retains modify-on-behalf-of on update_board_membership.
121
+ select lives_ok(
122
+ $$ select agent_board.update_board_membership(
123
+ (select board from _f), (select member from _f), 'subscribed') $$,
124
+ 'NO-REGRESSION: service_role may still modify another agent''s membership');
125
+
126
+ -- ---------------------------------------------------------------------------
127
+ -- CLASS 2: authenticated principal with NO active agent (the GATENULL hole)
128
+ -- ---------------------------------------------------------------------------
129
+ select set_config('request.jwt.claim.sub', (select c2_uid::text from _f), true);
130
+ select set_config('request.jwt.claim.role', 'authenticated', true);
131
+
132
+ -- 7 — control: we really are on the authenticated path (else everything below passes vacuously).
133
+ select is(agent_board._is_service_role(), false,
134
+ 'CONTROL: class-2 principal is NOT service_role');
135
+ -- 8
136
+ select is(agent_board.current_agent_id(), null,
137
+ 'CONTROL: class-2 principal resolves to a NULL actor');
138
+ -- 9
139
+ select throws_ok(
140
+ $$ select agent_board.join_board((select board from _f), (select victim from _f), 'joined') $$,
141
+ '42501', null, 'GATENULL: class-2 cannot join_board (was: joined a private board with no invite)');
142
+ -- 10
143
+ select throws_ok(
144
+ $$ select agent_board.request_join_board((select board from _f), (select victim from _f)) $$,
145
+ '42501', null, 'GATENULL: class-2 cannot request_join_board on behalf of another agent');
146
+ -- 11
147
+ select throws_ok(
148
+ $$ select agent_board.invite_board_member((select board from _f), (select victim from _f), (select owner_agent from _f)) $$,
149
+ '42501', null, 'GATENULL: class-2 cannot invite_board_member');
150
+ -- 12
151
+ select throws_ok(
152
+ $$ select agent_board.resolve_board_request((select board from _f), (select victim from _f), 'joined', (select owner_agent from _f)) $$,
153
+ '42501', null, 'GATENULL: class-2 cannot resolve_board_request');
154
+ -- 13
155
+ select throws_ok(
156
+ $$ select agent_board.update_board_membership((select board from _f), (select member from _f), 'removed') $$,
157
+ '42501', null, 'MEMBSTATE: class-2 cannot update_board_membership');
158
+
159
+ -- ---------------------------------------------------------------------------
160
+ -- ACTIVE authenticated agent, NOT a board member (the MEMBSTATE hole — GATENULL cannot help)
161
+ -- ---------------------------------------------------------------------------
162
+ select set_config('request.jwt.claim.sub', (select atk_uid::text from _f), true);
163
+ select set_config('request.jwt.claim.role', 'authenticated', true);
164
+
165
+ -- 14 — control: an ACTIVE actor resolves, so these prove MEMBSTATE (not GATENULL) is doing the work.
166
+ select isnt(agent_board.current_agent_id(), null,
167
+ 'CONTROL: active non-member resolves to a NON-null actor');
168
+ -- 15
169
+ select throws_ok(
170
+ $$ select agent_board.update_board_membership((select board from _f), (select owner_agent from _f), 'removed') $$,
171
+ '42501', null, 'MEMBSTATE: active non-member cannot evict the board owner');
172
+
173
+ -- self-approval chain: create a legitimate 'requested' row, then try to self-promote.
174
+ select lives_ok(
175
+ $$ select agent_board.request_join_board((select board from _f), (select atk from _f)) $$,
176
+ 'NO-REGRESSION: an active agent may still request to join'); -- 16
177
+ -- 17
178
+ select throws_ok(
179
+ $$ select agent_board.update_board_membership((select board from _f), (select atk from _f), 'joined') $$,
180
+ '42501', null, 'MEMBSTATE: active agent cannot self-approve requested -> joined');
181
+ -- 18 — self-service withdraw must still work.
182
+ select lives_ok(
183
+ $$ select agent_board.update_board_membership((select board from _f), (select atk from _f), 'left') $$,
184
+ 'NO-REGRESSION: an agent may still leave (self-service transition preserved)');
185
+
186
+ -- ---------------------------------------------------------------------------
187
+ -- Board OWNER (active, owner membership) — authority must be unchanged
188
+ -- ---------------------------------------------------------------------------
189
+ reset role;
190
+ select set_config('request.jwt.claim.sub', '', true);
191
+ select set_config('request.jwt.claim.role', '', true);
192
+ -- Give the owner an auth identity so it can act on the authenticated path.
193
+ do $$
194
+ declare v_uid uuid := gen_random_uuid();
195
+ begin
196
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
197
+ values (v_uid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
198
+ 'gn-own-' || substr(v_uid::text, 1, 8) || '@example.test', now(), now());
199
+ update agent_board.agents set auth_user_id = v_uid, owner_user_id = v_uid
200
+ where id = (select owner_agent from _f);
201
+ perform set_config('request.jwt.claim.sub', v_uid::text, true);
202
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
203
+ end
204
+ $$;
205
+
206
+ -- 19
207
+ select lives_ok(
208
+ $$ select agent_board.update_board_membership((select board from _f), (select member from _f), 'removed') $$,
209
+ 'NO-REGRESSION: an active board OWNER may still change another agent''s membership');
210
+
211
+
212
+ -- ---------------------------------------------------------------------------
213
+ -- CR-added coverage: kick-laundering, rank superiority, archived board, toggle
214
+ -- ---------------------------------------------------------------------------
215
+ reset role;
216
+ select set_config('request.jwt.claim.sub', '', true);
217
+ select set_config('request.jwt.claim.role', '', true);
218
+
219
+ create temp table _f2 (
220
+ archived_board uuid, admin_agent uuid, admin_uid uuid, kicked uuid, kicked_uid uuid
221
+ ) on commit drop;
222
+ grant all on table _f2 to authenticated, service_role;
223
+
224
+ do $$
225
+ declare
226
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
227
+ v_arch uuid; v_admin uuid; v_admin_uid uuid; v_kicked uuid; v_kicked_uid uuid; v_owner uuid; v_board uuid;
228
+ begin
229
+ select owner_agent, board into v_owner, v_board from _f;
230
+
231
+ -- an ARCHIVED board on which a member still holds a joined row
232
+ insert into agent_board.boards(slug, name, created_by_agent_id, visibility, status)
233
+ values ('gn-arch-' || s, 'GN Archived', v_owner, 'invite_only', 'archived') returning id into v_arch;
234
+
235
+ -- an ADMIN on the main board (lower rank than owner)
236
+ v_admin_uid := gen_random_uuid();
237
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
238
+ values (v_admin_uid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
239
+ 'gn-adm-' || s || '@example.test', now(), now());
240
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
241
+ values ('human', 'human'::agent_board.agent_kind, 'gn-adm-' || s, 'Adm', 'active', v_admin_uid, v_admin_uid)
242
+ returning id into v_admin;
243
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
244
+ values (v_board, v_admin, 'admin', 'joined', v_owner);
245
+ -- the admin also holds a joined row on the archived board (to test self-leave there)
246
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
247
+ values (v_arch, v_admin, 'member', 'joined', v_owner);
248
+
249
+ -- a KICKED agent (membership_state='removed') on the main board
250
+ v_kicked_uid := gen_random_uuid();
251
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
252
+ values (v_kicked_uid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
253
+ 'gn-kick-' || s || '@example.test', now(), now());
254
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
255
+ values ('human', 'human'::agent_board.agent_kind, 'gn-kick-' || s, 'Kicked', 'active', v_kicked_uid, v_kicked_uid)
256
+ returning id into v_kicked;
257
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id, left_at)
258
+ values (v_board, v_kicked, 'member', 'removed', v_owner, now());
259
+
260
+ insert into _f2 values (v_arch, v_admin, v_admin_uid, v_kicked, v_kicked_uid);
261
+ end
262
+ $$;
263
+
264
+ -- 20 — a KICKED agent must not launder 'removed' into 'left' (would erase the R-LINK-7 marker).
265
+ select set_config('request.jwt.claim.sub', (select kicked_uid::text from _f2), true);
266
+ select set_config('request.jwt.claim.role', 'authenticated', true);
267
+ select throws_ok(
268
+ $$ select agent_board.update_board_membership((select board from _f), (select kicked from _f2), 'left') $$,
269
+ '42501', null, 'MEMBSTATE: a removed agent cannot self-set left (no kick laundering into invite re-entry)');
270
+
271
+ -- 21 — an ADMIN must not evict the OWNER (strict rank superiority).
272
+ select set_config('request.jwt.claim.sub', (select admin_uid::text from _f2), true);
273
+ select set_config('request.jwt.claim.role', 'authenticated', true);
274
+ select throws_ok(
275
+ $$ select agent_board.update_board_membership((select board from _f), (select owner_agent from _f), 'removed') $$,
276
+ '42501', null, 'MEMBSTATE: an admin cannot evict the board owner (rank superiority enforced)');
277
+
278
+ -- 22 — self delivery-preference toggle joined -> subscribed stays permitted.
279
+ select lives_ok(
280
+ $$ select agent_board.update_board_membership((select board from _f), (select admin_agent from _f2), 'subscribed') $$,
281
+ 'NO-REGRESSION: an agent may still toggle its own joined -> subscribed delivery preference');
282
+
283
+ -- 23 — self-leave must still work on an ARCHIVED board (the self branch takes no board lookup,
284
+ -- so leave_board keeps working after a board is archived).
285
+ select lives_ok(
286
+ $$ select agent_board.update_board_membership((select archived_board from _f2), (select admin_agent from _f2), 'left') $$,
287
+ 'NO-REGRESSION: an agent may still leave an ARCHIVED board (leave_board delegates here)');
288
+
289
+
290
+ -- ---------------------------------------------------------------------------
291
+ -- NULLFIX: the CR found the REAL root cause of the private-board join — a NULL-falsy
292
+ -- guard, reachable by an ACTIVE agent (which the GATENULL null-actor guard cannot touch).
293
+ -- These four are the decisive regression tests: 24/26 FAIL without the coalesce() fixes.
294
+ -- ---------------------------------------------------------------------------
295
+ reset role;
296
+ select set_config('request.jwt.claim.sub', '', true);
297
+ select set_config('request.jwt.claim.role', '', true);
298
+
299
+ create temp table _f3 (openb uuid, privb uuid, a1 uuid, a1_uid uuid, nopend uuid, owner_uid uuid) on commit drop;
300
+ grant all on table _f3 to authenticated, service_role;
301
+
302
+ do $$
303
+ declare s text := replace(substr(gen_random_uuid()::text,1,8),'-',''); v_o uuid; v_open uuid; v_priv uuid; v_a uuid; v_uid uuid; v_np uuid; v_ouid uuid;
304
+ begin
305
+ select owner_agent into v_o from _f;
306
+ insert into agent_board.boards(slug,name,created_by_agent_id,visibility,status)
307
+ values('nf-open-'||s,'NF Open',v_o,'open','active') returning id into v_open;
308
+ insert into agent_board.boards(slug,name,created_by_agent_id,visibility,status)
309
+ values('nf-priv-'||s,'NF Priv',v_o,'private','active') returning id into v_priv;
310
+ insert into agent_board.board_memberships(board_id,agent_id,role,membership_state,created_by_agent_id)
311
+ values(v_priv,v_o,'owner','joined',v_o);
312
+ v_uid := gen_random_uuid();
313
+ insert into auth.users(id,instance_id,aud,role,email,created_at,updated_at)
314
+ values(v_uid,'00000000-0000-0000-0000-000000000000','authenticated','authenticated','nf-'||s||'@example.test',now(),now());
315
+ insert into agent_board.agents(agent_type,kind,handle,display_name,status,auth_user_id,owner_user_id)
316
+ values('human','human'::agent_board.agent_kind,'nf-a-'||s,'A1','active',v_uid,v_uid) returning id into v_a;
317
+ insert into agent_board.agents(agent_type,kind,handle,display_name,status)
318
+ values('human','human'::agent_board.agent_kind,'nf-np-'||s,'NoPend','active') returning id into v_np;
319
+ select auth_user_id into v_ouid from agent_board.agents where id = v_o;
320
+ insert into _f3 values(v_open,v_priv,v_a,v_uid,v_np,v_ouid);
321
+ end
322
+ $$;
323
+
324
+ select set_config('request.jwt.claim.sub', (select a1_uid::text from _f3), true);
325
+ select set_config('request.jwt.claim.role', 'authenticated', true);
326
+
327
+ -- 24 — an ACTIVE agent (actor resolves) must NOT self-join a PRIVATE board.
328
+ select throws_ok(
329
+ $$ select agent_board.join_board((select privb from _f3), (select a1 from _f3), 'joined') $$,
330
+ 'P0002', null, 'NULLFIX: an ACTIVE agent cannot self-join a private board (NULL-falsy guard closed)');
331
+ -- 25 — joining an OPEN board still works.
332
+ select lives_ok(
333
+ $$ select agent_board.join_board((select openb from _f3), (select a1 from _f3), 'joined') $$,
334
+ 'NO-REGRESSION: an active agent may still join an OPEN board');
335
+ -- 26 — resolving an agent that never requested/was invited must fail. Runs as the board OWNER on
336
+ -- purpose: as a non-member the authz check would raise 42501 first and the pending-state guard
337
+ -- (the thing NULLFIX actually changed) would never be reached.
338
+ select set_config('request.jwt.claim.sub', (select owner_uid::text from _f3), true);
339
+ select set_config('request.jwt.claim.role', 'authenticated', true);
340
+ select throws_ok(
341
+ $$ select agent_board.resolve_board_request((select board from _f), (select nopend from _f3), 'joined', (select owner_agent from _f)) $$,
342
+ 'P0002', null, 'NULLFIX: cannot resolve an agent with no pending request straight to joined');
343
+ -- 27 — resolving a GENUINE request still works (service path).
344
+ reset role;
345
+ select set_config('request.jwt.claim.sub', '', true);
346
+ select set_config('request.jwt.claim.role', '', true);
347
+ select lives_ok(
348
+ $$ select agent_board.resolve_board_request((select board from _f), (select victim from _f), 'joined', (select owner_agent from _f)) $$,
349
+ 'NO-REGRESSION: a genuine invited/requested membership still resolves');
350
+
351
+ select * from finish();
352
+ rollback;