@consiliency/agent-board-schema 1.3.0 → 1.4.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.
Files changed (62) hide show
  1. package/bin/agent-board-schema.js +0 -0
  2. package/fixtures/already-applied.json +104 -0
  3. package/migrations/20260717000100_agent_board_board_role.sql +180 -0
  4. package/migrations/20260717000200_agent_board_thread_acl.sql +149 -0
  5. package/migrations/20260717000300_agent_board_agent_lineage.sql +54 -0
  6. package/migrations/20260717000400_agent_board_delegation_policies.sql +175 -0
  7. package/migrations/20260718000100_agent_board_send_authz_config.sql +172 -0
  8. package/migrations/20260718000200_agent_board_send_role_gate.sql +804 -0
  9. package/migrations/20260718000300_agent_board_fleet_policy_default_deny.sql +192 -0
  10. package/migrations/20260718000400_agent_board_grant_ceiling_wiring.sql +58 -0
  11. package/migrations/20260718000500_agent_board_fleet_agent_session_class.sql +24 -0
  12. package/migrations/20260718000600_agent_board_mint_fleet_agent_session.sql +172 -0
  13. package/migrations/20260718000700_agent_board_nonfleet_session_reaper.sql +35 -0
  14. package/migrations/20260718000800_agent_board_regrant_scoped_send.sql +24 -0
  15. package/migrations/20260718000900_agent_board_agents_status_revoked.sql +24 -0
  16. package/migrations/20260718001000_agent_board_delegation_enforcement.sql +296 -0
  17. package/migrations/20260718001100_agent_board_revoke_agent_subtree.sql +238 -0
  18. package/migrations/20260718001200_agent_board_resigning_refresh.sql +129 -0
  19. package/migrations/20260718001300_agent_board_identity_providers.sql +70 -0
  20. package/migrations/20260718001400_agent_board_enrollment_federation_audit.sql +50 -0
  21. package/migrations/20260718001500_agent_board_enroll_federated_identity.sql +220 -0
  22. package/migrations/20260719000100_agent_board_signing_challenge_pending_cap.sql +124 -0
  23. package/migrations/20260719000200_agent_board_signing_challenge_reaper.sql +34 -0
  24. package/migrations/20260719000300_agent_board_enroll_rate_cap.sql +72 -0
  25. package/migrations/20260719001000_agent_board_identity_providers_portal_contract.sql +38 -0
  26. package/migrations/20260719001100_agent_board_enroll_federated_identity_pop.sql +267 -0
  27. package/migrations/20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql +296 -0
  28. package/migrations/20260719001300_agent_board_enroll_rate_events_reaper_rls.sql +62 -0
  29. package/package.json +9 -9
  30. package/schema-manifest.json +238 -4
  31. package/src/tooling.test.mjs +42 -1
  32. package/tests/agent_board_agent_lineage.test.sql +73 -0
  33. package/tests/agent_board_agents_status_revoked.test.sql +60 -0
  34. package/tests/agent_board_auth_rpcs.test.sql +17 -16
  35. package/tests/agent_board_board_role.test.sql +129 -0
  36. package/tests/agent_board_bundle_endpoint_compat.test.sql +18 -6
  37. package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +8 -8
  38. package/tests/agent_board_delegation_enforcement.test.sql +175 -0
  39. package/tests/agent_board_delegation_policies.test.sql +119 -0
  40. package/tests/agent_board_enroll_federated_identity.test.sql +217 -0
  41. package/tests/agent_board_enroll_pop_binding.test.sql +131 -0
  42. package/tests/agent_board_enroll_rate_cap.test.sql +53 -0
  43. package/tests/agent_board_enrollment_audit_content.test.sql +134 -0
  44. package/tests/agent_board_fleet_agent_session_class.test.sql +64 -0
  45. package/tests/agent_board_fleet_policy_default_deny.test.sql +149 -0
  46. package/tests/agent_board_fleet_registry.test.sql +2 -2
  47. package/tests/agent_board_identity_providers.test.sql +104 -0
  48. package/tests/agent_board_inbox_rpcs.test.sql +6 -6
  49. package/tests/agent_board_mint_fleet_agent_session.test.sql +290 -0
  50. package/tests/agent_board_nonfleet_session_reaper.test.sql +61 -0
  51. package/tests/agent_board_portal_offline_invariant.test.sql +130 -0
  52. package/tests/agent_board_resigning_refresh.test.sql +73 -0
  53. package/tests/agent_board_revoke_agent_subtree.test.sql +188 -0
  54. package/tests/agent_board_schema_version.test.sql +2 -2
  55. package/tests/agent_board_schema_version_rpc.test.sql +1 -1
  56. package/tests/agent_board_scoped_send_regrant.test.sql +138 -0
  57. package/tests/agent_board_send_authz_config.test.sql +84 -0
  58. package/tests/agent_board_send_authz_matrix.test.sql +159 -0
  59. package/tests/agent_board_send_role_gate.test.sql +177 -0
  60. package/tests/agent_board_signing_challenge_pending_cap.test.sql +87 -0
  61. package/tests/agent_board_signing_challenge_reaper.test.sql +74 -0
  62. package/tests/agent_board_thread_acl.test.sql +129 -0
@@ -0,0 +1,267 @@
1
+ -- MBPORTALGATE SL-2 (IF-0-MBPORTALGATE-4): rework enroll_federated_identity to the
2
+ -- real principal model — proof-of-possession at enrollment, one human -> N agents.
3
+ --
4
+ -- WHAT CHANGES vs the prior 8-arg RPC (20260718001500):
5
+ -- * The Portal credential is a VERIFIED access-token JWT; the /enroll EF ALSO
6
+ -- Ed25519-verifies a proof-of-possession signature over the token bytes and passes
7
+ -- p_pop_verified=true (mirrors record_signing_key_proof_result's verify/record
8
+ -- split). This RPC re-gates p_pop_verified and refuses false.
9
+ -- * The subject (sub) establishes a HUMAN OWNER auth.users row idempotently per
10
+ -- (issuer, sub), recorded as agents.owner_user_id. The AGENT is a SEPARATE agents
11
+ -- row identified by the enrolled Ed25519 key — NEVER keyed on sub. Enrolling the
12
+ -- same human (issuer, sub) with a second key creates a SECOND agent and does NOT
13
+ -- mutate the first. Each agent gets its OWN auth_user_id (session-usable via its
14
+ -- enrolled key, exactly like provision_agent), so revoking one agent revokes
15
+ -- neither the human nor a sibling.
16
+ -- * The enrolled public key lands in agent_signing_keys as status='active',
17
+ -- proof_state='proven' (satisfies agent_signing_keys_active_requires_proof). The
18
+ -- RPC RECOMPUTES the sha256:base64url fingerprint in-DB from p_agent_public_key and
19
+ -- ASSERTS it equals p_agent_public_key_fingerprint (defense-in-depth against a
20
+ -- direct service_role caller — the EF is the trusted computor, the RPC re-gates),
21
+ -- and dedups on the agent_signing_keys.fingerprint UNIQUE index (duplicate -> raise).
22
+ --
23
+ -- Returned auth_user_id = the AGENT's own board principal (session-usable), NOT the
24
+ -- human owner (the human is owner_user_id). This is the sanctioned impl choice
25
+ -- IF-0-MBPORTALGATE-4 defers to pgTAP, pinned by the N-agents + revoke-isolation cases.
26
+ --
27
+ -- Fingerprint derivation is byte-identical to the delegation-enforcement child-key
28
+ -- fingerprint (20260718001000): sha256 over the UTF-8 bytes of the base64url public-key
29
+ -- STRING, base64url-encoded (unpadded), 'sha256:'-prefixed — so the EF's WebCrypto
30
+ -- computation and this DB computation round-trip exactly.
31
+ --
32
+ -- service_role-only. Additive forward-only migration; writes no schema_version (stays 8).
33
+ -- The prior 8-arg function is DROPped (return type widens 4->5 cols, so create-or-replace
34
+ -- cannot be used) and replaced. No migration/EF/test FILE is deleted.
35
+
36
+ drop function if exists agent_board.enroll_federated_identity(
37
+ text, text, text, jsonb, text, text, text[], text[]);
38
+
39
+ create or replace function agent_board.enroll_federated_identity(
40
+ p_issuer text,
41
+ p_audience text,
42
+ p_subject text,
43
+ p_human_identity_claim jsonb,
44
+ p_slug text,
45
+ p_agent_public_key text,
46
+ p_agent_public_key_format text,
47
+ p_agent_public_key_fingerprint text,
48
+ p_pop_verified boolean,
49
+ p_actor_sub text default null,
50
+ p_agent_kind text default 'agent',
51
+ p_requested_scopes text[] default '{}',
52
+ p_requested_board_scopes text[] default '{}'
53
+ )
54
+ returns table(
55
+ agent_id uuid,
56
+ auth_user_id uuid,
57
+ provider_id text,
58
+ signing_key_id uuid,
59
+ granted_scopes text[]
60
+ )
61
+ language plpgsql
62
+ security definer
63
+ set search_path = agent_board, public
64
+ as $$
65
+ declare
66
+ v_slug text;
67
+ v_kind text;
68
+ v_provider agent_board.identity_providers%rowtype;
69
+ v_owner_user_id uuid;
70
+ v_auth_user_id uuid;
71
+ v_agent_handle text;
72
+ v_agent_id uuid;
73
+ v_signing_key_id uuid;
74
+ v_fingerprint text;
75
+ v_key_hex text;
76
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
77
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
78
+ begin
79
+ -- service_role-only (defense in depth on top of the execute-grant revocation).
80
+ if not agent_board._is_service_role() then
81
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
82
+ end if;
83
+
84
+ -- A verified subject + human identity claim are always present from the EF.
85
+ if p_subject is null then
86
+ raise exception 'verified subject is required' using errcode = '22023';
87
+ end if;
88
+ if p_human_identity_claim is null then
89
+ raise exception 'human identity claim is required' using errcode = '22023';
90
+ end if;
91
+
92
+ -- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
93
+ -- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
94
+ if p_pop_verified is not true then
95
+ raise exception 'proof-of-possession was not verified; enrollment refused'
96
+ using errcode = '42501';
97
+ end if;
98
+
99
+ -- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
100
+ -- base64url chars). Reject the all-zero small-order placeholder.
101
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
102
+ raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
103
+ end if;
104
+ if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
105
+ raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
106
+ using errcode = '22023';
107
+ end if;
108
+ if p_agent_public_key = repeat('A', 43) then
109
+ raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
110
+ using errcode = '22023';
111
+ end if;
112
+
113
+ -- (2) Recompute-and-assert the fingerprint in-DB (byte-identical to the EF/WebCrypto
114
+ -- and to the delegation-enforcement derivation). A tampered fingerprint -> raise.
115
+ v_fingerprint := 'sha256:' || rtrim(
116
+ translate(encode(extensions.digest(convert_to(p_agent_public_key, 'UTF8'), 'sha256'), 'base64'), '+/', '-_'),
117
+ '=');
118
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
119
+ raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
120
+ using errcode = '22023';
121
+ end if;
122
+
123
+ -- (3) act.sub is audit-only, but when present it must be a real value.
124
+ if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
125
+ raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
126
+ end if;
127
+
128
+ v_slug := agent_board._normalize_provision_slug(p_slug);
129
+ v_kind := lower(coalesce(trim(p_agent_kind), ''));
130
+
131
+ -- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
132
+ select *
133
+ into v_provider
134
+ from agent_board.identity_providers
135
+ where issuer = p_issuer
136
+ and audience = p_audience
137
+ limit 1;
138
+ if v_provider.id is null then
139
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
140
+ end if;
141
+ if not v_provider.supported then
142
+ raise exception 'identity provider is not supported' using errcode = '42501';
143
+ end if;
144
+ if not v_provider.policy_approved then
145
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
146
+ end if;
147
+
148
+ -- (5) scope-ceiling + kind gate. 'system' is never federatable.
149
+ if v_kind = 'system' then
150
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
151
+ end if;
152
+ if v_kind not in ('human', 'agent') then
153
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
154
+ end if;
155
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
156
+ and not (v_kind = any (v_provider.allowed_agent_kinds)) then
157
+ raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
158
+ end if;
159
+ if not (v_requested_scopes <@ v_provider.allowed_scopes) then
160
+ raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
161
+ end if;
162
+ if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
163
+ raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
164
+ end if;
165
+
166
+ -- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
167
+ -- Pre-check for a clean error; the UNIQUE index is the hard guarantee.
168
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
169
+ raise exception 'this signing key is already enrolled (duplicate fingerprint)'
170
+ using errcode = '23505';
171
+ end if;
172
+
173
+ -- (7) HUMAN OWNER — established idempotently per (issuer, sub) so re-enrollment of the
174
+ -- same human converges to ONE owner auth.users row (recorded as owner_user_id).
175
+ v_owner_user_id := agent_board._ensure_agent_auth_user(
176
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
177
+
178
+ -- (8) AGENT handle — unique per enrolled key. Use the slug; on collision (a human's
179
+ -- 2nd agent under the same slug, or an unrelated handle) disambiguate with a
180
+ -- deterministic per-key hex fragment so a distinct agent row is always created.
181
+ v_key_hex := encode(extensions.digest(convert_to(p_agent_public_key, 'UTF8'), 'sha256'), 'hex');
182
+ v_agent_handle := v_slug;
183
+ if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
184
+ v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
185
+ end if;
186
+
187
+ -- (9) the agent's OWN board principal (session-usable via its enrolled key).
188
+ v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
189
+
190
+ -- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
191
+ insert into agent_board.agents(
192
+ agent_type,
193
+ kind,
194
+ handle,
195
+ display_name,
196
+ status,
197
+ auth_user_id,
198
+ owner_user_id,
199
+ metadata
200
+ )
201
+ values (
202
+ v_kind,
203
+ v_kind::agent_board.agent_kind,
204
+ v_agent_handle,
205
+ agent_board._default_provision_display_name(v_agent_handle),
206
+ 'active',
207
+ v_auth_user_id,
208
+ v_owner_user_id,
209
+ jsonb_build_object(
210
+ 'federation', jsonb_build_object(
211
+ 'issuer', p_issuer,
212
+ 'audience', p_audience,
213
+ 'subject', p_subject,
214
+ 'actorSub', p_actor_sub,
215
+ 'providerId', v_provider.id,
216
+ 'signingKeyFingerprint', v_fingerprint,
217
+ 'requestedScopes', to_jsonb(v_requested_scopes),
218
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
219
+ 'popVerified', true,
220
+ 'enrolledAt', now()
221
+ )
222
+ )
223
+ )
224
+ returning id into v_agent_id;
225
+
226
+ -- (11) enroll the public key as active + proven (satisfies active_requires_proof).
227
+ insert into agent_board.agent_signing_keys(
228
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
229
+ status, proof_state, metadata
230
+ )
231
+ values (
232
+ gen_random_uuid(), v_agent_id, 'portal-enroll', 1, p_agent_public_key,
233
+ p_agent_public_key_format, v_fingerprint,
234
+ 'active', 'proven',
235
+ jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
236
+ )
237
+ returning id into v_signing_key_id;
238
+
239
+ -- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
240
+ insert into agent_board.enrollment_federation_audit(
241
+ issuer, audience, subject, human_identity_claim, provider_id,
242
+ agent_id, auth_user_id, requested_scopes, granted_scopes
243
+ )
244
+ values (
245
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
246
+ v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
247
+ );
248
+
249
+ return query
250
+ select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes;
251
+ end;
252
+ $$;
253
+
254
+ comment on function agent_board.enroll_federated_identity(
255
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[]) is
256
+ 'PoP-at-enrollment federated identity: re-gates p_pop_verified + allowlist/policy/scope, '
257
+ 'recomputes-and-asserts the key fingerprint, establishes ONE human owner (owner_user_id) '
258
+ 'idempotently per (issuer,sub), creates a SEPARATE key-identified agent (own auth_user_id) '
259
+ 'per enrolled key, and enrolls the key active+proven. service_role-only; returns a '
260
+ 'board-native agent + signing_key_id, never a Portal token.';
261
+
262
+ revoke all on function agent_board.enroll_federated_identity(
263
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[])
264
+ from public, anon, authenticated;
265
+ grant execute on function agent_board.enroll_federated_identity(
266
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[])
267
+ to service_role;
@@ -0,0 +1,296 @@
1
+ -- MBPORTAL PR #32 reconcile (B1 + B2): server-authoritative identity + canonical
2
+ -- raw-byte fingerprint for enroll_federated_identity.
3
+ --
4
+ -- WHY (two blocking-class defects the mock hid, both falsifying "proven end-to-end"):
5
+ --
6
+ -- B1 — the enrolled agent could never mint a /session. The RPC stores the enrolled
7
+ -- key as key_id='portal-enroll', key_version=1 and (on handle collision) rewrites the
8
+ -- agent handle, but the prior return table surfaced NEITHER, so the client kept its
9
+ -- locally-generated key_id + the raw input slug and /session (which resolves by the
10
+ -- exact (agents.handle, key_id, key_version)) found no row. FIX: the server is
11
+ -- authoritative for identity — the return table now ALSO yields key_id, key_version,
12
+ -- and the post-normalization/disambiguation agent_handle, and the client mints
13
+ -- /session with exactly those (keeping only its private key from the keystore).
14
+ --
15
+ -- B2 — the fingerprint (the sole uniqueness/dedup/revocation key) was computed over
16
+ -- the base64url STRING, not the decoded key bytes. A 32-byte Ed25519 key's 43-char
17
+ -- base64url encoding has 2 don't-care low bits, so up to 4 distinct strings decode to
18
+ -- the SAME key, all pass the regex, all verify one PoP signature, yet hash to 4
19
+ -- different fingerprints -> the UNIQUE index admits 4 rows: one keypair backing up to
20
+ -- 4 agents, and revoking one leaves the siblings alive. FIX: decode agent_public_key
21
+ -- to 32 raw bytes, REJECT if a canonical re-encode != the input string (kills the
22
+ -- 4-strings-one-key aliasing and the non-canonical all-zero-placeholder bypass), and
23
+ -- compute the fingerprint over the RAW 32 bytes. This matches the client child_keygen
24
+ -- fingerprint (already raw-byte) and is mirrored in the /enroll EF.
25
+ --
26
+ -- Additive, forward-only. The return table WIDENS 5 -> 8 columns, so create-or-replace
27
+ -- cannot be used: the released 13-arg function (20260719001100) is DROPped and recreated
28
+ -- here. The released 001100 bytes are NOT edited. Args are unchanged (still 13); the
29
+ -- grant/revoke signatures are identical. Writes no schema_version (stays 8).
30
+
31
+ drop function if exists agent_board.enroll_federated_identity(
32
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[]);
33
+
34
+ create or replace function agent_board.enroll_federated_identity(
35
+ p_issuer text,
36
+ p_audience text,
37
+ p_subject text,
38
+ p_human_identity_claim jsonb,
39
+ p_slug text,
40
+ p_agent_public_key text,
41
+ p_agent_public_key_format text,
42
+ p_agent_public_key_fingerprint text,
43
+ p_pop_verified boolean,
44
+ p_actor_sub text default null,
45
+ p_agent_kind text default 'agent',
46
+ p_requested_scopes text[] default '{}',
47
+ p_requested_board_scopes text[] default '{}'
48
+ )
49
+ returns table(
50
+ agent_id uuid,
51
+ auth_user_id uuid,
52
+ provider_id text,
53
+ signing_key_id uuid,
54
+ granted_scopes text[],
55
+ key_id text,
56
+ key_version int,
57
+ agent_handle text
58
+ )
59
+ language plpgsql
60
+ security definer
61
+ set search_path = agent_board, public
62
+ as $$
63
+ declare
64
+ v_slug text;
65
+ v_kind text;
66
+ v_provider agent_board.identity_providers%rowtype;
67
+ v_owner_user_id uuid;
68
+ v_auth_user_id uuid;
69
+ v_agent_handle text;
70
+ v_agent_id uuid;
71
+ v_signing_key_id uuid;
72
+ v_fingerprint text;
73
+ v_key_hex text;
74
+ v_raw_key bytea;
75
+ v_reencoded text;
76
+ v_key_id text := 'portal-enroll';
77
+ v_key_version int := 1;
78
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
79
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
80
+ begin
81
+ -- service_role-only (defense in depth on top of the execute-grant revocation).
82
+ if not agent_board._is_service_role() then
83
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
84
+ end if;
85
+
86
+ -- A verified subject + human identity claim are always present from the EF.
87
+ if p_subject is null then
88
+ raise exception 'verified subject is required' using errcode = '22023';
89
+ end if;
90
+ if p_human_identity_claim is null then
91
+ raise exception 'human identity claim is required' using errcode = '22023';
92
+ end if;
93
+
94
+ -- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
95
+ -- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
96
+ if p_pop_verified is not true then
97
+ raise exception 'proof-of-possession was not verified; enrollment refused'
98
+ using errcode = '42501';
99
+ end if;
100
+
101
+ -- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
102
+ -- base64url chars).
103
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
104
+ raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
105
+ end if;
106
+ if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
107
+ raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
108
+ using errcode = '22023';
109
+ end if;
110
+
111
+ -- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
112
+ -- A 32-byte key's 43-char base64url has 2 don't-care low bits, so up to 4 distinct
113
+ -- strings decode to the same key. Re-encoding the decoded bytes canonically and
114
+ -- requiring string equality collapses those aliases to ONE accepted encoding, so the
115
+ -- fingerprint (over raw bytes, below) is a true per-key identity — one keypair can
116
+ -- never enroll as multiple agents, and revocation isolation holds.
117
+ v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
118
+ if octet_length(v_raw_key) <> 32 then
119
+ raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
120
+ using errcode = '22023';
121
+ end if;
122
+ v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
123
+ if v_reencoded is distinct from p_agent_public_key then
124
+ raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
125
+ using errcode = '22023';
126
+ end if;
127
+
128
+ -- (1b) Reject the all-zero small-order placeholder over the RAW bytes (not a
129
+ -- string-exact match), so no alternate encoding can smuggle it past.
130
+ if v_raw_key = decode(repeat('00', 32), 'hex') then
131
+ raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
132
+ using errcode = '22023';
133
+ end if;
134
+
135
+ -- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes
136
+ -- (byte-identical to the EF/WebCrypto raw-byte derivation and the client child_keygen
137
+ -- keyRef). A tampered fingerprint -> raise.
138
+ v_fingerprint := 'sha256:' || rtrim(
139
+ translate(encode(extensions.digest(v_raw_key, 'sha256'), 'base64'), '+/', '-_'),
140
+ '=');
141
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
142
+ raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
143
+ using errcode = '22023';
144
+ end if;
145
+
146
+ -- (3) act.sub is audit-only, but when present it must be a real value.
147
+ if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
148
+ raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
149
+ end if;
150
+
151
+ v_slug := agent_board._normalize_provision_slug(p_slug);
152
+ v_kind := lower(coalesce(trim(p_agent_kind), ''));
153
+
154
+ -- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
155
+ select *
156
+ into v_provider
157
+ from agent_board.identity_providers
158
+ where issuer = p_issuer
159
+ and audience = p_audience
160
+ limit 1;
161
+ if v_provider.id is null then
162
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
163
+ end if;
164
+ if not v_provider.supported then
165
+ raise exception 'identity provider is not supported' using errcode = '42501';
166
+ end if;
167
+ if not v_provider.policy_approved then
168
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
169
+ end if;
170
+
171
+ -- (5) scope-ceiling + kind gate. 'system' is never federatable.
172
+ if v_kind = 'system' then
173
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
174
+ end if;
175
+ if v_kind not in ('human', 'agent') then
176
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
177
+ end if;
178
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
179
+ and not (v_kind = any (v_provider.allowed_agent_kinds)) then
180
+ raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
181
+ end if;
182
+ if not (v_requested_scopes <@ v_provider.allowed_scopes) then
183
+ raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
184
+ end if;
185
+ if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
186
+ raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
187
+ end if;
188
+
189
+ -- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
190
+ -- Pre-check for a clean error; the UNIQUE index is the hard guarantee.
191
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
192
+ raise exception 'this signing key is already enrolled (duplicate fingerprint)'
193
+ using errcode = '23505';
194
+ end if;
195
+
196
+ -- (7) HUMAN OWNER — established idempotently per (issuer, sub) so re-enrollment of the
197
+ -- same human converges to ONE owner auth.users row (recorded as owner_user_id).
198
+ v_owner_user_id := agent_board._ensure_agent_auth_user(
199
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
200
+
201
+ -- (8) AGENT handle — unique per enrolled key. Use the slug; on collision (a human's
202
+ -- 2nd agent under the same slug, or an unrelated handle) disambiguate with a
203
+ -- deterministic per-key hex fragment so a distinct agent row is always created. The
204
+ -- resulting handle is RETURNED so the client can mint /session against it.
205
+ v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
206
+ v_agent_handle := v_slug;
207
+ if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
208
+ v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
209
+ end if;
210
+
211
+ -- (9) the agent's OWN board principal (session-usable via its enrolled key).
212
+ v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
213
+
214
+ -- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
215
+ insert into agent_board.agents(
216
+ agent_type,
217
+ kind,
218
+ handle,
219
+ display_name,
220
+ status,
221
+ auth_user_id,
222
+ owner_user_id,
223
+ metadata
224
+ )
225
+ values (
226
+ v_kind,
227
+ v_kind::agent_board.agent_kind,
228
+ v_agent_handle,
229
+ agent_board._default_provision_display_name(v_agent_handle),
230
+ 'active',
231
+ v_auth_user_id,
232
+ v_owner_user_id,
233
+ jsonb_build_object(
234
+ 'federation', jsonb_build_object(
235
+ 'issuer', p_issuer,
236
+ 'audience', p_audience,
237
+ 'subject', p_subject,
238
+ 'actorSub', p_actor_sub,
239
+ 'providerId', v_provider.id,
240
+ 'signingKeyFingerprint', v_fingerprint,
241
+ 'requestedScopes', to_jsonb(v_requested_scopes),
242
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
243
+ 'popVerified', true,
244
+ 'enrolledAt', now()
245
+ )
246
+ )
247
+ )
248
+ returning id into v_agent_id;
249
+
250
+ -- (11) enroll the public key as active + proven (satisfies active_requires_proof).
251
+ insert into agent_board.agent_signing_keys(
252
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
253
+ status, proof_state, metadata
254
+ )
255
+ values (
256
+ gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
257
+ p_agent_public_key_format, v_fingerprint,
258
+ 'active', 'proven',
259
+ jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
260
+ )
261
+ returning id into v_signing_key_id;
262
+
263
+ -- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
264
+ insert into agent_board.enrollment_federation_audit(
265
+ issuer, audience, subject, human_identity_claim, provider_id,
266
+ agent_id, auth_user_id, requested_scopes, granted_scopes
267
+ )
268
+ values (
269
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
270
+ v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
271
+ );
272
+
273
+ -- Server-authoritative identity: the client mints /session with the RETURNED
274
+ -- key_id/key_version/agent_handle (its private key stays in the keystore).
275
+ return query
276
+ select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
277
+ v_key_id, v_key_version, v_agent_handle;
278
+ end;
279
+ $$;
280
+
281
+ comment on function agent_board.enroll_federated_identity(
282
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[]) is
283
+ 'PoP-at-enrollment federated identity: re-gates p_pop_verified + allowlist/policy/scope, '
284
+ 'decodes the key to raw bytes + rejects non-canonical/all-zero encodings, recomputes-'
285
+ 'and-asserts the sha256:base64url fingerprint over the RAW 32 bytes, establishes ONE '
286
+ 'human owner (owner_user_id) idempotently per (issuer,sub), creates a SEPARATE '
287
+ 'key-identified agent (own auth_user_id) per enrolled key, enrolls the key active+proven, '
288
+ 'and RETURNS the server-assigned key_id/key_version/agent_handle so the client mints '
289
+ '/session against the authoritative identity. service_role-only; never returns a Portal token.';
290
+
291
+ revoke all on function agent_board.enroll_federated_identity(
292
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[])
293
+ from public, anon, authenticated;
294
+ grant execute on function agent_board.enroll_federated_identity(
295
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[])
296
+ to service_role;
@@ -0,0 +1,62 @@
1
+ -- MBPORTAL PR #32 reconcile (S1 + S2): bound + isolate agent_board.enroll_rate_events.
2
+ --
3
+ -- S1 — the /enroll rate-cap table (20260719000300) had NO reaper: every under-cap
4
+ -- request INSERTs a row and nothing ever deletes it, so the table grows unbounded
5
+ -- (the challenge path got a reaper; this one did not). FIX: ship
6
+ -- reap_expired_enroll_rate_events(interval), mirroring
7
+ -- reap_expired_signing_key_challenges — security definer, service_role-only,
8
+ -- deletes-and-counts rows older than p_older_than (default 1 hour; comfortably past
9
+ -- any sane rolling window). Scheduling (pg_cron / external cadence) is a deferred
10
+ -- operational owner, exactly like the challenge reaper; the callable function is
11
+ -- in-scope. (The XFF-hop half of S1 is fixed in the /enroll EF, which owns index.ts.)
12
+ --
13
+ -- S2 — enroll_rate_events was created WITHOUT row level security. It grants to
14
+ -- service_role only (unreachable by client JWTs today), but an RLS-disabled table
15
+ -- leaves no defense if a future blanket grant is added. FIX (matching the
16
+ -- workload_providers corrective, 20260714000200): enable RLS + revoke from
17
+ -- anon/authenticated + explicit deny policies, so a client JWT can never read the
18
+ -- per-source rate ledger even under a future grant. service_role bypasses RLS, so the
19
+ -- /enroll cap path is unaffected.
20
+ --
21
+ -- Additive, forward-only. Idempotent. Writes no schema_version (stays 8).
22
+
23
+ -- S1 — reaper.
24
+ create or replace function agent_board.reap_expired_enroll_rate_events(
25
+ p_older_than interval default interval '1 hour'
26
+ )
27
+ returns bigint
28
+ language plpgsql
29
+ security definer
30
+ set search_path = agent_board, public
31
+ as $$
32
+ declare
33
+ v_count bigint;
34
+ begin
35
+ with deleted as (
36
+ delete from agent_board.enroll_rate_events e
37
+ where e.created_at < now() - coalesce(p_older_than, interval '1 hour')
38
+ returning 1
39
+ )
40
+ select count(*) into v_count from deleted;
41
+ return v_count;
42
+ end;
43
+ $$;
44
+
45
+ comment on function agent_board.reap_expired_enroll_rate_events(interval) is
46
+ 'Deletes agent_board.enroll_rate_events rows older than p_older_than (default 1 hour); returns the affected count. service_role-only. Scheduling (pg_cron / external cadence) is a deferred operational owner.';
47
+
48
+ revoke all on function agent_board.reap_expired_enroll_rate_events(interval) from public, anon, authenticated;
49
+ grant execute on function agent_board.reap_expired_enroll_rate_events(interval) to service_role;
50
+
51
+ -- S2 — RLS + client isolation (defense-in-depth vs a future blanket grant).
52
+ alter table agent_board.enroll_rate_events enable row level security;
53
+
54
+ revoke all on agent_board.enroll_rate_events from anon, authenticated;
55
+
56
+ drop policy if exists enroll_rate_events_deny_anon on agent_board.enroll_rate_events;
57
+ create policy enroll_rate_events_deny_anon on agent_board.enroll_rate_events
58
+ as permissive for select to anon using (false);
59
+
60
+ drop policy if exists enroll_rate_events_deny_authenticated on agent_board.enroll_rate_events;
61
+ create policy enroll_rate_events_deny_authenticated on agent_board.enroll_rate_events
62
+ as permissive for select to authenticated using (false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -25,6 +25,12 @@
25
25
  "LICENSE",
26
26
  "NOTICE"
27
27
  ],
28
+ "scripts": {
29
+ "build:manifest": "node ../../scripts/build-schema-manifest.mjs",
30
+ "check:manifest": "node ../../scripts/build-schema-manifest.mjs --check",
31
+ "test": "vitest run",
32
+ "pack:smoke": "node ../../scripts/schema-packed-consumer-smoke.mjs"
33
+ },
28
34
  "publishConfig": {
29
35
  "access": "public",
30
36
  "registry": "https://registry.npmjs.org"
@@ -32,11 +38,5 @@
32
38
  "engines": {
33
39
  "node": ">=18"
34
40
  },
35
- "license": "Apache-2.0",
36
- "scripts": {
37
- "build:manifest": "node ../../scripts/build-schema-manifest.mjs",
38
- "check:manifest": "node ../../scripts/build-schema-manifest.mjs --check",
39
- "test": "vitest run",
40
- "pack:smoke": "node ../../scripts/schema-packed-consumer-smoke.mjs"
41
- }
42
- }
41
+ "license": "Apache-2.0"
42
+ }