@consiliency/agent-board-schema 1.7.0 → 1.10.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.
@@ -304,6 +304,18 @@
304
304
  {
305
305
  "id": "20260725000100_agent_board_roomdeleg_role_gate",
306
306
  "sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
307
+ },
308
+ {
309
+ "id": "20260726000100_agent_board_localauth_self_enroll",
310
+ "sha256": "38bcc86f0e6d4ad38bc76f5abe719456f5faa2ac5bd2ab24813c61d0160da664"
311
+ },
312
+ {
313
+ "id": "20260727000100_agent_board_roomuiapi",
314
+ "sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
315
+ },
316
+ {
317
+ "id": "20260728000100_agent_board_enroll_federated_human",
318
+ "sha256": "5f300449c29ec05c2cd3c2cd7eca622ca41e8b32b8856c5bc0695b5750a196e7"
307
319
  }
308
320
  ]
309
321
  }
@@ -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;
@@ -0,0 +1,122 @@
1
+ -- ROOMUIAPI: two read RPCs the room console/CLI need. Additive, no schema_version
2
+ -- bump (stays 8). Adds:
3
+ -- * agent_board.list_board_pending_memberships(p_board_id) — owner/admin-gated
4
+ -- reader that returns the board's pending (requested + invited) memberships,
5
+ -- latest-row-per-agent. board_memberships SELECT RLS is self-scoped, so a board
6
+ -- owner/admin cannot see EITHER pending state today; this reader is the sole
7
+ -- window. FLAT owner/admin gate ALWAYS, fail-closed on a null actor (mirrors
8
+ -- set_board_role_gate 20260725000100:46-55; NOT the write-RPC `if v_actor is not
9
+ -- null` bypass). Deliberately STRICTER than resolve_board_request: enumerating
10
+ -- requesters is disclosure, so no creator-identity exemption and no gate-off
11
+ -- relaxation. service_role (current_agent_id() = null) hits 42501 here; operators
12
+ -- read the table directly, bypassing RLS.
13
+ -- * agent_board.resolve_agent_handle(p_handle) — SECURITY INVOKER handle->identity
14
+ -- resolver. Runs under caller RLS, so by construction it cannot exceed
15
+ -- agent_board_authenticated_select_agents (20260512000003:49-54, which already
16
+ -- lets any authenticated active agent read every handle) — zero new exposure. The
17
+ -- status='active' filter is a UI affordance (invite accepts any status), not a
18
+ -- security boundary. Returns 0-or-1 row.
19
+
20
+ -- 1. list_board_pending_memberships (owner/admin-gated pending reader) --------------
21
+ create or replace function agent_board.list_board_pending_memberships(
22
+ p_board_id uuid
23
+ )
24
+ returns table(
25
+ membership_id uuid,
26
+ board_id uuid,
27
+ agent_id uuid,
28
+ handle text,
29
+ display_name text,
30
+ agent_type text,
31
+ membership_state text,
32
+ role text,
33
+ created_by_agent_id uuid,
34
+ requested_at timestamptz,
35
+ updated_at timestamptz
36
+ )
37
+ language plpgsql
38
+ stable
39
+ security definer
40
+ set search_path = agent_board, public
41
+ as $$
42
+ declare
43
+ v_actor uuid := agent_board.current_agent_id();
44
+ begin
45
+ -- FLAT owner/admin gate, ALWAYS. Fail closed on a null actor (suspended/unmapped
46
+ -- principal, or service_role) — mirrors set_board_role_gate, NOT the write-RPC
47
+ -- `if v_actor is not null` bypass. Authz precedes disclosure.
48
+ if v_actor is null
49
+ or not exists (
50
+ select 1
51
+ from agent_board.board_memberships bm
52
+ where bm.board_id = p_board_id
53
+ and bm.agent_id = v_actor
54
+ and bm.role in ('owner', 'admin')
55
+ and bm.membership_state in ('joined', 'subscribed')
56
+ ) then
57
+ raise exception 'only a board owner or admin may list pending memberships' using errcode = '42501';
58
+ end if;
59
+
60
+ -- Latest row per agent (in-place upserts append; the newest created_at is current
61
+ -- state), scoped to this board, filtered to the pending set. The dedup subquery is
62
+ -- ALSO scoped to p_board_id so no other board's rows can leak in.
63
+ return query
64
+ select
65
+ latest.id,
66
+ latest.board_id,
67
+ latest.agent_id,
68
+ a.handle,
69
+ a.display_name,
70
+ a.agent_type,
71
+ latest.membership_state,
72
+ latest.role,
73
+ latest.created_by_agent_id,
74
+ latest.created_at as requested_at,
75
+ latest.updated_at
76
+ from (
77
+ select distinct on (bm.agent_id) bm.*
78
+ from agent_board.board_memberships bm
79
+ where bm.board_id = p_board_id
80
+ order by bm.agent_id, bm.created_at desc
81
+ ) latest
82
+ join agent_board.agents a on a.id = latest.agent_id
83
+ where latest.membership_state in ('requested', 'invited')
84
+ order by latest.created_at desc;
85
+ end;
86
+ $$;
87
+
88
+ comment on function agent_board.list_board_pending_memberships(uuid) is
89
+ 'Owner/admin-gated reader for a board''s pending (requested + invited) memberships, latest-row-per-agent. FLAT owner/admin gate ALWAYS, fail-closed on a null actor (else 42501). Board-scoped: gate + dedup subquery both filter p_board_id. service_role hits 42501 (null current_agent_id()); operators read the table directly.';
90
+
91
+ revoke all on function agent_board.list_board_pending_memberships(uuid) from public, anon;
92
+ grant execute on function agent_board.list_board_pending_memberships(uuid) to authenticated, service_role;
93
+
94
+ -- 2. resolve_agent_handle (SECURITY INVOKER handle resolver) ------------------------
95
+ create or replace function agent_board.resolve_agent_handle(
96
+ p_handle text
97
+ )
98
+ returns table(
99
+ agent_id uuid,
100
+ handle text,
101
+ display_name text,
102
+ agent_type text
103
+ )
104
+ language sql
105
+ stable
106
+ security invoker
107
+ set search_path = agent_board, public
108
+ as $$
109
+ -- limit 1 is deterministic only via the UNIQUE handle constraint
110
+ -- (20260502000000:8); at most one active agent can own a given handle.
111
+ select a.id, a.handle, a.display_name, a.agent_type
112
+ from agent_board.agents a
113
+ where a.handle = p_handle
114
+ and a.status = 'active'
115
+ limit 1;
116
+ $$;
117
+
118
+ comment on function agent_board.resolve_agent_handle(text) is
119
+ 'SECURITY INVOKER handle->identity resolver: returns the 0-or-1 active agent for a handle, under caller RLS (cannot exceed agent_board_authenticated_select_agents). status=active is a UI affordance, not a security boundary. limit 1 deterministic via the UNIQUE handle constraint.';
120
+
121
+ revoke all on function agent_board.resolve_agent_handle(text) from public, anon;
122
+ grant execute on function agent_board.resolve_agent_handle(text) to authenticated, service_role;
@@ -0,0 +1,313 @@
1
+ -- FEDSESSION (Option A): service_role no-PoP federated-HUMAN provisioning substrate.
2
+ --
3
+ -- WHAT: enroll_federated_human() is the DB half of the /portal-session Edge Function
4
+ -- minter. A browser human who holds a live Portal OIDC session hits /portal-session; the
5
+ -- EF RS256-verifies the Portal token (aud/exp/sig against the issuer JWKS), then calls
6
+ -- THIS RPC (service_role) to establish a kind='human' agent keyed on (issuer, sub), and
7
+ -- finally self-signs a bounded (<=15 min) board-native access token (the same HS256
8
+ -- mechanism as /session — SESSION_SIGNING_SECRET == the project PostgREST JWT secret).
9
+ --
10
+ -- Option A = ACCESS-ONLY. There is NO GoTrue session and therefore NO refresh_token and
11
+ -- NO revocable auth.sessions row. The browser re-mints from /portal-session on expiry
12
+ -- (it holds a live Portal session throughout). This RPC never returns a token — the EF
13
+ -- mints it — and never touches a Portal token itself.
14
+ --
15
+ -- WHY a new RPC (cannot reuse the existing federation paths):
16
+ -- * enroll_federated_identity (20260719001200) HARD-REQUIRES p_pop_verified=true + a
17
+ -- raw Ed25519 key. A human holds no signing key, so that path cannot apply.
18
+ -- * self_enroll_human (20260726000100) binds the REAL auth.uid() (authenticated-only).
19
+ -- The Portal human is not authenticated to THIS project, so there is no auth.uid().
20
+ -- => a service_role, no-PoP, human-only RPC that mints a SYNTHETIC per-identity uid.
21
+ --
22
+ -- IDENTITY MODEL: the human is its OWN principal — auth_user_id = owner_user_id = a
23
+ -- SYNTHETIC uid derived idempotently from (issuer, sub). This mirrors self_enroll_human's
24
+ -- self-ownership, NOT enroll_federated_identity (where the human is only owner_user_id of
25
+ -- a separate key-identified agent). The synthetic-uid handle uses the SAME byte-exact
26
+ -- derivation as enroll_federated_identity:198-199, so a fed-human here and a federated
27
+ -- IDENTITY enrollment for the SAME (issuer, sub) converge on ONE shared owner uid (the
28
+ -- IDLINK substrate). L-4b (one agent per real auth user) is not violated: this uid is
29
+ -- synthetic, and enroll_federated_identity never puts an agent ON that owner uid.
30
+ --
31
+ -- KIND ENFORCEMENT IS STRUCTURAL: there is NO p_agent_kind argument — this RPC ONLY ever
32
+ -- inserts kind='human'/agent_type='human'. No agent_signing_keys row is created (humans
33
+ -- hold no key). Authority stays membership/role-gated: merely holding a session grants no
34
+ -- board access; current_agent_id() resolves auth.uid() -> the active human agent (with NO
35
+ -- agent_credential_id claim, the null-credential branch), and RLS then gates by
36
+ -- membership/role exactly as for any other principal.
37
+ --
38
+ -- F-13 REVOCATION LEVER (Option A's substitute for a revocable session row): suspend or
39
+ -- revoke the fed-human agent (agents.status <> 'active'). current_agent_id() then resolves
40
+ -- to NULL, so every RLS-gated read/write denies — any outstanding <=15-min token is inert
41
+ -- at the AUTHORITY layer within one token lifetime. The non-active guard below ALSO blocks
42
+ -- re-mint (no self-reactivation), so a suspended human cannot re-provision a fresh session.
43
+ --
44
+ -- F-10 TRUST BOUNDARY (named): Portal `sub`-issuance is the takeover boundary. A
45
+ -- compromised or mis-issued Portal `sub` for a human = full takeover of that human on the
46
+ -- board, INCLUDING ownership of any agent subtree the human owns (this fed-human is the
47
+ -- owner_user_id of it). The board delegates that decision to the Portal by design.
48
+ --
49
+ -- F-14 FLAG: because the fed-human is owner_user_id of its subtree, a FUTURE "act-as-owned"
50
+ -- RPC would turn this bounded session into act-as-fleet. No such RPC exists today; if one
51
+ -- is ever added it MUST NOT be reachable from a fedsession-minted token.
52
+ --
53
+ -- PII (F-12): the verified email is written ONLY to auth.users.raw_user_meta_data
54
+ -- (service-role-only), tagged provenance='portal-claim-unverified' (the board has NOT
55
+ -- independently verified possession — an IDLINK link-time flow MUST re-verify before
56
+ -- trusting it). agents.metadata.federation stores ONLY the boolean email_verified, NEVER
57
+ -- the address: the agents SELECT RLS is not row-scoped, so a stored email would be a
58
+ -- fleet-wide harvestable directory (the LOCALAUTH lesson, 20260726000100:15-22).
59
+ --
60
+ -- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
61
+
62
+ -- F-6: tag each federation-audit row with its provisioning mode. Additive nullable column
63
+ -- (enroll_federated_identity's insert leaves it null; the audit-content pgTAP uses
64
+ -- has_column, not a strict column-set pin, so this is safe). enroll_federated_human sets
65
+ -- mode='fedsession' for accountability — Option A has no revocable session row, so the
66
+ -- audit trail is the durable per-provision record.
67
+ alter table agent_board.enrollment_federation_audit
68
+ add column if not exists mode text;
69
+
70
+ create or replace function agent_board.enroll_federated_human(
71
+ p_issuer text,
72
+ p_audience text,
73
+ p_subject text,
74
+ p_human_identity_claim jsonb,
75
+ p_display_name text default null
76
+ )
77
+ returns table(
78
+ agent_id uuid,
79
+ auth_user_id uuid,
80
+ handle text,
81
+ kind text,
82
+ status text,
83
+ provider_id text,
84
+ created_at timestamptz
85
+ )
86
+ language plpgsql
87
+ security definer
88
+ set search_path = agent_board, public
89
+ as $$
90
+ declare
91
+ v_provider agent_board.identity_providers%rowtype;
92
+ v_handle text;
93
+ v_uid uuid;
94
+ v_agent agent_board.agents%rowtype;
95
+ v_existing agent_board.agents%rowtype;
96
+ v_email text := nullif(trim(coalesce(p_human_identity_claim->>'email', '')), '');
97
+ v_email_verified boolean;
98
+ begin
99
+ -- Derive the verified-email boolean from the claim (never trust it as board-proven
100
+ -- possession; it only records what the Portal asserted). A non-boolean/absent value
101
+ -- collapses to false.
102
+ begin
103
+ v_email_verified := coalesce((p_human_identity_claim->>'email_verified')::boolean, false);
104
+ exception when others then
105
+ v_email_verified := false;
106
+ end;
107
+
108
+ -- (1) service_role-only (defense in depth on top of the execute-grant revocation).
109
+ if not agent_board._is_service_role() then
110
+ raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
111
+ end if;
112
+
113
+ -- (2) A verified subject + human identity claim are always present from the EF.
114
+ if p_subject is null then
115
+ raise exception 'verified subject is required' using errcode = '22023';
116
+ end if;
117
+ if p_human_identity_claim is null then
118
+ raise exception 'human identity claim is required' using errcode = '22023';
119
+ end if;
120
+
121
+ -- (3) F-11: the synthetic-uid preimage is issuer||'|'||subject. Guard the separator so a
122
+ -- subject cannot inject a '|' to collide two distinct (issuer, subject) pairs onto one
123
+ -- owner uid. ALSO reject an email-shaped subject ('@'): the subject is stored VERBATIM in
124
+ -- agents.metadata.federation.subject, and the agents SELECT RLS is not row-scoped, so an
125
+ -- email `sub` would be a fleet-harvestable PII leak — the exact hazard the email is kept
126
+ -- out of agents.metadata to avoid. The operator's ENROLL_SUBJECT_CLAIM must resolve to an
127
+ -- opaque id; this is the fail-closed backstop, and the pgTAP PII guard asserts BOTH the
128
+ -- '@'-rejection and metadata carrying no '@'.
129
+ if position('|' in p_subject) > 0 then
130
+ raise exception 'verified subject must not contain a pipe separator character'
131
+ using errcode = '22023';
132
+ end if;
133
+ if position('@' in p_subject) > 0 then
134
+ raise exception 'verified subject must be an opaque id, not an email/@-bearing value'
135
+ using errcode = '22023';
136
+ end if;
137
+
138
+ -- (4) provider gate on (issuer, audience): must exist + supported + policy_approved.
139
+ -- Defense in depth atop the EF's signature verify (mirrors 20260719001200:154-181).
140
+ select *
141
+ into v_provider
142
+ from agent_board.identity_providers
143
+ where issuer = p_issuer
144
+ and audience = p_audience
145
+ limit 1;
146
+ if v_provider.id is null then
147
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
148
+ end if;
149
+ if not v_provider.supported then
150
+ raise exception 'identity provider is not supported' using errcode = '42501';
151
+ end if;
152
+ if not v_provider.policy_approved then
153
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
154
+ end if;
155
+
156
+ -- (5) HUMAN-ONLY ceiling. allowed_agent_kinds is permissive on empty/null (the ceiling
157
+ -- is "unset"); reject ONLY when it is non-empty AND does not include 'human'.
158
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
159
+ and not ('human' = any (v_provider.allowed_agent_kinds)) then
160
+ raise exception 'provider ceiling does not permit the human kind' using errcode = '42501';
161
+ end if;
162
+
163
+ -- (6) SYNTHETIC uid — byte-identical derivation to enroll_federated_identity:198-199
164
+ -- (extensions.digest, since the pinned search_path lacks extensions). Idempotent per
165
+ -- (issuer, sub) so re-provisioning converges to ONE owner auth.users row = the shared
166
+ -- IDLINK uid. Pass issuer/subject VERBATIM (no trim/normalize) to stay byte-identical.
167
+ v_handle := 'fed-human-' || substr(
168
+ encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'),
169
+ 1, 32);
170
+ v_uid := agent_board._ensure_agent_auth_user(v_handle);
171
+
172
+ -- (7) Idempotent + no-self-reactivation, keyed on auth_user_id = the synthetic uid (ANY
173
+ -- status). An ACTIVE row -> return it (idempotent). A NON-active row -> fail closed
174
+ -- (42501): a suspended/revoked human must NOT re-mint a session (mirrors
175
+ -- 20260726000100:78-81 and reinforces the F-13 revocation lever). F-5: assert the row is
176
+ -- human before returning it as a human session (a non-human agent on this owner uid must
177
+ -- never yield a "human" provision).
178
+ select *
179
+ into v_existing
180
+ from agent_board.agents a
181
+ where a.auth_user_id = v_uid
182
+ order by (a.status = 'active') desc, a.created_at asc
183
+ limit 1;
184
+ if found then
185
+ if v_existing.status = 'active' then
186
+ if v_existing.kind <> 'human' then
187
+ raise exception 'existing agent on this identity is not human; refusing human session'
188
+ using errcode = '42501';
189
+ end if;
190
+ -- Refresh the verified-email snapshot on re-provision (MERGE, see step 9).
191
+ if v_email is not null then
192
+ update auth.users u
193
+ set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
194
+ || jsonb_build_object(
195
+ 'fedsession_email', v_email,
196
+ 'fedsession_email_verified', v_email_verified,
197
+ 'fedsession_email_provenance', 'portal-claim-unverified')
198
+ where u.id = v_uid;
199
+ end if;
200
+ insert into agent_board.enrollment_federation_audit(
201
+ issuer, audience, subject, human_identity_claim, provider_id,
202
+ agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
203
+ values (
204
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
205
+ v_existing.id, v_uid, '{}', '{}', 'fedsession');
206
+ return query
207
+ select v_existing.id, v_existing.auth_user_id, v_existing.handle,
208
+ v_existing.kind::text, v_existing.status, v_provider.id, v_existing.created_at;
209
+ return;
210
+ else
211
+ raise exception 'a non-active identity already exists for this human; contact an operator'
212
+ using errcode = '42501';
213
+ end if;
214
+ end if;
215
+
216
+ -- (8) Create the kind='human' agent (self-owned: auth_user_id = owner_user_id = uid).
217
+ -- No agent_signing_keys row (humans hold no key). metadata.federation carries ONLY the
218
+ -- boolean email_verified — never the address (PII / fleet-readable RLS).
219
+ begin
220
+ insert into agent_board.agents(
221
+ agent_type,
222
+ kind,
223
+ handle,
224
+ display_name,
225
+ status,
226
+ auth_user_id,
227
+ owner_user_id,
228
+ metadata
229
+ )
230
+ values (
231
+ 'human',
232
+ 'human'::agent_board.agent_kind,
233
+ v_handle,
234
+ coalesce(nullif(trim(p_display_name), ''), 'Human'),
235
+ 'active',
236
+ v_uid,
237
+ v_uid,
238
+ jsonb_build_object(
239
+ 'federation', jsonb_build_object(
240
+ 'mode', 'fedsession',
241
+ 'issuer', p_issuer,
242
+ 'audience', p_audience,
243
+ 'subject', p_subject,
244
+ 'providerId', v_provider.id,
245
+ 'email_verified', v_email_verified,
246
+ 'enrolledAt', now()
247
+ )
248
+ )
249
+ )
250
+ returning * into v_agent;
251
+ exception when unique_violation then
252
+ -- Concurrent first-provision race on the synthetic uid. F-5: re-select the ACTIVE row
253
+ -- and ASSERT it is human (fail closed otherwise) — never return a non-human agent as a
254
+ -- human session.
255
+ select *
256
+ into v_existing
257
+ from agent_board.agents a
258
+ where a.auth_user_id = v_uid
259
+ and a.status = 'active'
260
+ and a.kind = 'human'
261
+ limit 1;
262
+ if found then
263
+ v_agent := v_existing;
264
+ else
265
+ raise exception 'could not provision federated human (race or non-human on owner uid), please retry'
266
+ using errcode = '40001';
267
+ end if;
268
+ end;
269
+
270
+ -- (9) F-12/F-4: store the verified email ONLY in auth.users.raw_user_meta_data
271
+ -- (service-role-only), MERGED so _ensure_agent_auth_user's {slug, issuer} is preserved,
272
+ -- tagged provenance='portal-claim-unverified'. The definer owner (postgres) owns auth.*.
273
+ if v_email is not null then
274
+ update auth.users u
275
+ set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
276
+ || jsonb_build_object(
277
+ 'fedsession_email', v_email,
278
+ 'fedsession_email_verified', v_email_verified,
279
+ 'fedsession_email_provenance', 'portal-claim-unverified')
280
+ where u.id = v_uid;
281
+ end if;
282
+
283
+ -- (10) F-6: append the audit row (mode='fedsession'). No requested/granted scopes on the
284
+ -- human path (authority is membership/role, not enrollment scopes) -> empty arrays.
285
+ insert into agent_board.enrollment_federation_audit(
286
+ issuer, audience, subject, human_identity_claim, provider_id,
287
+ agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
288
+ values (
289
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
290
+ v_agent.id, v_uid, '{}', '{}', 'fedsession');
291
+
292
+ return query
293
+ select v_agent.id, v_agent.auth_user_id, v_agent.handle,
294
+ v_agent.kind::text, v_agent.status, v_provider.id, v_agent.created_at;
295
+ end;
296
+ $$;
297
+
298
+ comment on function agent_board.enroll_federated_human(text, text, text, jsonb, text) is
299
+ 'FEDSESSION (Option A): service_role no-PoP federated-HUMAN provisioning. Re-gates the '
300
+ '(issuer,audience) provider allowlist/policy + the human-only ceiling, establishes a '
301
+ 'kind=human self-owned agent (auth_user_id = owner_user_id = a SYNTHETIC uid derived '
302
+ 'byte-identically to enroll_federated_identity per (issuer,sub) = the IDLINK substrate), '
303
+ 'stores the verified email ONLY in auth.users.raw_user_meta_data (provenance '
304
+ 'portal-claim-unverified) with only the boolean email_verified in agents.metadata, and '
305
+ 'writes a mode=fedsession audit row. No signing key. Idempotent for an active identity; '
306
+ 'a non-active identity fails closed (42501, no self-reactivation) — the F-13 revocation '
307
+ 'lever (suspend the agent -> current_agent_id null -> RLS denies + re-mint blocked). '
308
+ 'service_role-only; never returns or touches a Portal token.';
309
+
310
+ revoke all on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
311
+ from public, anon, authenticated;
312
+ grant execute on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
313
+ to service_role;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.7.0",
3
+ "version": "1.10.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",