@consiliency/agent-board-schema 1.4.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 +20 -0
- package/migrations/20260723000100_agent_board_roomown_owner_seed.sql +167 -0
- package/migrations/20260723000200_agent_board_roomown_grant_role_live.sql +72 -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 +48 -3
- package/src/tooling.test.mjs +12 -2
- package/tests/agent_board_board_role.test.sql +3 -3
- package/tests/agent_board_delegation_policies.test.sql +2 -1
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +5 -0
- package/tests/agent_board_identity_providers.test.sql +3 -3
- package/tests/agent_board_inbox_rpcs.test.sql +4 -4
- package/tests/agent_board_portal_offline_invariant.test.sql +7 -3
- package/tests/agent_board_roomdeleg.test.sql +167 -0
- package/tests/agent_board_roomlink.test.sql +364 -0
- package/tests/agent_board_roomown.test.sql +233 -0
- package/tests/agent_board_scoped_send_regrant.test.sql +7 -3
- package/tests/agent_board_send_authz_matrix.test.sql +11 -8
- package/tests/agent_board_send_role_gate.test.sql +8 -4
- package/tests/agent_board_v2_invariants.test.sql +5 -5
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
-- ROOMLINK Lane B: bind an invite code into federated enrollment.
|
|
2
|
+
--
|
|
3
|
+
-- Adds an OPTIONAL trailing p_invite_code to enroll_federated_identity and a widened
|
|
4
|
+
-- return column invite_redeemed. After the agent + key + audit are established, when a
|
|
5
|
+
-- non-empty code is present the RPC soft-honors it via _redeem_invite_for_agent inside
|
|
6
|
+
-- a BEGIN/EXCEPTION subtransaction: a bad/expired/exhausted code rolls back ONLY the
|
|
7
|
+
-- redeem sub-block (the agent-create persists) and enrollment still succeeds with
|
|
8
|
+
-- invite_redeemed=false. This lets a stranger arrive with a shareable board link and
|
|
9
|
+
-- land directly in the board in one call, while a bad code never blocks enrollment.
|
|
10
|
+
--
|
|
11
|
+
-- The return table WIDENS 8 -> 9 columns and the arity goes 13 -> 14, so
|
|
12
|
+
-- create-or-replace cannot be used: the released 13-arg function (20260719001200) is
|
|
13
|
+
-- DROPped and recreated here. The released 001200 bytes are NOT edited. A 13-positional
|
|
14
|
+
-- or 13-named call still resolves (the new arg defaults null), so no old caller breaks.
|
|
15
|
+
--
|
|
16
|
+
-- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
|
|
17
|
+
|
|
18
|
+
drop function if exists agent_board.enroll_federated_identity(
|
|
19
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[]);
|
|
20
|
+
|
|
21
|
+
create or replace function agent_board.enroll_federated_identity(
|
|
22
|
+
p_issuer text,
|
|
23
|
+
p_audience text,
|
|
24
|
+
p_subject text,
|
|
25
|
+
p_human_identity_claim jsonb,
|
|
26
|
+
p_slug text,
|
|
27
|
+
p_agent_public_key text,
|
|
28
|
+
p_agent_public_key_format text,
|
|
29
|
+
p_agent_public_key_fingerprint text,
|
|
30
|
+
p_pop_verified boolean,
|
|
31
|
+
p_actor_sub text default null,
|
|
32
|
+
p_agent_kind text default 'agent',
|
|
33
|
+
p_requested_scopes text[] default '{}',
|
|
34
|
+
p_requested_board_scopes text[] default '{}',
|
|
35
|
+
p_invite_code text default null
|
|
36
|
+
)
|
|
37
|
+
returns table(
|
|
38
|
+
agent_id uuid,
|
|
39
|
+
auth_user_id uuid,
|
|
40
|
+
provider_id text,
|
|
41
|
+
signing_key_id uuid,
|
|
42
|
+
granted_scopes text[],
|
|
43
|
+
key_id text,
|
|
44
|
+
key_version int,
|
|
45
|
+
agent_handle text,
|
|
46
|
+
invite_redeemed boolean
|
|
47
|
+
)
|
|
48
|
+
language plpgsql
|
|
49
|
+
security definer
|
|
50
|
+
set search_path = agent_board, public
|
|
51
|
+
as $$
|
|
52
|
+
declare
|
|
53
|
+
v_slug text;
|
|
54
|
+
v_kind text;
|
|
55
|
+
v_provider agent_board.identity_providers%rowtype;
|
|
56
|
+
v_owner_user_id uuid;
|
|
57
|
+
v_auth_user_id uuid;
|
|
58
|
+
v_agent_handle text;
|
|
59
|
+
v_agent_id uuid;
|
|
60
|
+
v_signing_key_id uuid;
|
|
61
|
+
v_fingerprint text;
|
|
62
|
+
v_key_hex text;
|
|
63
|
+
v_raw_key bytea;
|
|
64
|
+
v_reencoded text;
|
|
65
|
+
v_key_id text := 'portal-enroll';
|
|
66
|
+
v_key_version int := 1;
|
|
67
|
+
v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
|
|
68
|
+
v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
|
|
69
|
+
v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
|
|
70
|
+
v_invite_redeemed boolean := false;
|
|
71
|
+
begin
|
|
72
|
+
-- service_role-only (defense in depth on top of the execute-grant revocation).
|
|
73
|
+
if not agent_board._is_service_role() then
|
|
74
|
+
raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
|
|
75
|
+
end if;
|
|
76
|
+
|
|
77
|
+
-- A verified subject + human identity claim are always present from the EF.
|
|
78
|
+
if p_subject is null then
|
|
79
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
80
|
+
end if;
|
|
81
|
+
if p_human_identity_claim is null then
|
|
82
|
+
raise exception 'human identity claim is required' using errcode = '22023';
|
|
83
|
+
end if;
|
|
84
|
+
|
|
85
|
+
-- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
|
|
86
|
+
-- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
|
|
87
|
+
if p_pop_verified is not true then
|
|
88
|
+
raise exception 'proof-of-possession was not verified; enrollment refused'
|
|
89
|
+
using errcode = '42501';
|
|
90
|
+
end if;
|
|
91
|
+
|
|
92
|
+
-- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
|
|
93
|
+
-- base64url chars).
|
|
94
|
+
if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
|
|
95
|
+
raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
|
|
96
|
+
end if;
|
|
97
|
+
if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
|
|
98
|
+
raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
|
|
99
|
+
using errcode = '22023';
|
|
100
|
+
end if;
|
|
101
|
+
|
|
102
|
+
-- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
|
|
103
|
+
v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
|
|
104
|
+
if octet_length(v_raw_key) <> 32 then
|
|
105
|
+
raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
|
|
106
|
+
using errcode = '22023';
|
|
107
|
+
end if;
|
|
108
|
+
v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
|
|
109
|
+
if v_reencoded is distinct from p_agent_public_key then
|
|
110
|
+
raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
|
|
111
|
+
using errcode = '22023';
|
|
112
|
+
end if;
|
|
113
|
+
|
|
114
|
+
-- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
|
|
115
|
+
if v_raw_key = decode(repeat('00', 32), 'hex') then
|
|
116
|
+
raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
|
|
117
|
+
using errcode = '22023';
|
|
118
|
+
end if;
|
|
119
|
+
|
|
120
|
+
-- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes.
|
|
121
|
+
v_fingerprint := 'sha256:' || rtrim(
|
|
122
|
+
translate(encode(extensions.digest(v_raw_key, 'sha256'), 'base64'), '+/', '-_'),
|
|
123
|
+
'=');
|
|
124
|
+
if p_agent_public_key_fingerprint is distinct from v_fingerprint then
|
|
125
|
+
raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
|
|
126
|
+
using errcode = '22023';
|
|
127
|
+
end if;
|
|
128
|
+
|
|
129
|
+
-- (3) act.sub is audit-only, but when present it must be a real value.
|
|
130
|
+
if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
|
|
131
|
+
raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
|
|
132
|
+
end if;
|
|
133
|
+
|
|
134
|
+
v_slug := agent_board._normalize_provision_slug(p_slug);
|
|
135
|
+
v_kind := lower(coalesce(trim(p_agent_kind), ''));
|
|
136
|
+
|
|
137
|
+
-- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
|
|
138
|
+
select *
|
|
139
|
+
into v_provider
|
|
140
|
+
from agent_board.identity_providers
|
|
141
|
+
where issuer = p_issuer
|
|
142
|
+
and audience = p_audience
|
|
143
|
+
limit 1;
|
|
144
|
+
if v_provider.id is null then
|
|
145
|
+
raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
|
|
146
|
+
end if;
|
|
147
|
+
if not v_provider.supported then
|
|
148
|
+
raise exception 'identity provider is not supported' using errcode = '42501';
|
|
149
|
+
end if;
|
|
150
|
+
if not v_provider.policy_approved then
|
|
151
|
+
raise exception 'identity provider is not policy-approved' using errcode = '42501';
|
|
152
|
+
end if;
|
|
153
|
+
|
|
154
|
+
-- (5) scope-ceiling + kind gate. 'system' is never federatable.
|
|
155
|
+
if v_kind = 'system' then
|
|
156
|
+
raise exception 'the system kind cannot be federated' using errcode = '42501';
|
|
157
|
+
end if;
|
|
158
|
+
if v_kind not in ('human', 'agent') then
|
|
159
|
+
raise exception 'agent kind must be human or agent' using errcode = '22023';
|
|
160
|
+
end if;
|
|
161
|
+
if array_length(v_provider.allowed_agent_kinds, 1) is not null
|
|
162
|
+
and not (v_kind = any (v_provider.allowed_agent_kinds)) then
|
|
163
|
+
raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
|
|
164
|
+
end if;
|
|
165
|
+
if not (v_requested_scopes <@ v_provider.allowed_scopes) then
|
|
166
|
+
raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
|
|
167
|
+
end if;
|
|
168
|
+
if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
|
|
169
|
+
raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
|
|
170
|
+
end if;
|
|
171
|
+
|
|
172
|
+
-- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
|
|
173
|
+
if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
|
|
174
|
+
raise exception 'this signing key is already enrolled (duplicate fingerprint)'
|
|
175
|
+
using errcode = '23505';
|
|
176
|
+
end if;
|
|
177
|
+
|
|
178
|
+
-- (7) HUMAN OWNER — established idempotently per (issuer, sub).
|
|
179
|
+
v_owner_user_id := agent_board._ensure_agent_auth_user(
|
|
180
|
+
'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
|
|
181
|
+
|
|
182
|
+
-- (8) AGENT handle — unique per enrolled key.
|
|
183
|
+
v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
|
|
184
|
+
v_agent_handle := v_slug;
|
|
185
|
+
if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
|
|
186
|
+
v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
|
|
187
|
+
end if;
|
|
188
|
+
|
|
189
|
+
-- (9) the agent's OWN board principal (session-usable via its enrolled key).
|
|
190
|
+
v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
|
|
191
|
+
|
|
192
|
+
-- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
|
|
193
|
+
insert into agent_board.agents(
|
|
194
|
+
agent_type,
|
|
195
|
+
kind,
|
|
196
|
+
handle,
|
|
197
|
+
display_name,
|
|
198
|
+
status,
|
|
199
|
+
auth_user_id,
|
|
200
|
+
owner_user_id,
|
|
201
|
+
metadata
|
|
202
|
+
)
|
|
203
|
+
values (
|
|
204
|
+
v_kind,
|
|
205
|
+
v_kind::agent_board.agent_kind,
|
|
206
|
+
v_agent_handle,
|
|
207
|
+
agent_board._default_provision_display_name(v_agent_handle),
|
|
208
|
+
'active',
|
|
209
|
+
v_auth_user_id,
|
|
210
|
+
v_owner_user_id,
|
|
211
|
+
jsonb_build_object(
|
|
212
|
+
'federation', jsonb_build_object(
|
|
213
|
+
'issuer', p_issuer,
|
|
214
|
+
'audience', p_audience,
|
|
215
|
+
'subject', p_subject,
|
|
216
|
+
'actorSub', p_actor_sub,
|
|
217
|
+
'providerId', v_provider.id,
|
|
218
|
+
'signingKeyFingerprint', v_fingerprint,
|
|
219
|
+
'requestedScopes', to_jsonb(v_requested_scopes),
|
|
220
|
+
'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
|
|
221
|
+
'popVerified', true,
|
|
222
|
+
'enrolledAt', now()
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
returning id into v_agent_id;
|
|
227
|
+
|
|
228
|
+
-- (11) enroll the public key as active + proven (satisfies active_requires_proof).
|
|
229
|
+
insert into agent_board.agent_signing_keys(
|
|
230
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
231
|
+
status, proof_state, metadata
|
|
232
|
+
)
|
|
233
|
+
values (
|
|
234
|
+
gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
|
|
235
|
+
p_agent_public_key_format, v_fingerprint,
|
|
236
|
+
'active', 'proven',
|
|
237
|
+
jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
|
|
238
|
+
)
|
|
239
|
+
returning id into v_signing_key_id;
|
|
240
|
+
|
|
241
|
+
-- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
|
|
242
|
+
insert into agent_board.enrollment_federation_audit(
|
|
243
|
+
issuer, audience, subject, human_identity_claim, provider_id,
|
|
244
|
+
agent_id, auth_user_id, requested_scopes, granted_scopes
|
|
245
|
+
)
|
|
246
|
+
values (
|
|
247
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
|
|
248
|
+
v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
-- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
|
|
252
|
+
-- membership for the freshly-created agent. Wrapped in a subtransaction so a
|
|
253
|
+
-- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
|
|
254
|
+
-- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
|
|
255
|
+
if v_invite_code is not null then
|
|
256
|
+
begin
|
|
257
|
+
perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
|
|
258
|
+
v_invite_redeemed := true;
|
|
259
|
+
exception when others then
|
|
260
|
+
v_invite_redeemed := false;
|
|
261
|
+
end;
|
|
262
|
+
end if;
|
|
263
|
+
|
|
264
|
+
-- Server-authoritative identity: the client mints /session with the RETURNED
|
|
265
|
+
-- key_id/key_version/agent_handle (its private key stays in the keystore).
|
|
266
|
+
return query
|
|
267
|
+
select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
|
|
268
|
+
v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
|
|
269
|
+
end;
|
|
270
|
+
$$;
|
|
271
|
+
|
|
272
|
+
comment on function agent_board.enroll_federated_identity(
|
|
273
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
|
|
274
|
+
'PoP-at-enrollment federated identity (ROOMLINK-extended). Same server-authoritative '
|
|
275
|
+
'identity establishment as 20260719001200, plus an OPTIONAL trailing p_invite_code: '
|
|
276
|
+
'when present the RPC soft-honors it via _redeem_invite_for_agent in a BEGIN/EXCEPTION '
|
|
277
|
+
'subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false) and '
|
|
278
|
+
'RETURNS invite_redeemed. service_role-only; never returns a Portal token.';
|
|
279
|
+
|
|
280
|
+
revoke all on function agent_board.enroll_federated_identity(
|
|
281
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
282
|
+
from public, anon, authenticated;
|
|
283
|
+
grant execute on function agent_board.enroll_federated_identity(
|
|
284
|
+
text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
|
|
285
|
+
to service_role;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
-- ROOMDELEG Lane A: owner-gated role-gate flip + creator-identity gate-bypass fix.
|
|
2
|
+
--
|
|
3
|
+
-- Additive, no schema_version bump (stays 8). Adds:
|
|
4
|
+
-- * agent_board.set_board_role_gate(board, enforced) — lets a board OWNER flip the
|
|
5
|
+
-- per-board boards.role_gate_enforced flag from their own authenticated session.
|
|
6
|
+
-- Owner-membership gated (mirrors set_board_authority_ceiling's owner-gate + grant
|
|
7
|
+
-- pattern; NOT its side-table upsert — this UPDATEs the boards row). service_role
|
|
8
|
+
-- (current_agent_id() = null) is intentionally NOT an owner here; it retains its
|
|
9
|
+
-- direct table-write grant for operator provisioning.
|
|
10
|
+
-- * R-DELEG-1: create-or-replace invite_board_member / resolve_board_request so the
|
|
11
|
+
-- creator-identity exemption does NOT apply when role_gate_enforced is true. The
|
|
12
|
+
-- board creator (created_by_agent_id is immutable, so a DEMOTED creator kept its
|
|
13
|
+
-- exemption) must, under an enforced gate, ALSO hold an active owner/admin
|
|
14
|
+
-- membership like any other member. Gate-OFF behavior is byte-identical to today
|
|
15
|
+
-- (creator bypasses; non-creator members need an active membership; a null actor /
|
|
16
|
+
-- service_role still bypasses) so no existing test regresses.
|
|
17
|
+
|
|
18
|
+
-- 1. set_board_role_gate (owner-settable per-board role gate) ------------------------
|
|
19
|
+
-- NOTE: board status is NOT gated here (a role-gate flip is permitted on any board the
|
|
20
|
+
-- caller owns regardless of status); this matches the sibling ceiling setter and is an
|
|
21
|
+
-- accepted scope boundary for Lane A.
|
|
22
|
+
create or replace function agent_board.set_board_role_gate(
|
|
23
|
+
p_board_id uuid,
|
|
24
|
+
p_enforced boolean
|
|
25
|
+
)
|
|
26
|
+
returns table(
|
|
27
|
+
board_id uuid,
|
|
28
|
+
role_gate_enforced boolean,
|
|
29
|
+
updated_at timestamptz
|
|
30
|
+
)
|
|
31
|
+
language plpgsql
|
|
32
|
+
security definer
|
|
33
|
+
set search_path = agent_board, public
|
|
34
|
+
as $$
|
|
35
|
+
declare
|
|
36
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
37
|
+
v_row agent_board.boards%rowtype;
|
|
38
|
+
begin
|
|
39
|
+
if p_enforced is null then
|
|
40
|
+
raise exception 'p_enforced must not be null' using errcode = '22023';
|
|
41
|
+
end if;
|
|
42
|
+
|
|
43
|
+
-- Owner gate: only an active owner-role member may flip the gate. service_role
|
|
44
|
+
-- (current_agent_id() = null) is not an owner and is rejected here (it keeps its
|
|
45
|
+
-- direct table-write grant for operator provisioning).
|
|
46
|
+
if v_actor is null
|
|
47
|
+
or not exists (
|
|
48
|
+
select 1
|
|
49
|
+
from agent_board.board_memberships bm
|
|
50
|
+
where bm.board_id = p_board_id
|
|
51
|
+
and bm.agent_id = v_actor
|
|
52
|
+
and bm.role = 'owner'
|
|
53
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
54
|
+
) then
|
|
55
|
+
raise exception 'only a board owner may set the role gate' using errcode = '42501';
|
|
56
|
+
end if;
|
|
57
|
+
|
|
58
|
+
update agent_board.boards b
|
|
59
|
+
set role_gate_enforced = p_enforced,
|
|
60
|
+
updated_at = now()
|
|
61
|
+
where b.id = p_board_id
|
|
62
|
+
returning * into v_row;
|
|
63
|
+
|
|
64
|
+
if v_row.id is null then
|
|
65
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
66
|
+
end if;
|
|
67
|
+
|
|
68
|
+
return query
|
|
69
|
+
select v_row.id, v_row.role_gate_enforced, v_row.updated_at;
|
|
70
|
+
end;
|
|
71
|
+
$$;
|
|
72
|
+
|
|
73
|
+
comment on function agent_board.set_board_role_gate(uuid, boolean) is
|
|
74
|
+
'Owner-gated setter for a board''s role_gate_enforced flag: UPDATEs boards.role_gate_enforced only when current_agent_id() is an active owner of the board (else 42501; null p_enforced -> 22023; missing board -> P0002). service_role is not an owner here.';
|
|
75
|
+
|
|
76
|
+
revoke all on function agent_board.set_board_role_gate(uuid, boolean) from public, anon;
|
|
77
|
+
grant execute on function agent_board.set_board_role_gate(uuid, boolean) to authenticated, service_role;
|
|
78
|
+
|
|
79
|
+
-- 2. R-DELEG-1: role-gate-aware invite_board_member ---------------------------------
|
|
80
|
+
-- Body is verbatim from 20260717000100 EXCEPT the actor gate-guard block: when the
|
|
81
|
+
-- role gate is enforced the creator-identity exemption is dropped (the creator must
|
|
82
|
+
-- then also hold an active owner/admin membership).
|
|
83
|
+
create or replace function agent_board.invite_board_member(
|
|
84
|
+
p_board_id uuid,
|
|
85
|
+
p_agent_id uuid,
|
|
86
|
+
p_created_by_agent_id uuid,
|
|
87
|
+
p_metadata jsonb default '{}'::jsonb
|
|
88
|
+
)
|
|
89
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
|
|
90
|
+
language plpgsql
|
|
91
|
+
security definer
|
|
92
|
+
set search_path = agent_board, public
|
|
93
|
+
as $$
|
|
94
|
+
declare
|
|
95
|
+
v_board agent_board.boards%rowtype;
|
|
96
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
97
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
98
|
+
begin
|
|
99
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
100
|
+
if v_board.id is null then
|
|
101
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
102
|
+
end if;
|
|
103
|
+
if v_actor is not null then
|
|
104
|
+
if v_board.role_gate_enforced then
|
|
105
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
106
|
+
-- must hold an active owner/admin membership.
|
|
107
|
+
if not exists (
|
|
108
|
+
select 1
|
|
109
|
+
from agent_board.board_memberships bm
|
|
110
|
+
where bm.board_id = p_board_id
|
|
111
|
+
and bm.agent_id = v_actor
|
|
112
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
113
|
+
and bm.role in ('owner', 'admin')
|
|
114
|
+
) then
|
|
115
|
+
raise exception 'board invite requires owner or admin role' using errcode = '42501';
|
|
116
|
+
end if;
|
|
117
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
118
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
119
|
+
if not exists (
|
|
120
|
+
select 1
|
|
121
|
+
from agent_board.board_memberships bm
|
|
122
|
+
where bm.board_id = p_board_id
|
|
123
|
+
and bm.agent_id = v_actor
|
|
124
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
125
|
+
) then
|
|
126
|
+
raise exception 'board invite requires active membership' using errcode = '42501';
|
|
127
|
+
end if;
|
|
128
|
+
end if;
|
|
129
|
+
end if;
|
|
130
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
131
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
132
|
+
end if;
|
|
133
|
+
if not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
134
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
135
|
+
end if;
|
|
136
|
+
|
|
137
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
138
|
+
p_board_id,
|
|
139
|
+
p_agent_id,
|
|
140
|
+
'invited',
|
|
141
|
+
p_created_by_agent_id,
|
|
142
|
+
p_metadata
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return query
|
|
146
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
147
|
+
end;
|
|
148
|
+
$$;
|
|
149
|
+
|
|
150
|
+
-- 3. R-DELEG-1: role-gate-aware resolve_board_request -------------------------------
|
|
151
|
+
create or replace function agent_board.resolve_board_request(
|
|
152
|
+
p_board_id uuid,
|
|
153
|
+
p_agent_id uuid,
|
|
154
|
+
p_membership_state text,
|
|
155
|
+
p_created_by_agent_id uuid,
|
|
156
|
+
p_metadata jsonb default '{}'::jsonb
|
|
157
|
+
)
|
|
158
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamptz, updated_at timestamptz)
|
|
159
|
+
language plpgsql
|
|
160
|
+
security definer
|
|
161
|
+
set search_path = agent_board, public
|
|
162
|
+
as $$
|
|
163
|
+
declare
|
|
164
|
+
v_board agent_board.boards%rowtype;
|
|
165
|
+
v_existing_state text;
|
|
166
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
167
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
168
|
+
begin
|
|
169
|
+
if p_membership_state not in ('joined', 'subscribed', 'removed') then
|
|
170
|
+
raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
|
|
171
|
+
end if;
|
|
172
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
173
|
+
if v_board.id is null then
|
|
174
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
175
|
+
end if;
|
|
176
|
+
if v_actor is not null then
|
|
177
|
+
if v_board.role_gate_enforced then
|
|
178
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
179
|
+
-- must hold an active owner/admin membership.
|
|
180
|
+
if not exists (
|
|
181
|
+
select 1
|
|
182
|
+
from agent_board.board_memberships bm
|
|
183
|
+
where bm.board_id = p_board_id
|
|
184
|
+
and bm.agent_id = v_actor
|
|
185
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
186
|
+
and bm.role in ('owner', 'admin')
|
|
187
|
+
) then
|
|
188
|
+
raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
|
|
189
|
+
end if;
|
|
190
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
191
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
192
|
+
if not exists (
|
|
193
|
+
select 1
|
|
194
|
+
from agent_board.board_memberships bm
|
|
195
|
+
where bm.board_id = p_board_id
|
|
196
|
+
and bm.agent_id = v_actor
|
|
197
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
198
|
+
) then
|
|
199
|
+
raise exception 'board request resolution requires active membership' using errcode = '42501';
|
|
200
|
+
end if;
|
|
201
|
+
end if;
|
|
202
|
+
end if;
|
|
203
|
+
|
|
204
|
+
select bm.membership_state
|
|
205
|
+
into v_existing_state
|
|
206
|
+
from agent_board.board_memberships bm
|
|
207
|
+
where bm.board_id = p_board_id
|
|
208
|
+
and bm.agent_id = p_agent_id
|
|
209
|
+
order by bm.created_at desc
|
|
210
|
+
limit 1;
|
|
211
|
+
|
|
212
|
+
if v_existing_state not in ('requested', 'invited') then
|
|
213
|
+
raise exception 'pending board membership not found' using errcode = 'P0002';
|
|
214
|
+
end if;
|
|
215
|
+
|
|
216
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
217
|
+
p_board_id,
|
|
218
|
+
p_agent_id,
|
|
219
|
+
p_membership_state,
|
|
220
|
+
p_created_by_agent_id,
|
|
221
|
+
p_metadata
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return query
|
|
225
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
226
|
+
end;
|
|
227
|
+
$$;
|
|
228
|
+
|
|
229
|
+
-- Re-assert grants (create or replace preserves them, but be explicit so a fresh
|
|
230
|
+
-- replay is order-independent).
|
|
231
|
+
grant execute on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) to authenticated, service_role;
|
|
232
|
+
grant execute on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) to authenticated, service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.7.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":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 76,
|
|
50
|
+
"aggregateSha256": "fca8d220460ac16309d9a2d76cfa6f9ccc395e1c52c90c7cbd4bd5f04bbc42dc",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -687,6 +687,51 @@
|
|
|
687
687
|
"sourceRef": "message-board",
|
|
688
688
|
"bytes": 3112,
|
|
689
689
|
"sha256": "b2f58eadc13cb227991c4d978c5460878dc3cbada43c58285b55154b89a28459"
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
"order": 71,
|
|
693
|
+
"id": "20260723000100_agent_board_roomown_owner_seed",
|
|
694
|
+
"filename": "20260723000100_agent_board_roomown_owner_seed.sql",
|
|
695
|
+
"origin": "message-board-forward",
|
|
696
|
+
"sourceRef": "message-board",
|
|
697
|
+
"bytes": 7871,
|
|
698
|
+
"sha256": "ecfaf262832b3d499102ec3a2ee1d48e043b33282287d0e987555e868c07b804"
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
"order": 72,
|
|
702
|
+
"id": "20260723000200_agent_board_roomown_grant_role_live",
|
|
703
|
+
"filename": "20260723000200_agent_board_roomown_grant_role_live.sql",
|
|
704
|
+
"origin": "message-board-forward",
|
|
705
|
+
"sourceRef": "message-board",
|
|
706
|
+
"bytes": 3716,
|
|
707
|
+
"sha256": "40c9f585af16211fe9ab1d35efca6787e49b31afc7c85f208da22b9566a65575"
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
"order": 73,
|
|
711
|
+
"id": "20260724000100_agent_board_roomlink_invite_tokens",
|
|
712
|
+
"filename": "20260724000100_agent_board_roomlink_invite_tokens.sql",
|
|
713
|
+
"origin": "message-board-forward",
|
|
714
|
+
"sourceRef": "message-board",
|
|
715
|
+
"bytes": 22491,
|
|
716
|
+
"sha256": "98870e44a71eb648cfefd550f32eb30cc7a25e65ae1a7aa3852a3f9234eb98e2"
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
"order": 74,
|
|
720
|
+
"id": "20260724000200_agent_board_roomlink_enroll_binding",
|
|
721
|
+
"filename": "20260724000200_agent_board_roomlink_enroll_binding.sql",
|
|
722
|
+
"origin": "message-board-forward",
|
|
723
|
+
"sourceRef": "message-board",
|
|
724
|
+
"bytes": 12092,
|
|
725
|
+
"sha256": "60e289d99a212ff63cb99ea275b545f7e9c15ad8cc4e0ba42c98492bae342adc"
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
"order": 75,
|
|
729
|
+
"id": "20260725000100_agent_board_roomdeleg_role_gate",
|
|
730
|
+
"filename": "20260725000100_agent_board_roomdeleg_role_gate.sql",
|
|
731
|
+
"origin": "message-board-forward",
|
|
732
|
+
"sourceRef": "message-board",
|
|
733
|
+
"bytes": 9521,
|
|
734
|
+
"sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
|
|
690
735
|
}
|
|
691
736
|
],
|
|
692
737
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -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 forward migrations (migrationCount = prior
|
|
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)', () => {
|
|
41
41
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
42
|
-
expect(manifest.migrationCount).toBe(
|
|
42
|
+
expect(manifest.migrationCount).toBe(76);
|
|
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,
|
|
@@ -74,6 +74,16 @@ describe('manifest integrity', () => {
|
|
|
74
74
|
'20260719000300_agent_board_enroll_rate_cap.sql',
|
|
75
75
|
'20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql',
|
|
76
76
|
'20260719001300_agent_board_enroll_rate_events_reaper_rls.sql',
|
|
77
|
+
// ROOMOWN: room ownership + self-service authority (create_board owner-seed +
|
|
78
|
+
// bootstrap; grant_board_role activated for authenticated). schema stays 8.
|
|
79
|
+
'20260723000100_agent_board_roomown_owner_seed.sql',
|
|
80
|
+
'20260723000200_agent_board_roomown_grant_role_live.sql',
|
|
81
|
+
// ROOMLINK: shareable board invite links + enroll-on-redeem binding. schema stays 8.
|
|
82
|
+
'20260724000100_agent_board_roomlink_invite_tokens.sql',
|
|
83
|
+
'20260724000200_agent_board_roomlink_enroll_binding.sql',
|
|
84
|
+
// ROOMDELEG Lane A: owner-gated set_board_role_gate + creator-identity gate-bypass
|
|
85
|
+
// fix on invite_board_member/resolve_board_request. schema stays 8.
|
|
86
|
+
'20260725000100_agent_board_roomdeleg_role_gate.sql',
|
|
77
87
|
]) {
|
|
78
88
|
expect(names.has(f)).toBe(true);
|
|
79
89
|
}
|
|
@@ -47,11 +47,11 @@ begin
|
|
|
47
47
|
select board_id into v_board
|
|
48
48
|
from agent_board.create_board(v_creator, 'br-board-' || v_suffix, 'BR Board', 'board role test', '{}'::jsonb, 'private');
|
|
49
49
|
|
|
50
|
-
--
|
|
51
|
-
--
|
|
50
|
+
-- The creator's owner membership is now seeded by create_board (ROOMOWN). The
|
|
51
|
+
-- `other` membership is inserted with the column DEFAULT (member) to exercise the
|
|
52
|
+
-- one-time backfill; member/admin get explicit roles for gate tests.
|
|
52
53
|
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id)
|
|
53
54
|
values
|
|
54
|
-
(v_board, v_creator, 'joined', v_creator),
|
|
55
55
|
(v_board, v_other, 'joined', v_creator);
|
|
56
56
|
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
57
57
|
values
|
|
@@ -37,9 +37,10 @@ begin
|
|
|
37
37
|
select board_id into v_board
|
|
38
38
|
from agent_board.create_board(v_owner, 'dg-board-' || v_suffix, 'DG Board', 'delegation test', '{}'::jsonb, 'private');
|
|
39
39
|
|
|
40
|
+
-- The owner's owner membership is now seeded by create_board (ROOMOWN); only the
|
|
41
|
+
-- member membership needs an explicit insert.
|
|
40
42
|
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
41
43
|
values
|
|
42
|
-
(v_board, v_owner, 'joined', v_owner, 'owner'),
|
|
43
44
|
(v_board, v_member, 'joined', v_owner, 'member');
|
|
44
45
|
|
|
45
46
|
-- Policy with only root_agent_id set -> defaults apply (child_ttl_ceiling 8h).
|
|
@@ -15,11 +15,11 @@ begin;
|
|
|
15
15
|
|
|
16
16
|
select plan(28);
|
|
17
17
|
|
|
18
|
-
-- New
|
|
18
|
+
-- New 14-arg signature exists (ROOMLINK 20260724000200 added a trailing invite code). -
|
|
19
19
|
select has_function(
|
|
20
20
|
'agent_board', 'enroll_federated_identity',
|
|
21
|
-
array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]'],
|
|
22
|
-
'enroll_federated_identity exists with the
|
|
21
|
+
array['text','text','text','jsonb','text','text','text','text','boolean','text','text','text[]','text[]','text'],
|
|
22
|
+
'enroll_federated_identity exists with the ROOMLINK-extended 14-arg PoP signature');
|
|
23
23
|
select hasnt_function(
|
|
24
24
|
'agent_board', 'enroll_federated_identity',
|
|
25
25
|
array['text','text','text','jsonb','text','text','text[]','text[]'],
|