@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.
@@ -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;
@@ -0,0 +1,172 @@
1
+ -- LOCALAUTH: Portal-free standalone human self-enrollment.
2
+ --
3
+ -- WHAT: self_enroll_human() lets a browser human who has signed in with the
4
+ -- target Supabase project's OWN auth (magic-link/OTP/OAuth/passkey) bind their
5
+ -- auth.uid() to a kind='human' agent_board.agents row from their own
6
+ -- authenticated session — no Portal, no service_role, no operator step per user.
7
+ --
8
+ -- IDENTITY MODEL: the human is SELF-owned (owner_user_id = auth_user_id =
9
+ -- auth.uid()). Contrast enroll_federated_identity, where auth_user_id is a
10
+ -- synthetic per-agent uid and owner_user_id is the real human. The only trusted
11
+ -- identity input is auth.uid(); there is NO auth_user_id/agent_id argument, so a
12
+ -- caller cannot self-assign someone else's identity. Spoofing auth.uid() would
13
+ -- require the JWT-signing secret or the service_role key — that is inherent to
14
+ -- Supabase, not a boundary introduced here.
15
+ --
16
+ -- PII: metadata.localauth stores ONLY a boolean email_verified (derived from
17
+ -- auth.users.email_confirmed_at), never the plaintext email. The agents SELECT
18
+ -- RLS is not row-scoped, so a stored email would be a fleet-wide harvestable
19
+ -- directory. The canonical human email lives in auth.users (bound via the
20
+ -- agent's auth_user_id); an IDLINK flow reads it there via service_role AND MUST
21
+ -- RE-VERIFY email ownership at link time (email_confirmed_at can be admin- or
22
+ -- auto-set, so it is not by itself proof of possession).
23
+ --
24
+ -- OPERATOR / SCOPE (L-10): enabling a Supabase Auth sign-in method in the target
25
+ -- project is a net-new OPERATOR config task. The sign-in UI / browser app is
26
+ -- EXTERNAL / consumer-owned — there is no in-repo frontend. LOCALAUTH ships the
27
+ -- RPC + the zero-node `@consiliency/agent-board-client/localauth` helper ONLY.
28
+ --
29
+ -- Additive, forward-only. Writes no schema_version (stays 8).
30
+
31
+ create or replace function agent_board.self_enroll_human(
32
+ p_display_name text default null
33
+ )
34
+ returns table(
35
+ agent_id uuid,
36
+ handle text,
37
+ kind text,
38
+ status text,
39
+ auth_user_id uuid,
40
+ created_at timestamptz
41
+ )
42
+ language plpgsql
43
+ security definer
44
+ set search_path = agent_board, public
45
+ as $$
46
+ declare
47
+ v_uid uuid := auth.uid();
48
+ v_existing agent_board.agents%rowtype;
49
+ v_email_confirmed_at timestamptz;
50
+ v_email_verified boolean;
51
+ v_handle text;
52
+ v_agent agent_board.agents%rowtype;
53
+ begin
54
+ -- Fail closed: no authenticated Supabase user -> no identity to bind. This also
55
+ -- covers service_role and any superuser/operator path (auth.uid() is null for
56
+ -- them), which is intentional: LOCALAUTH is a self-service authenticated path.
57
+ if v_uid is null then
58
+ raise exception 'self_enroll_human requires an authenticated Supabase user (auth.uid() is null)'
59
+ using errcode = '42501';
60
+ end if;
61
+
62
+ -- Idempotency + no-self-reactivation guard, keyed on auth_user_id (ANY status).
63
+ -- An ACTIVE row -> return it (idempotent, no duplicate). A NON-active row
64
+ -- (status <> 'active', i.e. inactive or suspended per the agents status enum)
65
+ -- -> fail closed: a deactivated/suspended human must NOT self-reactivate; an
66
+ -- operator must restore them.
67
+ select * into v_existing
68
+ from agent_board.agents a
69
+ where a.auth_user_id = v_uid
70
+ order by (a.status = 'active') desc, a.created_at asc
71
+ limit 1;
72
+ if found then
73
+ if v_existing.status = 'active' then
74
+ return query
75
+ select v_existing.id, v_existing.handle, v_existing.kind::text,
76
+ v_existing.status, v_existing.auth_user_id, v_existing.created_at;
77
+ return;
78
+ else
79
+ raise exception 'a non-active identity already exists for this user; contact an operator'
80
+ using errcode = '42501';
81
+ end if;
82
+ end if;
83
+
84
+ -- Verified-email as a BOOLEAN only. Read email_confirmed_at server-side (never
85
+ -- trust an unverified JWT email claim); store the derived boolean, never the
86
+ -- address.
87
+ select u.email_confirmed_at into v_email_confirmed_at
88
+ from auth.users u
89
+ where u.id = v_uid;
90
+ v_email_verified := v_email_confirmed_at is not null;
91
+
92
+ -- Deterministic globally-unique handle: 16 hex (64-bit) of sha256(uid).
93
+ v_handle := 'local-human-' || substr(encode(extensions.digest(v_uid::text, 'sha256'), 'hex'), 1, 16);
94
+
95
+ begin
96
+ insert into agent_board.agents(
97
+ agent_type,
98
+ kind,
99
+ handle,
100
+ display_name,
101
+ status,
102
+ auth_user_id,
103
+ owner_user_id,
104
+ metadata
105
+ )
106
+ values (
107
+ 'human',
108
+ 'human'::agent_board.agent_kind,
109
+ v_handle,
110
+ coalesce(nullif(trim(p_display_name), ''), 'Human'),
111
+ 'active',
112
+ v_uid,
113
+ v_uid,
114
+ jsonb_build_object(
115
+ 'localauth', jsonb_build_object(
116
+ 'self_enrolled_at', now(),
117
+ 'email_verified', v_email_verified
118
+ )
119
+ )
120
+ )
121
+ returning * into v_agent;
122
+ exception when unique_violation then
123
+ -- Concurrent first-enroll race: another session inserted the active row for
124
+ -- this uid between our select and insert. Re-select and return it.
125
+ select * into v_existing
126
+ from agent_board.agents a
127
+ where a.auth_user_id = v_uid
128
+ and a.status = 'active'
129
+ limit 1;
130
+ if found then
131
+ return query
132
+ select v_existing.id, v_existing.handle, v_existing.kind::text,
133
+ v_existing.status, v_existing.auth_user_id, v_existing.created_at;
134
+ return;
135
+ end if;
136
+ -- Not the uid race -> a genuine handle collision (astronomically rare for a
137
+ -- 64-bit hash). Surface a clean, distinct, retryable error rather than the
138
+ -- opaque raw unique_violation.
139
+ raise exception 'could not self-enroll (handle collision), please retry'
140
+ using errcode = '40001';
141
+ end;
142
+
143
+ return query
144
+ select v_agent.id, v_agent.handle, v_agent.kind::text,
145
+ v_agent.status, v_agent.auth_user_id, v_agent.created_at;
146
+ end;
147
+ $$;
148
+
149
+ comment on function agent_board.self_enroll_human(text) is
150
+ 'Portal-free self-service: binds auth.uid() to a kind=human agent (self-owned: '
151
+ 'owner_user_id = auth_user_id = auth.uid()). Idempotent for an active identity; '
152
+ 'a non-active identity fails closed (42501, no self-reactivation). Stores only a '
153
+ 'BOOLEAN metadata.localauth.email_verified (never the plaintext email). Fails '
154
+ 'closed with 42501 when auth.uid() is null (unauthenticated / service_role / '
155
+ 'operator). authenticated-only; not a signed write.';
156
+
157
+ -- authenticated-only. service_role is explicitly revoked (belt-and-suspenders on
158
+ -- top of the auth.uid()-null fail-close): the service path is
159
+ -- enroll_federated_identity, not this self-service RPC.
160
+ revoke all on function agent_board.self_enroll_human(text) from public, anon, service_role;
161
+ grant execute on function agent_board.self_enroll_human(text) to authenticated;
162
+
163
+ -- L-4b DB backstop: one agent per REAL auth user, enforced at the DB (not just the
164
+ -- app guard above). SAFE because federated agents use SYNTHETIC per-agent uids
165
+ -- (_ensure_agent_auth_user), so only real-uid rows are ever constrained. This is a
166
+ -- DIFFERENT guarantee than the pre-existing partial-unique
167
+ -- idx_agent_board_agents_auth_user_id_active_unique (which is scoped to
168
+ -- status='active'); this one admits at most one row per uid EVER. On real data a
169
+ -- failure here means a genuine duplicate — correct fail-loud.
170
+ create unique index if not exists idx_agent_board_agents_auth_user_id_unique
171
+ on agent_board.agents(auth_user_id)
172
+ where auth_user_id is not null;