@consiliency/agent-board-schema 1.5.0 → 1.7.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.
- package/fixtures/already-applied.json +12 -0
- package/migrations/20260724000100_agent_board_roomlink_invite_tokens.sql +490 -0
- package/migrations/20260724000200_agent_board_roomlink_enroll_binding.sql +285 -0
- package/migrations/20260725000100_agent_board_roomdeleg_role_gate.sql +232 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +8 -2
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_identity_providers.test.sql +3 -3
- package/tests/agent_board_roomdeleg.test.sql +167 -0
- package/tests/agent_board_roomlink.test.sql +364 -0
|
@@ -292,6 +292,18 @@
|
|
|
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"
|
|
295
307
|
}
|
|
296
308
|
]
|
|
297
309
|
}
|
|
@@ -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;
|