@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.
@@ -292,6 +292,26 @@
292
292
  {
293
293
  "id": "20260723000200_agent_board_roomown_grant_role_live",
294
294
  "sha256": "40c9f585af16211fe9ab1d35efca6787e49b31afc7c85f208da22b9566a65575"
295
+ },
296
+ {
297
+ "id": "20260724000100_agent_board_roomlink_invite_tokens",
298
+ "sha256": "98870e44a71eb648cfefd550f32eb30cc7a25e65ae1a7aa3852a3f9234eb98e2"
299
+ },
300
+ {
301
+ "id": "20260724000200_agent_board_roomlink_enroll_binding",
302
+ "sha256": "60e289d99a212ff63cb99ea275b545f7e9c15ad8cc4e0ba42c98492bae342adc"
303
+ },
304
+ {
305
+ "id": "20260725000100_agent_board_roomdeleg_role_gate",
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"
295
315
  }
296
316
  ]
297
317
  }
@@ -0,0 +1,490 @@
1
+ -- ROOMLINK Lane A: shareable board invite links (tokens + ledger + RPCs).
2
+ --
3
+ -- Adds a board invite-link primitive: an owner/admin mints a single opaque code
4
+ -- (256-bit, base64url) that carries a PRESET board role + membership_state, an
5
+ -- optional use cap, and a TTL. Any agent that later presents the code redeems it
6
+ -- into a bounded board membership. This is the invite-link analogue of ROOMOWN's
7
+ -- grant_board_role: authority is grantor-bounded at MINT and re-bounded AGAIN at
8
+ -- REDEEM against the creator's CURRENT rank + the board ceiling, so a stale
9
+ -- admin-link auto-degrades if the creator is later demoted or suspended.
10
+ --
11
+ -- Security model (panel-reviewed):
12
+ -- * The code is the ONLY secret. RLS exposes board_invite_tokens rows for SELECT
13
+ -- only to their creator; redeemers/members never read the code. All four RPCs
14
+ -- are SECURITY DEFINER (authenticated holds NO table write grant), each pinned
15
+ -- to `set search_path = agent_board, public`.
16
+ -- * Redemption is grantor-bounded (re-checks _grant_within_ceiling against the
17
+ -- creator's live authority), suspension-contained (creator must be active),
18
+ -- and refuses a previously 'removed' (kicked) agent — the active partial-unique
19
+ -- index would otherwise let a fresh 'joined' row bypass the revocation.
20
+ -- * The membership write is a SINGLE atomic rank-max upsert: redemption never
21
+ -- DOWNGRADES a higher existing role and is race-safe across concurrent tokens
22
+ -- (the token row is locked FOR UPDATE before the exhaustion check; the ledger's
23
+ -- unique(invite_id, agent_id) makes replays idempotent, never inflating
24
+ -- uses_count). rank-max lifts ONLY the role, not membership_state — intended
25
+ -- for v1.
26
+ -- * _redeem_invite_for_agent is the shared internal core (service_role-only),
27
+ -- called by the public redeem_invite_link (actor = current_agent_id()) and by
28
+ -- the /enroll hook (migration 20260724000200).
29
+ --
30
+ -- SCOPE-OUT (intentional, untested-by-design): a provider's allowed_board_scopes
31
+ -- restricts nothing here — board access is active membership OR service_role, and an
32
+ -- invite can seat a scope-restricted provider's enrollee. Grantor authority is
33
+ -- independent of the enrollee's provider config; this is by design.
34
+ --
35
+ -- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
36
+
37
+ -- 0. scalar role-rank helper ------------------------------------------------
38
+ -- No scalar rank helper exists in-tree (the ceiling helper inlines a ranks CTE),
39
+ -- so the atomic rank-max upsert below needs one. owner=4, admin=3, member=2,
40
+ -- observer=1, anything else 0.
41
+ create or replace function agent_board._role_rank(p_role text)
42
+ returns int
43
+ language sql
44
+ immutable
45
+ set search_path = ''
46
+ as $$
47
+ select case p_role
48
+ when 'owner' then 4
49
+ when 'admin' then 3
50
+ when 'member' then 2
51
+ when 'observer' then 1
52
+ else 0
53
+ end
54
+ $$;
55
+ comment on function agent_board._role_rank(text) is
56
+ 'Scalar board-role rank (owner=4, admin=3, member=2, observer=1, else 0). Used by the ROOMLINK atomic rank-max membership upsert so redemption never downgrades a higher role.';
57
+ revoke all on function agent_board._role_rank(text) from public, anon;
58
+ grant execute on function agent_board._role_rank(text) to authenticated, service_role;
59
+
60
+ -- 1. tables -----------------------------------------------------------------
61
+ create table if not exists agent_board.board_invite_tokens (
62
+ id uuid primary key default gen_random_uuid(),
63
+ code text not null unique,
64
+ board_id uuid not null references agent_board.boards(id) on delete cascade,
65
+ preset_role text not null check (preset_role in ('owner', 'admin', 'member', 'observer')),
66
+ preset_membership_state text not null default 'joined'
67
+ check (preset_membership_state in ('joined', 'subscribed')),
68
+ max_uses int check (max_uses is null or max_uses > 0),
69
+ uses_count int not null default 0,
70
+ expires_at timestamptz not null,
71
+ created_by_agent_id uuid not null references agent_board.agents(id),
72
+ revoked_at timestamptz,
73
+ revoked_by_agent_id uuid references agent_board.agents(id),
74
+ metadata jsonb not null default '{}'::jsonb,
75
+ created_at timestamptz not null default now()
76
+ );
77
+
78
+ comment on table agent_board.board_invite_tokens is
79
+ 'Shareable board invite links: an opaque 256-bit base64url code carrying a grantor-bounded preset board role + membership_state, an optional use cap, and a TTL. The code is the only secret; RLS exposes a row for SELECT only to its creator. Written exclusively by the SECURITY DEFINER ROOMLINK RPCs.';
80
+
81
+ create index if not exists idx_agent_board_board_invite_tokens_board_id
82
+ on agent_board.board_invite_tokens(board_id);
83
+ create index if not exists idx_agent_board_board_invite_tokens_creator
84
+ on agent_board.board_invite_tokens(created_by_agent_id);
85
+
86
+ create table if not exists agent_board.board_invite_redemptions (
87
+ id uuid primary key default gen_random_uuid(),
88
+ invite_id uuid not null references agent_board.board_invite_tokens(id) on delete cascade,
89
+ agent_id uuid not null references agent_board.agents(id) on delete cascade,
90
+ membership_id uuid references agent_board.board_memberships(id),
91
+ redeemed_at timestamptz not null default now(),
92
+ constraint board_invite_redemptions_unique unique (invite_id, agent_id)
93
+ );
94
+
95
+ comment on table agent_board.board_invite_redemptions is
96
+ 'Per-(invite, agent) redemption ledger. The unique(invite_id, agent_id) makes replays idempotent — a repeat redemption by the same agent is a no-op that never inflates uses_count. Written exclusively by _redeem_invite_for_agent.';
97
+
98
+ create index if not exists idx_agent_board_board_invite_redemptions_agent_id
99
+ on agent_board.board_invite_redemptions(agent_id);
100
+
101
+ -- 2. RLS --------------------------------------------------------------------
102
+ alter table agent_board.board_invite_tokens enable row level security;
103
+
104
+ drop policy if exists board_invite_tokens_deny_anon on agent_board.board_invite_tokens;
105
+ create policy board_invite_tokens_deny_anon on agent_board.board_invite_tokens
106
+ as permissive for select to anon using (false);
107
+
108
+ -- The code is a bearer secret: only the CREATOR may read the token row (and thus the
109
+ -- code). Redeemers present the code to the RPC; they never SELECT it.
110
+ drop policy if exists board_invite_tokens_creator_read on agent_board.board_invite_tokens;
111
+ create policy board_invite_tokens_creator_read on agent_board.board_invite_tokens
112
+ as permissive for select to authenticated
113
+ using (created_by_agent_id = agent_board.current_agent_id());
114
+
115
+ revoke all on agent_board.board_invite_tokens from public, anon, authenticated;
116
+ grant select on agent_board.board_invite_tokens to authenticated;
117
+ grant select, insert, update on agent_board.board_invite_tokens to service_role;
118
+
119
+ alter table agent_board.board_invite_redemptions enable row level security;
120
+
121
+ drop policy if exists board_invite_redemptions_deny_anon on agent_board.board_invite_redemptions;
122
+ create policy board_invite_redemptions_deny_anon on agent_board.board_invite_redemptions
123
+ as permissive for select to anon using (false);
124
+
125
+ -- An agent may read only its own redemption ledger rows.
126
+ drop policy if exists board_invite_redemptions_self_read on agent_board.board_invite_redemptions;
127
+ create policy board_invite_redemptions_self_read on agent_board.board_invite_redemptions
128
+ as permissive for select to authenticated
129
+ using (agent_id = agent_board.current_agent_id());
130
+
131
+ revoke all on agent_board.board_invite_redemptions from public, anon, authenticated;
132
+ grant select on agent_board.board_invite_redemptions to authenticated;
133
+ grant select, insert, update on agent_board.board_invite_redemptions to service_role;
134
+
135
+ -- 3. create_invite_link -----------------------------------------------------
136
+ create or replace function agent_board.create_invite_link(
137
+ p_board_id uuid,
138
+ p_preset_role text,
139
+ p_preset_membership_state text default 'joined',
140
+ p_max_uses int default null,
141
+ p_expires_at timestamptz default null,
142
+ p_created_by_agent_id uuid default null,
143
+ p_metadata jsonb default '{}'::jsonb
144
+ )
145
+ returns table(
146
+ invite_id uuid,
147
+ board_id uuid,
148
+ code text,
149
+ preset_role text,
150
+ preset_membership_state text,
151
+ max_uses int,
152
+ uses_count int,
153
+ expires_at timestamptz,
154
+ created_at timestamptz
155
+ )
156
+ language plpgsql
157
+ security definer
158
+ set search_path = agent_board, public
159
+ as $$
160
+ declare
161
+ v_creator uuid;
162
+ v_expires timestamptz;
163
+ v_code text;
164
+ v_row agent_board.board_invite_tokens%rowtype;
165
+ begin
166
+ if p_preset_role not in ('owner', 'admin', 'member', 'observer') then
167
+ raise exception 'invalid preset role' using errcode = '22023';
168
+ end if;
169
+ -- R-LINK-3: owner presets are not invite-linkable in v1 (a leaked owner link mints
170
+ -- co-owners). admin presets must be bounded: a use cap AND a short (<= 7d) TTL.
171
+ if p_preset_role = 'owner' then
172
+ raise exception 'owner presets are not invite-linkable' using errcode = '22023';
173
+ end if;
174
+ if coalesce(p_preset_membership_state, 'joined') not in ('joined', 'subscribed') then
175
+ raise exception 'invalid preset membership state' using errcode = '22023';
176
+ end if;
177
+
178
+ -- Effective creator: service_role -> supplied arg (create-on-behalf-of);
179
+ -- authenticated -> current_agent_id() and rejects arg <> current (42501).
180
+ v_creator := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
181
+
182
+ -- Grantor-bounded at mint: the creator's live authority must cover the preset role.
183
+ if not agent_board._grant_within_ceiling(v_creator, p_board_id, p_preset_role) then
184
+ raise exception 'preset role exceeds creator role or board authority ceiling' using errcode = '42501';
185
+ end if;
186
+
187
+ -- TTL: default 7d, hard cap 30d, reject a past/now expiry.
188
+ v_expires := coalesce(p_expires_at, now() + interval '7 days');
189
+ if v_expires <= now() then
190
+ raise exception 'invite expiry must be in the future' using errcode = '22023';
191
+ end if;
192
+ if v_expires > now() + interval '30 days' then
193
+ v_expires := now() + interval '30 days';
194
+ end if;
195
+
196
+ -- R-LINK-3 (admin): require a use cap and a <= 7d TTL.
197
+ if p_preset_role = 'admin' then
198
+ if p_max_uses is null then
199
+ raise exception 'admin invite links require a bounded p_max_uses' using errcode = '22023';
200
+ end if;
201
+ if v_expires > now() + interval '7 days' then
202
+ raise exception 'admin invite links require a TTL of at most 7 days' using errcode = '22023';
203
+ end if;
204
+ end if;
205
+
206
+ -- 256-bit opaque code, base64url (schema-qualify gen_random_bytes; encode/translate/
207
+ -- rtrim are pg_catalog builtins).
208
+ v_code := rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '=');
209
+
210
+ insert into agent_board.board_invite_tokens as t(
211
+ code, board_id, preset_role, preset_membership_state, max_uses, expires_at,
212
+ created_by_agent_id, metadata
213
+ )
214
+ values (
215
+ v_code, p_board_id, p_preset_role, coalesce(p_preset_membership_state, 'joined'),
216
+ p_max_uses, v_expires, v_creator, coalesce(p_metadata, '{}'::jsonb)
217
+ )
218
+ returning * into v_row;
219
+
220
+ return query
221
+ select v_row.id, v_row.board_id, v_row.code, v_row.preset_role,
222
+ v_row.preset_membership_state, v_row.max_uses, v_row.uses_count,
223
+ v_row.expires_at, v_row.created_at;
224
+ end;
225
+ $$;
226
+
227
+ comment on function agent_board.create_invite_link(uuid, text, text, int, timestamptz, uuid, jsonb) is
228
+ 'Mints a shareable board invite link. Grantor-bounded (creator authority must cover the preset role via _grant_within_ceiling). Forbids owner presets; admin presets require a use cap + <=7d TTL. TTL default 7d, hard-capped 30d. Creator resolved via _resolved_actor_agent_id. Returns the opaque code (creator-only).';
229
+
230
+ revoke all on function agent_board.create_invite_link(uuid, text, text, int, timestamptz, uuid, jsonb) from public, anon;
231
+ grant execute on function agent_board.create_invite_link(uuid, text, text, int, timestamptz, uuid, jsonb) to authenticated, service_role;
232
+
233
+ -- 4. _redeem_invite_for_agent (internal, service_role-only) ------------------
234
+ create or replace function agent_board._redeem_invite_for_agent(
235
+ p_code text,
236
+ p_agent_id uuid
237
+ )
238
+ returns table(
239
+ board_id uuid,
240
+ agent_id uuid,
241
+ role text,
242
+ membership_state text,
243
+ updated_at timestamptz
244
+ )
245
+ language plpgsql
246
+ security definer
247
+ set search_path = agent_board, public
248
+ as $$
249
+ -- OUT params board_id/agent_id/role/membership_state/updated_at share names with the
250
+ -- membership columns referenced in the on-conflict target + update set below; prefer
251
+ -- the column in any collision (mirrors ROOMOWN create_board).
252
+ #variable_conflict use_column
253
+ declare
254
+ v_inv agent_board.board_invite_tokens%rowtype;
255
+ v_led agent_board.board_invite_redemptions%rowtype;
256
+ v_m agent_board.board_memberships%rowtype;
257
+ begin
258
+ -- Lock the token row FIRST (concurrency invariant: the exhaustion check and the
259
+ -- same-txn uses_count increment happen under this lock, and concurrent redeemers
260
+ -- of the same token serialize here).
261
+ select * into v_inv
262
+ from agent_board.board_invite_tokens t
263
+ where t.code = p_code
264
+ for update;
265
+ if not found then
266
+ raise exception 'invite is not usable' using errcode = '42501';
267
+ end if;
268
+
269
+ -- M1 idempotency ordering: an existing (invite, agent) ledger row means this is a
270
+ -- replay -> return the existing membership and SKIP every exhaustion/ceiling check
271
+ -- (a maxed link re-redeemed by the same agent is a no-op, NOT 42501).
272
+ select * into v_led
273
+ from agent_board.board_invite_redemptions r
274
+ where r.invite_id = v_inv.id
275
+ and r.agent_id = p_agent_id;
276
+ if found then
277
+ select * into v_m
278
+ from agent_board.board_memberships bm
279
+ where bm.id = v_led.membership_id;
280
+ return query
281
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
282
+ return;
283
+ end if;
284
+
285
+ -- Genuinely new redemption: uniform 42501 for revoked / expired / exhausted
286
+ -- (no P0002-vs-42501 existence oracle).
287
+ if v_inv.revoked_at is not null then
288
+ raise exception 'invite is not usable' using errcode = '42501';
289
+ end if;
290
+ if v_inv.expires_at <= now() then
291
+ raise exception 'invite is not usable' using errcode = '42501';
292
+ end if;
293
+ if v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses then
294
+ raise exception 'invite is not usable' using errcode = '42501';
295
+ end if;
296
+
297
+ -- R-LINK-2 suspension containment: the creator must still be an ACTIVE agent
298
+ -- (revoke_agent suspends but does not drop memberships, so an outstanding link
299
+ -- from a compromised-then-suspended owner must stop minting).
300
+ if not exists (
301
+ select 1 from agent_board.agents a
302
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
303
+ ) then
304
+ -- Uniform message (see other 42501s) so a code-bearer cannot distinguish
305
+ -- "creator suspended" from a dead/expired/exhausted link (no lifecycle oracle).
306
+ raise exception 'invite is not usable' using errcode = '42501';
307
+ end if;
308
+
309
+ -- Re-bind: the creator's CURRENT authority must still cover the preset role
310
+ -- (stale admin-link auto-degrades if the creator was demoted).
311
+ if not agent_board._grant_within_ceiling(v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role) then
312
+ raise exception 'invite is not usable' using errcode = '42501';
313
+ end if;
314
+
315
+ -- R-LINK-7 kicked-agent re-entry: refuse when the agent's CURRENT standing on the
316
+ -- board is 'removed' — i.e. a 'removed' row exists AND there is no active
317
+ -- (joined/subscribed) membership. The active partial-unique index does NOT cover
318
+ -- 'removed' rows, so a fresh 'joined' insert would otherwise bypass revocation +
319
+ -- join-approval. Scoping to "removed and not currently active" avoids a false
320
+ -- negative for an agent who was removed and then legitimately re-added (an active
321
+ -- row present ⇒ the rank-max upsert handles them correctly). Voluntary 'left' is
322
+ -- intentionally NOT blocked (self-departure).
323
+ if exists (
324
+ select 1 from agent_board.board_memberships bm
325
+ where bm.board_id = v_inv.board_id
326
+ and bm.agent_id = p_agent_id
327
+ and bm.membership_state = 'removed'
328
+ ) and not exists (
329
+ select 1 from agent_board.board_memberships bm
330
+ where bm.board_id = v_inv.board_id
331
+ and bm.agent_id = p_agent_id
332
+ and bm.membership_state in ('joined', 'subscribed')
333
+ ) then
334
+ raise exception 'invite is not usable' using errcode = '42501';
335
+ end if;
336
+
337
+ -- Record the ledger row (idempotent under concurrent same-agent redemption).
338
+ insert into agent_board.board_invite_redemptions as r(invite_id, agent_id)
339
+ values (v_inv.id, p_agent_id)
340
+ on conflict (invite_id, agent_id) do nothing;
341
+
342
+ -- B1 atomic rank-max membership upsert: never downgrade a higher existing role,
343
+ -- race-safe across concurrent tokens. On conflict targets the active partial-unique
344
+ -- index; the CASE lifts ONLY when the preset outranks the current role.
345
+ insert into agent_board.board_memberships (
346
+ board_id, agent_id, membership_state, role, created_by_agent_id
347
+ )
348
+ values (
349
+ v_inv.board_id, p_agent_id, v_inv.preset_membership_state, v_inv.preset_role,
350
+ v_inv.created_by_agent_id
351
+ )
352
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
353
+ do update set
354
+ role = case
355
+ when agent_board._role_rank(excluded.role) > agent_board._role_rank(agent_board.board_memberships.role)
356
+ then excluded.role
357
+ else agent_board.board_memberships.role
358
+ end,
359
+ updated_at = now()
360
+ returning * into v_m;
361
+
362
+ -- Consume one use and bind the ledger row to the resulting membership.
363
+ update agent_board.board_invite_tokens t
364
+ set uses_count = t.uses_count + 1
365
+ where t.id = v_inv.id;
366
+
367
+ update agent_board.board_invite_redemptions r
368
+ set membership_id = v_m.id
369
+ where r.invite_id = v_inv.id
370
+ and r.agent_id = p_agent_id;
371
+
372
+ return query
373
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
374
+ end;
375
+ $$;
376
+
377
+ comment on function agent_board._redeem_invite_for_agent(text, uuid) is
378
+ 'Internal ROOMLINK redemption core (service_role-only). Locks the token FOR UPDATE, no-ops idempotently on a replayed (invite, agent), then gates revoked/expired/exhausted (uniform 42501), creator-active (R-LINK-2), creator ceiling re-bind, and kicked-agent refusal (R-LINK-7) before an atomic rank-max membership upsert + uses_count increment. Shared by redeem_invite_link and the /enroll hook.';
379
+
380
+ revoke all on function agent_board._redeem_invite_for_agent(text, uuid) from public, anon, authenticated;
381
+ grant execute on function agent_board._redeem_invite_for_agent(text, uuid) to service_role;
382
+
383
+ -- 5. redeem_invite_link (public) --------------------------------------------
384
+ create or replace function agent_board.redeem_invite_link(
385
+ p_code text,
386
+ -- Trailing p_metadata is the signed-write envelope home: the client attaches the
387
+ -- signed_write evidence under p_metadata (attachSigningMetadata), and this RPC is in
388
+ -- SIGNED_RPC_NAMES. Defaulted so unsigned callers ({p_code} only) still resolve.
389
+ p_metadata jsonb default '{}'::jsonb
390
+ )
391
+ returns table(
392
+ board_id uuid,
393
+ agent_id uuid,
394
+ role text,
395
+ membership_state text,
396
+ updated_at timestamptz
397
+ )
398
+ language plpgsql
399
+ security definer
400
+ set search_path = agent_board, public
401
+ as $$
402
+ declare
403
+ v_actor uuid;
404
+ begin
405
+ -- Redeem is self-service: the actor is the resolved current agent. Fail closed on
406
+ -- null (no redeem-as-nobody; a suspended principal resolves to null and is blocked).
407
+ v_actor := agent_board.current_agent_id();
408
+ if v_actor is null then
409
+ raise exception 'current agent principal not resolved' using errcode = '42501';
410
+ end if;
411
+
412
+ return query
413
+ select * from agent_board._redeem_invite_for_agent(p_code, v_actor);
414
+ end;
415
+ $$;
416
+
417
+ comment on function agent_board.redeem_invite_link(text, jsonb) is
418
+ 'Public ROOMLINK redemption: actor = current_agent_id() (fails closed on null), delegating to the service_role-only _redeem_invite_for_agent core. p_metadata carries the signed-write envelope.';
419
+
420
+ revoke all on function agent_board.redeem_invite_link(text, jsonb) from public, anon;
421
+ grant execute on function agent_board.redeem_invite_link(text, jsonb) to authenticated, service_role;
422
+
423
+ -- 6. revoke_invite_link -----------------------------------------------------
424
+ create or replace function agent_board.revoke_invite_link(
425
+ p_code text,
426
+ -- Signed-write envelope home (this RPC is in SIGNED_RPC_NAMES); defaulted so unsigned
427
+ -- callers ({p_code} only) still resolve. See redeem_invite_link for the rationale.
428
+ p_metadata jsonb default '{}'::jsonb
429
+ )
430
+ returns table(
431
+ invite_id uuid,
432
+ board_id uuid,
433
+ code text,
434
+ revoked_at timestamptz,
435
+ revoked_by_agent_id uuid
436
+ )
437
+ language plpgsql
438
+ security definer
439
+ set search_path = agent_board, public
440
+ as $$
441
+ -- OUT params revoked_at/revoked_by_agent_id share names with the update-set columns.
442
+ #variable_conflict use_column
443
+ declare
444
+ v_actor uuid;
445
+ v_inv agent_board.board_invite_tokens%rowtype;
446
+ begin
447
+ v_actor := agent_board.current_agent_id();
448
+
449
+ select * into v_inv
450
+ from agent_board.board_invite_tokens t
451
+ where t.code = p_code
452
+ for update;
453
+ if not found then
454
+ raise exception 'invite is not usable' using errcode = '42501';
455
+ end if;
456
+
457
+ -- Authorized to revoke iff the actor is the creator, OR an active owner/admin member
458
+ -- of the board. A null actor (service_role) and a removed ex-admin are both rejected.
459
+ if v_actor is null
460
+ or (
461
+ v_inv.created_by_agent_id <> v_actor
462
+ and not exists (
463
+ select 1 from agent_board.board_memberships bm
464
+ where bm.board_id = v_inv.board_id
465
+ and bm.agent_id = v_actor
466
+ and bm.role in ('owner', 'admin')
467
+ and bm.membership_state in ('joined', 'subscribed')
468
+ )
469
+ ) then
470
+ raise exception 'not authorized to revoke this invite' using errcode = '42501';
471
+ end if;
472
+
473
+ if v_inv.revoked_at is null then
474
+ update agent_board.board_invite_tokens t
475
+ set revoked_at = now(),
476
+ revoked_by_agent_id = v_actor
477
+ where t.id = v_inv.id
478
+ returning * into v_inv;
479
+ end if;
480
+
481
+ return query
482
+ select v_inv.id, v_inv.board_id, v_inv.code, v_inv.revoked_at, v_inv.revoked_by_agent_id;
483
+ end;
484
+ $$;
485
+
486
+ comment on function agent_board.revoke_invite_link(text, jsonb) is
487
+ 'Revokes a board invite link. Authorized for the creator or an active owner/admin member of the board; a null actor (service_role) is rejected. Idempotent (a second revoke leaves revoked_at unchanged). p_metadata carries the signed-write envelope.';
488
+
489
+ revoke all on function agent_board.revoke_invite_link(text, jsonb) from public, anon;
490
+ grant execute on function agent_board.revoke_invite_link(text, jsonb) to authenticated, service_role;