@consiliency/agent-board-schema 1.38.0 → 1.40.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.
@@ -548,6 +548,10 @@
548
548
  {
549
549
  "id": "20260919000100_agent_board_signed_write_model_key",
550
550
  "sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
551
+ },
552
+ {
553
+ "id": "20260920000100_agent_board_realtime_credential_claim",
554
+ "sha256": "670d6e97ca84ef74ebd10a6ee102c328005a28aa48f8668ba444ce31b714cf02"
551
555
  }
552
556
  ]
553
557
  }
@@ -0,0 +1,119 @@
1
+ -- #341 — `current_agent_id()`'s credential check never ran. Anywhere.
2
+ --
3
+ -- THE DEFECT, corrected after a correctness seat disproved this header's first version by
4
+ -- measurement. The original text said PostgREST sets the per-claim GUCs and Realtime does not, so
5
+ -- the credential branch was dead only for subscribers. That is FALSE, and the truth is worse.
6
+ --
7
+ -- Measured through a real PostgREST (v14.5) with a signed `authenticated` JWT carrying
8
+ -- `agent_credential_id`, by a probe function called over HTTP:
9
+ --
10
+ -- per_claim_sub : null
11
+ -- per_claim_cred : null
12
+ -- whole_json_present : true
13
+ -- whole_json_has_cred : true
14
+ --
15
+ -- PostgREST REMOVED the legacy `request.jwt.claim.<name>` GUCs (the option went away in v12), and
16
+ -- nothing in this repository sets them either. So the pre-fix body, which read ONLY that source,
17
+ -- resolved the claim to NULL on EVERY transport. NULL takes the permissive branch by design, and
18
+ -- the branch requiring a non-revoked, non-expired credential NEVER EXECUTED — not on Realtime,
19
+ -- and not on the RPC path either.
20
+ --
21
+ -- CONSEQUENCE, stated plainly because the first version of this header understated its own fix:
22
+ -- mid-JWT-lifetime credential revocation stopped nothing, anywhere. This migration does not
23
+ -- "restore" a gate that worked on one path; it ACTIVATES the credential gate for the first time,
24
+ -- on every path. That is a real behaviour change on the live RPC path, not only a subscription fix.
25
+ --
26
+ -- WHY AUTH.UID() SURVIVED THE SAME GAP, which is where the fix comes from. `auth.uid()` already
27
+ -- reads BOTH sources and coalesces:
28
+ --
29
+ -- coalesce(
30
+ -- nullif(current_setting('request.jwt.claim.sub', true), ''),
31
+ -- (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
32
+ -- )::uuid
33
+ --
34
+ -- The second source is populated on both transports, and carries custom claims. Verified on
35
+ -- Realtime with a throwaway table whose RLS policy keyed ONLY on that GUC (a row wanting the JWT's
36
+ -- value delivered, a row wanting a different value not), and on PostgREST by the probe above.
37
+ --
38
+ -- THE FIX is that same coalesce shape applied to the credential claim. Nothing is loosened:
39
+ -- absence in BOTH sources still takes the permissive branch, which callers that legitimately have
40
+ -- no credential depend on.
41
+ --
42
+ -- NO NEW RAISE PATH — and the first version of this header was wrong about that too. It claimed
43
+ -- the cast to `jsonb` was "a way this function can now raise where it previously could not". The
44
+ -- previous body raised as well: with a per-claim value of `bogus` it raises
45
+ -- `invalid input syntax for type uuid (22P02)`, measured on the pre-fix body. The UUID regex does
46
+ -- NOT guard the cast — the cast is evaluated at SQL-function startup, before any qual filtering,
47
+ -- so a malformed value raises regardless of a conjunct that syntactically precedes it.
48
+ --
49
+ -- Rather than disclose that, this migration REMOVES the raise class: the credential is compared as
50
+ -- TEXT against `ac.id::text`, so a malformed claim of any shape (`""`, `42`, `{"a":1}`, `bogus`)
51
+ -- matches nothing and DENIES CLEANLY instead of erroring. `lower()` because `uuid::text` is
52
+ -- canonical lowercase and the claim may not be. The regex is kept as a cheap pre-filter, and is
53
+ -- now genuinely a filter rather than a guard that never guarded.
54
+ --
55
+ -- WHAT THIS DOES NOT DO. It does not make a Realtime payload trustworthy. Delivery is now gated on
56
+ -- the same credential the RPC path checks, but the event body is still a replication row image and
57
+ -- the subscription can silently miss events (`SUBSCRIBED` is reported before events actually flow —
58
+ -- measured: on a cold Realtime container the first insert after SUBSCRIBED was dropped). Consumers
59
+ -- must treat an event as "something changed, go fetch". That posture is asserted in the client.
60
+ --
61
+ -- CREATE OR REPLACE with the identical signature, so the ACL survives — verified before writing
62
+ -- this: `authenticated` and `service_role` hold EXECUTE, `anon` does not, PUBLIC holds nothing.
63
+ -- Restated anyway, because a recreated SECURITY DEFINER function that does not restate its
64
+ -- privileges is a gate-bypass this repo has already shipped once.
65
+
66
+ create or replace function agent_board.current_agent_id()
67
+ returns uuid
68
+ language sql
69
+ stable
70
+ security definer
71
+ set search_path = ''
72
+ as $$
73
+ with credential_claim as (
74
+ -- #341: read BOTH sources. PostgREST sets the per-claim GUC; Realtime sets only the
75
+ -- whole-JSON one. Reading a single source made the credential branch dead code for every
76
+ -- Realtime subscriber. Mirrors auth.uid(), which has always coalesced for the same reason.
77
+ select coalesce(
78
+ nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
79
+ -- The OUTER nullif is not redundant, and its absence was a transport inconsistency
80
+ -- caught by a test: the per-claim arm maps '' to absent, so without this the same
81
+ -- logical claim ("" ) would be ABSENT on one transport and a malformed credential
82
+ -- that DENIES on the other. Same input, different answer by transport, which is the
83
+ -- exact bug class #341 is about. Empty means absent on both, matching what omission
84
+ -- already permits.
85
+ nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
86
+ ) as credential_id_text
87
+ )
88
+ select a.id
89
+ from agent_board.agents a
90
+ cross join credential_claim cc
91
+ where a.auth_user_id = auth.uid()
92
+ and a.status = 'active'
93
+ and (
94
+ cc.credential_id_text is null
95
+ or (
96
+ cc.credential_id_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
97
+ and exists (
98
+ select 1
99
+ from agent_board.agent_credentials ac
100
+ -- TEXT comparison, never a cast of the untrusted claim: a malformed value must DENY,
101
+ -- not raise. `ac.id::text` is canonical lowercase, hence lower() on the claim.
102
+ where ac.id::text = lower(cc.credential_id_text)
103
+ and ac.agent_id = a.id
104
+ and ac.revoked_at is null
105
+ and (ac.expires_at is null or ac.expires_at > now())
106
+ )
107
+ )
108
+ )
109
+ limit 1;
110
+ $$;
111
+
112
+ -- Restated rather than relied upon. `anon` is deliberately NOT granted: it did not hold EXECUTE
113
+ -- before this migration and must not gain it.
114
+ revoke all on function agent_board.current_agent_id() from public;
115
+ grant execute on function agent_board.current_agent_id() to authenticated;
116
+ grant execute on function agent_board.current_agent_id() to service_role;
117
+
118
+ comment on function agent_board.current_agent_id() is
119
+ 'Resolves auth.uid() to the active agent_board.agents.id and, when an agent_credential_id claim is present in EITHER the per-claim GUC (request.jwt.claim.agent_credential_id) or the whole-JSON GUC (request.jwt.claims), requires it to reference a non-revoked, non-expired credential for that agent. Reading only the per-claim source made the credential branch unreachable on EVERY transport, not just Realtime: PostgREST removed those legacy GUCs at v12 and nothing here sets them, so the claim read NULL and took the permissive branch everywhere. This migration ACTIVATES the credential gate for the first time rather than restoring it (#341). The claim is compared as TEXT against agent_credentials.id::text, so a malformed claim denies cleanly instead of raising 22P02. Absence in BOTH sources still takes the permissive branch, which callers that legitimately have no credential depend on.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.38.0",
3
+ "version": "1.40.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",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.38.0",
5
+ "packageVersion": "1.40.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 137,
50
- "aggregateSha256": "bfe0b978234464938428a66199b75d2f8d028706517b4674f9ae11cae34c52d1",
49
+ "migrationCount": 138,
50
+ "aggregateSha256": "e5e6870533c69d20b446431b6fb19693588d9eb4bb55e1fb3f7f239afcfa76c0",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1281,6 +1281,15 @@
1281
1281
  "sourceRef": "message-board",
1282
1282
  "bytes": 7204,
1283
1283
  "sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
1284
+ },
1285
+ {
1286
+ "order": 137,
1287
+ "id": "20260920000100_agent_board_realtime_credential_claim",
1288
+ "filename": "20260920000100_agent_board_realtime_credential_claim.sql",
1289
+ "origin": "message-board-forward",
1290
+ "sourceRef": "message-board",
1291
+ "bytes": 7556,
1292
+ "sha256": "670d6e97ca84ef74ebd10a6ee102c328005a28aa48f8668ba444ce31b714cf02"
1284
1293
  }
1285
1294
  ],
1286
1295
  "excludedBridge": {
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
38
38
  expect(manifest.schemaVersion).toBe(8);
39
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
40
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 137)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 138)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(137);
43
+ expect(manifest.migrationCount).toBe(138);
44
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -222,6 +222,17 @@ describe('manifest integrity', () => {
222
222
  // names. Definition-only: CREATE OR REPLACE with the identical signature, grants and
223
223
  // COMMENT restated in the same migration.
224
224
  '20260919000100_agent_board_signed_write_model_key.sql',
225
+ // #341 RTCREDCLAIM. `current_agent_id()` read the agent_credential_id claim ONLY from
226
+ // `request.jwt.claim.agent_credential_id`, which PostgREST sets and Realtime does not --
227
+ // so on a postgres_changes subscription the claim read NULL, NULL took the permissive
228
+ // branch by design, and the branch requiring a non-revoked credential never executed.
229
+ // Credential revocation did not stop realtime delivery. Measured: a JWT carrying a bogus
230
+ // credential still received its rows in 542ms. The fix is the coalesce shape auth.uid()
231
+ // has always used -- read the per-claim GUC, else `request.jwt.claims ->> ...` -- which is
232
+ // exactly why auth.uid() survived the same gap. Nothing is loosened: absence in BOTH
233
+ // sources still takes the permissive branch. Definition-only: CREATE OR REPLACE with the
234
+ // identical signature, grants and COMMENT restated in the same migration.
235
+ '20260920000100_agent_board_realtime_credential_claim.sql',
225
236
  ]) {
226
237
  expect(names.has(f)).toBe(true);
227
238
  }
@@ -0,0 +1,301 @@
1
+ -- #341 — the credential branch of `current_agent_id()` must run on BOTH claim transports.
2
+ --
3
+ -- WHY THIS FILE EXISTS. `current_agent_id()` read the credential claim from exactly one place,
4
+ -- `request.jwt.claim.agent_credential_id`, which PostgREST sets and Realtime does not. So for
5
+ -- every `postgres_changes` subscriber the claim read NULL, NULL took the permissive branch by
6
+ -- design, and the branch requiring a non-revoked credential never executed. Credential revocation
7
+ -- did not stop realtime delivery.
8
+ --
9
+ -- The defect was invisible to every existing test because they all exercise the PostgREST-shaped
10
+ -- context, where the per-claim GUC IS set. The assertions below therefore separate the two
11
+ -- transports deliberately: the ones that matter set the whole-JSON GUC and NOTHING else, which is
12
+ -- the context a Realtime subscriber actually gets.
13
+ --
14
+ -- WHAT EACH HALF PINS:
15
+ -- * per-claim transport -- pre-existing behaviour, asserted so the fix cannot regress it;
16
+ -- * whole-JSON transport -- the fix. These fail on the pre-#341 body;
17
+ -- * absence in BOTH -- the permissive branch, which callers with no credential depend on.
18
+ -- Asserted positively rather than assumed, because "the fix did not over-deny" is the half a
19
+ -- revocation fix is most likely to get wrong.
20
+
21
+ begin;
22
+ select plan(17);
23
+
24
+ -- ---------------------------------------------------------------------------
25
+ -- Fixture: one auth user, one active agent bound to it, three credentials
26
+ -- (valid / revoked / expired) belonging to that agent.
27
+ -- ---------------------------------------------------------------------------
28
+ insert into auth.users (id, instance_id, aud, role, email, created_at, updated_at)
29
+ values (
30
+ '34100000-0000-4000-8000-000000000001',
31
+ '00000000-0000-0000-0000-000000000000',
32
+ 'authenticated', 'authenticated', 'rtcred-subject@example.test', now(), now()
33
+ );
34
+
35
+ insert into agent_board.agents (id, agent_type, handle, display_name, status, metadata, auth_user_id)
36
+ values (
37
+ '34100000-0000-4000-8000-000000000010',
38
+ 'agent', 'rtcred-subject', 'Realtime Credential Subject', 'active', '{}'::jsonb,
39
+ '34100000-0000-4000-8000-000000000001'
40
+ );
41
+
42
+ insert into agent_board.agent_credentials
43
+ (id, agent_id, refresh_token_hash, hash_version, credential_label, expires_at, revoked_at)
44
+ values
45
+ ('34100000-0000-4000-8000-000000000100', '34100000-0000-4000-8000-000000000010',
46
+ '\x3411'::bytea, 1, 'valid', now() + interval '1 day', null),
47
+ ('34100000-0000-4000-8000-000000000200', '34100000-0000-4000-8000-000000000010',
48
+ '\x3412'::bytea, 1, 'revoked', now() + interval '1 day', now() - interval '1 hour'),
49
+ ('34100000-0000-4000-8000-000000000300', '34100000-0000-4000-8000-000000000010',
50
+ '\x3413'::bytea, 1, 'expired', now() - interval '1 hour', null);
51
+
52
+ -- ---------------------------------------------------------------------------
53
+ -- A. PER-CLAIM GUC context. NOTE THE LABEL: an earlier version called this "PostgREST-shaped",
54
+ -- which is wrong -- PostgREST removed these GUCs at v12 and sets only request.jwt.claims, so
55
+ -- NO production transport produces this context; only direct SQL / set_config does. Kept
56
+ -- anyway, because it pins the coalesce ORDER and the pre-existing branch semantics.
57
+ -- ---------------------------------------------------------------------------
58
+ select set_config('request.jwt.claim.sub', '34100000-0000-4000-8000-000000000001', true);
59
+ select set_config('request.jwt.claims', '', true);
60
+ select set_config('request.jwt.claim.agent_credential_id', '', true);
61
+
62
+ select is(
63
+ (select agent_board.current_agent_id()),
64
+ '34100000-0000-4000-8000-000000000010'::uuid,
65
+ 'A1 per-claim transport, no credential claim: resolves the agent (permissive branch)'
66
+ );
67
+
68
+ select set_config('request.jwt.claim.agent_credential_id', '34100000-0000-4000-8000-000000000100', true);
69
+ select is(
70
+ (select agent_board.current_agent_id()),
71
+ '34100000-0000-4000-8000-000000000010'::uuid,
72
+ 'A2 per-claim transport, VALID credential: resolves the agent'
73
+ );
74
+
75
+ select set_config('request.jwt.claim.agent_credential_id', '34100000-0000-4000-8000-000000000200', true);
76
+ select is(
77
+ (select agent_board.current_agent_id()),
78
+ null::uuid,
79
+ 'A3 per-claim transport, REVOKED credential: denies'
80
+ );
81
+
82
+ -- ---------------------------------------------------------------------------
83
+ -- B. Realtime-shaped context: ONLY the whole-JSON GUC. These are the fix.
84
+ -- Every per-claim GUC is cleared first, so a pass here cannot be produced by
85
+ -- the source that already worked.
86
+ -- ---------------------------------------------------------------------------
87
+ select set_config('request.jwt.claim.sub', '', true);
88
+ select set_config('request.jwt.claim.agent_credential_id', '', true);
89
+
90
+ select set_config(
91
+ 'request.jwt.claims',
92
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated"}',
93
+ true
94
+ );
95
+ select is(
96
+ (select agent_board.current_agent_id()),
97
+ '34100000-0000-4000-8000-000000000010'::uuid,
98
+ 'B1 realtime transport, claims carry no credential key: resolves (permissive branch preserved)'
99
+ );
100
+
101
+ select set_config(
102
+ 'request.jwt.claims',
103
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000100"}',
104
+ true
105
+ );
106
+ select is(
107
+ (select agent_board.current_agent_id()),
108
+ '34100000-0000-4000-8000-000000000010'::uuid,
109
+ 'B2 realtime transport, VALID credential in claims: resolves (fix does not over-deny)'
110
+ );
111
+
112
+ select set_config(
113
+ 'request.jwt.claims',
114
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000200"}',
115
+ true
116
+ );
117
+ select is(
118
+ (select agent_board.current_agent_id()),
119
+ null::uuid,
120
+ 'B3 realtime transport, REVOKED credential in claims: denies (the #341 defect)'
121
+ );
122
+
123
+ select set_config(
124
+ 'request.jwt.claims',
125
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000300"}',
126
+ true
127
+ );
128
+ select is(
129
+ (select agent_board.current_agent_id()),
130
+ null::uuid,
131
+ 'B4 realtime transport, EXPIRED credential in claims: denies'
132
+ );
133
+
134
+ select set_config(
135
+ 'request.jwt.claims',
136
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"99999999-9999-4999-8999-999999999999"}',
137
+ true
138
+ );
139
+ select is(
140
+ (select agent_board.current_agent_id()),
141
+ null::uuid,
142
+ 'B5 realtime transport, credential belonging to no agent: denies'
143
+ );
144
+
145
+ -- A credential that exists and is valid but belongs to a DIFFERENT agent must not authorize this
146
+ -- one. The nearest principal that should still be refused, rather than a stranger: a stranger is
147
+ -- refused by any check that looks at anything at all.
148
+ insert into auth.users (id, instance_id, aud, role, email, created_at, updated_at)
149
+ values (
150
+ '34100000-0000-4000-8000-000000000002',
151
+ '00000000-0000-0000-0000-000000000000',
152
+ 'authenticated', 'authenticated', 'rtcred-other@example.test', now(), now()
153
+ );
154
+ insert into agent_board.agents (id, agent_type, handle, display_name, status, metadata, auth_user_id)
155
+ values (
156
+ '34100000-0000-4000-8000-000000000020',
157
+ 'agent', 'rtcred-other', 'Other Agent', 'active', '{}'::jsonb,
158
+ '34100000-0000-4000-8000-000000000002'
159
+ );
160
+ insert into agent_board.agent_credentials
161
+ (id, agent_id, refresh_token_hash, hash_version, credential_label, expires_at, revoked_at)
162
+ values
163
+ ('34100000-0000-4000-8000-000000000400', '34100000-0000-4000-8000-000000000020',
164
+ '\x3414'::bytea, 1, 'other-valid', now() + interval '1 day', null);
165
+
166
+ select set_config(
167
+ 'request.jwt.claims',
168
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000400"}',
169
+ true
170
+ );
171
+ select is(
172
+ (select agent_board.current_agent_id()),
173
+ null::uuid,
174
+ 'B6 realtime transport, valid credential owned by ANOTHER agent: denies'
175
+ );
176
+
177
+ -- ---------------------------------------------------------------------------
178
+ -- C. Precedence and shape.
179
+ -- ---------------------------------------------------------------------------
180
+ -- Both sources present: the per-claim value is preferred. Asserted so the coalesce ORDER is
181
+ -- pinned, not merely its membership. A revoked value in the ignored source must not deny.
182
+ select set_config('request.jwt.claim.agent_credential_id', '34100000-0000-4000-8000-000000000100', true);
183
+ select set_config(
184
+ 'request.jwt.claims',
185
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000200"}',
186
+ true
187
+ );
188
+ select is(
189
+ (select agent_board.current_agent_id()),
190
+ '34100000-0000-4000-8000-000000000010'::uuid,
191
+ 'C1 both sources present: per-claim wins over whole-JSON'
192
+ );
193
+
194
+ -- The function must still be SECURITY DEFINER with a pinned search_path. A recreate that dropped
195
+ -- either would change who it can read and what it resolves, silently.
196
+ select is(
197
+ (select p.prosecdef from pg_catalog.pg_proc p
198
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
199
+ where n.nspname = 'agent_board' and p.proname = 'current_agent_id'),
200
+ true,
201
+ 'C2 current_agent_id remains SECURITY DEFINER'
202
+ );
203
+
204
+ -- ---------------------------------------------------------------------------
205
+ -- E. MALFORMED CLAIM SHAPES MUST DENY, NOT RAISE.
206
+ --
207
+ -- A correctness seat measured that the UUID regex never guarded the cast: `::uuid` is evaluated
208
+ -- at SQL-function startup, before any qual filtering, so a malformed claim raised 22P02 rather
209
+ -- than denying -- on the OLD body too, which is why this migration removes the cast entirely and
210
+ -- compares as text. Every one of these was RED before that change.
211
+ -- ---------------------------------------------------------------------------
212
+ select set_config('request.jwt.claim.agent_credential_id', '', true);
213
+
214
+ select set_config(
215
+ 'request.jwt.claims',
216
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":""}',
217
+ true
218
+ );
219
+ select is(
220
+ (select agent_board.current_agent_id()),
221
+ '34100000-0000-4000-8000-000000000010'::uuid,
222
+ 'E1 EMPTY-STRING claim is ABSENT on the whole-JSON arm too, matching the per-claim arm'
223
+ );
224
+
225
+ select set_config(
226
+ 'request.jwt.claims',
227
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"bogus"}',
228
+ true
229
+ );
230
+ select is(
231
+ (select agent_board.current_agent_id()),
232
+ null::uuid,
233
+ 'E2 NON-UUID credential claim denies cleanly instead of raising 22P02'
234
+ );
235
+
236
+ select set_config(
237
+ 'request.jwt.claims',
238
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":42}',
239
+ true
240
+ );
241
+ select is(
242
+ (select agent_board.current_agent_id()),
243
+ null::uuid,
244
+ 'E3 NUMERIC credential claim denies cleanly instead of raising 22P02'
245
+ );
246
+
247
+ -- Case-insensitivity is load-bearing now that the comparison is text: uuid::text is canonical
248
+ -- LOWERCASE, so an uppercase claim would silently stop matching without lower().
249
+ select set_config(
250
+ 'request.jwt.claims',
251
+ '{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000100"}',
252
+ true
253
+ );
254
+ select is(
255
+ (select agent_board.current_agent_id()),
256
+ '34100000-0000-4000-8000-000000000010'::uuid,
257
+ 'E4 UPPERCASE credential claim still matches (uuid::text is lowercase; lower() bridges it)'
258
+ );
259
+
260
+ -- ---------------------------------------------------------------------------
261
+ -- D. THE CLASS. #341 is one site; this asserts it is the ONLY one, and keeps it that way.
262
+ --
263
+ -- A function that reads a per-claim GUC (`request.jwt.claim.<name>`) WITHOUT also reading the
264
+ -- whole-JSON GUC has #341's defect by construction: PostgREST populates the first, Realtime only
265
+ -- the second, so its check silently does not run for subscribers. Catalog-derived, so a NEW
266
+ -- function with that shape fails this and NAMES ITSELF rather than needing anyone to remember.
267
+ --
268
+ -- `keep_literals => true` is REQUIRED and is the trap this repo documents: the GUC name is a
269
+ -- STRING LITERAL, and stripped_body strips literals by default, so the default spelling matches
270
+ -- nothing and the assertion passes vacuously.
271
+ --
272
+ -- Note `request.jwt.claim.` carries a trailing dot, so it does not also match `request.jwt.claims`.
273
+ -- ---------------------------------------------------------------------------
274
+
275
+ -- Population canary FIRST: an empty "no offenders" result must not be reachable by the class
276
+ -- being empty. If nothing reads a per-claim GUC at all, this file is asserting nothing.
277
+ select cmp_ok(
278
+ (select count(*)
279
+ from pg_catalog.pg_proc p
280
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
281
+ where n.nspname = 'agent_board'
282
+ and agent_board_test.stripped_body(p.oid, true) like '%request.jwt.claim.%'),
283
+ '>=',
284
+ 1::bigint,
285
+ 'D1 population canary: at least one agent_board function reads a per-claim JWT GUC'
286
+ );
287
+
288
+ select is_empty(
289
+ $$
290
+ select p.proname
291
+ from pg_catalog.pg_proc p
292
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
293
+ where n.nspname = 'agent_board'
294
+ and agent_board_test.stripped_body(p.oid, true) like '%request.jwt.claim.%'
295
+ and agent_board_test.stripped_body(p.oid, true) not like '%request.jwt.claims%'
296
+ $$,
297
+ 'D2 no agent_board function reads a per-claim JWT GUC without also reading request.jwt.claims'
298
+ );
299
+
300
+ select finish();
301
+ rollback;