@consiliency/agent-board-schema 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +8 -0
- package/migrations/20260726000100_agent_board_localauth_self_enroll.sql +172 -0
- package/migrations/20260727000100_agent_board_roomuiapi.sql +122 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +9 -2
- package/tests/agent_board_localauth.test.sql +171 -0
- package/tests/agent_board_roomuiapi.test.sql +225 -0
|
@@ -304,6 +304,14 @@
|
|
|
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"
|
|
307
315
|
}
|
|
308
316
|
]
|
|
309
317
|
}
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.9.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 78,
|
|
50
|
+
"aggregateSha256": "4b6a5435c11341549636dfec4273c5bdc5773be23f8b8667ea816927e9412f87",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -732,6 +732,24 @@
|
|
|
732
732
|
"sourceRef": "message-board",
|
|
733
733
|
"bytes": 9521,
|
|
734
734
|
"sha256": "23c1b1545540232bbd94a47cdcbdf30e6294fd714c4d961a0a273a018d4c3398"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
"order": 76,
|
|
738
|
+
"id": "20260726000100_agent_board_localauth_self_enroll",
|
|
739
|
+
"filename": "20260726000100_agent_board_localauth_self_enroll.sql",
|
|
740
|
+
"origin": "message-board-forward",
|
|
741
|
+
"sourceRef": "message-board",
|
|
742
|
+
"bytes": 7323,
|
|
743
|
+
"sha256": "38bcc86f0e6d4ad38bc76f5abe719456f5faa2ac5bd2ab24813c61d0160da664"
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
"order": 77,
|
|
747
|
+
"id": "20260727000100_agent_board_roomuiapi",
|
|
748
|
+
"filename": "20260727000100_agent_board_roomuiapi.sql",
|
|
749
|
+
"origin": "message-board-forward",
|
|
750
|
+
"sourceRef": "message-board",
|
|
751
|
+
"bytes": 5217,
|
|
752
|
+
"sha256": "9c938c40115aa25f464bbb9d177f9f85214fb84f7b37abe5ae9df5b6a2485141"
|
|
735
753
|
}
|
|
736
754
|
],
|
|
737
755
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -37,9 +37,9 @@ describe('manifest integrity', () => {
|
|
|
37
37
|
expect(manifest.schemaVersion).toBe(8);
|
|
38
38
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
39
39
|
});
|
|
40
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG forward migrations (migrationCount = prior
|
|
40
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI forward migrations (migrationCount = prior 77 + 1 = 78)', () => {
|
|
41
41
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
42
|
-
expect(manifest.migrationCount).toBe(
|
|
42
|
+
expect(manifest.migrationCount).toBe(78);
|
|
43
43
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
44
44
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
45
45
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -84,6 +84,13 @@ describe('manifest integrity', () => {
|
|
|
84
84
|
// ROOMDELEG Lane A: owner-gated set_board_role_gate + creator-identity gate-bypass
|
|
85
85
|
// fix on invite_board_member/resolve_board_request. schema stays 8.
|
|
86
86
|
'20260725000100_agent_board_roomdeleg_role_gate.sql',
|
|
87
|
+
// LOCALAUTH: Portal-free standalone human self-enroll (self_enroll_human RPC +
|
|
88
|
+
// one-agent-per-real-auth-user backstop). schema stays 8.
|
|
89
|
+
'20260726000100_agent_board_localauth_self_enroll.sql',
|
|
90
|
+
// ROOMUIAPI: two read RPCs for the room console/CLI (owner/admin-gated
|
|
91
|
+
// list_board_pending_memberships + SECURITY INVOKER resolve_agent_handle).
|
|
92
|
+
// schema stays 8.
|
|
93
|
+
'20260727000100_agent_board_roomuiapi.sql',
|
|
87
94
|
]) {
|
|
88
95
|
expect(names.has(f)).toBe(true);
|
|
89
96
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
-- LOCALAUTH pgTAP: self_enroll_human() — Portal-free human self-enrollment.
|
|
2
|
+
--
|
|
3
|
+
-- Harness mirrors agent_board_authbound_boundary.test.sql: seed auth.users via
|
|
4
|
+
-- public.ensure_github_user, fake an authenticated caller with
|
|
5
|
+
-- set local role authenticated + set_config('request.jwt.claim.sub', <uid>),
|
|
6
|
+
-- so auth.uid() = sub. reset role -> superuser (auth.uid() null / service_role).
|
|
7
|
+
--
|
|
8
|
+
-- CRITICAL harness notes:
|
|
9
|
+
-- * L-7: `reset role` does NOT null auth.uid(); request.jwt.claim.sub is set
|
|
10
|
+
-- is_local=true and PERSISTS. Cases 12/13 explicitly clear the sub GUC before
|
|
11
|
+
-- asserting 42501, else the RPC finds the prior uid's agent (vacuous pass).
|
|
12
|
+
-- * L-8: ensure_github_user sets email_confirmed_at = now() unconditionally, so
|
|
13
|
+
-- the unverified case nulls it first.
|
|
14
|
+
|
|
15
|
+
create extension if not exists pgtap with schema extensions;
|
|
16
|
+
set search_path = extensions, public, agent_board;
|
|
17
|
+
|
|
18
|
+
begin;
|
|
19
|
+
|
|
20
|
+
select plan(17);
|
|
21
|
+
|
|
22
|
+
-- ---------------------------------------------------------------------------
|
|
23
|
+
-- Phase 0 (superuser): function metadata + seed
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
-- 1
|
|
27
|
+
select has_function('agent_board', 'self_enroll_human', array['text'], 'self_enroll_human(text) exists');
|
|
28
|
+
-- 2
|
|
29
|
+
select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'authenticated', array['EXECUTE'],
|
|
30
|
+
'authenticated may EXECUTE self_enroll_human');
|
|
31
|
+
-- 3
|
|
32
|
+
select function_privs_are('agent_board', 'self_enroll_human', array['text'], 'anon', array[]::text[],
|
|
33
|
+
'anon may NOT EXECUTE self_enroll_human');
|
|
34
|
+
|
|
35
|
+
create temp table _lfix (
|
|
36
|
+
user_a uuid,
|
|
37
|
+
user_b uuid,
|
|
38
|
+
user_c uuid,
|
|
39
|
+
agent_a uuid,
|
|
40
|
+
agent_a2 uuid
|
|
41
|
+
) on commit drop;
|
|
42
|
+
grant all on table _lfix to authenticated, service_role;
|
|
43
|
+
|
|
44
|
+
do $$
|
|
45
|
+
declare
|
|
46
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
47
|
+
v_a uuid;
|
|
48
|
+
v_b uuid;
|
|
49
|
+
v_c uuid;
|
|
50
|
+
begin
|
|
51
|
+
-- verified user (email_confirmed_at defaults to now())
|
|
52
|
+
v_a := public.ensure_github_user(
|
|
53
|
+
'localauth-a-' || v_suffix,
|
|
54
|
+
'localauth-a-' || v_suffix || '@example.test',
|
|
55
|
+
'localauth-a-' || v_suffix,
|
|
56
|
+
'LOCALAUTH A ' || v_suffix);
|
|
57
|
+
|
|
58
|
+
-- unverified user: L-8 — null out the unconditionally-set email_confirmed_at
|
|
59
|
+
v_b := public.ensure_github_user(
|
|
60
|
+
'localauth-b-' || v_suffix,
|
|
61
|
+
'localauth-b-' || v_suffix || '@example.test',
|
|
62
|
+
'localauth-b-' || v_suffix,
|
|
63
|
+
'LOCALAUTH B ' || v_suffix);
|
|
64
|
+
update auth.users set email_confirmed_at = null where id = v_b;
|
|
65
|
+
|
|
66
|
+
-- user with a pre-existing SUSPENDED human agent (no self-reactivation case)
|
|
67
|
+
v_c := public.ensure_github_user(
|
|
68
|
+
'localauth-c-' || v_suffix,
|
|
69
|
+
'localauth-c-' || v_suffix || '@example.test',
|
|
70
|
+
'localauth-c-' || v_suffix,
|
|
71
|
+
'LOCALAUTH C ' || v_suffix);
|
|
72
|
+
insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
73
|
+
values ('human', 'human', 'localauth-suspended-' || v_suffix, 'Suspended Human', 'suspended', v_c, v_c);
|
|
74
|
+
|
|
75
|
+
insert into _lfix(user_a, user_b, user_c) values (v_a, v_b, v_c);
|
|
76
|
+
end
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
-- ---------------------------------------------------------------------------
|
|
80
|
+
-- Phase 1 (authenticated as user_a): enroll + auth-context assertions
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
set local role authenticated;
|
|
83
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
84
|
+
select set_config('request.jwt.claim.sub', (select user_a::text from _lfix), true);
|
|
85
|
+
|
|
86
|
+
do $$
|
|
87
|
+
declare
|
|
88
|
+
v_first uuid;
|
|
89
|
+
v_second uuid;
|
|
90
|
+
begin
|
|
91
|
+
select s.agent_id into v_first from agent_board.self_enroll_human('Alice') s;
|
|
92
|
+
-- idempotent second call for the same uid returns the SAME agent
|
|
93
|
+
select s.agent_id into v_second from agent_board.self_enroll_human('Alice Again') s;
|
|
94
|
+
update _lfix set agent_a = v_first, agent_a2 = v_second;
|
|
95
|
+
end
|
|
96
|
+
$$;
|
|
97
|
+
|
|
98
|
+
-- 4 (part 1 of case 8): current_agent_id() resolves to the freshly-enrolled agent
|
|
99
|
+
select is(agent_board.current_agent_id(), (select agent_a from _lfix),
|
|
100
|
+
'current_agent_id() (no agent_credential_id claim) resolves to the self-enrolled agent');
|
|
101
|
+
-- 5 (case 9): idempotent — second enroll returned the same agent_id
|
|
102
|
+
select is((select agent_a2 from _lfix), (select agent_a from _lfix),
|
|
103
|
+
'second self_enroll_human for the same uid is idempotent (same agent_id)');
|
|
104
|
+
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
-- Phase 2 (authenticated as user_b): enroll unverified user
|
|
107
|
+
-- ---------------------------------------------------------------------------
|
|
108
|
+
select set_config('request.jwt.claim.sub', (select user_b::text from _lfix), true);
|
|
109
|
+
select agent_board.self_enroll_human(null);
|
|
110
|
+
|
|
111
|
+
-- ---------------------------------------------------------------------------
|
|
112
|
+
-- Phase 3 (superuser): row-shape assertions without RLS in the way
|
|
113
|
+
-- ---------------------------------------------------------------------------
|
|
114
|
+
reset role;
|
|
115
|
+
|
|
116
|
+
-- 6/7/8 (case 4): the enrolled row is a human bound to the uid
|
|
117
|
+
select is((select kind::text from agent_board.agents where id = (select agent_a from _lfix)),
|
|
118
|
+
'human', 'self-enrolled agent kind = human');
|
|
119
|
+
select is((select agent_type from agent_board.agents where id = (select agent_a from _lfix)),
|
|
120
|
+
'human', 'self-enrolled agent agent_type = human');
|
|
121
|
+
select is((select auth_user_id from agent_board.agents where id = (select agent_a from _lfix)),
|
|
122
|
+
(select user_a from _lfix), 'self-enrolled agent auth_user_id = auth.uid()');
|
|
123
|
+
-- 9 (case 5): self-owned
|
|
124
|
+
select is((select owner_user_id from agent_board.agents where id = (select agent_a from _lfix)),
|
|
125
|
+
(select user_a from _lfix), 'self-enrolled agent owner_user_id = auth.uid() (self-owned)');
|
|
126
|
+
-- 10 (case 6): active
|
|
127
|
+
select is((select status from agent_board.agents where id = (select agent_a from _lfix)),
|
|
128
|
+
'active', 'self-enrolled agent status = active');
|
|
129
|
+
-- 11 (case 7): verified user -> email_verified BOOLEAN true (never a plaintext email)
|
|
130
|
+
select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where id = (select agent_a from _lfix)),
|
|
131
|
+
'true', 'verified user -> metadata.localauth.email_verified = true');
|
|
132
|
+
-- 12 (case 10): exactly one active row for the uid
|
|
133
|
+
select is((select count(*) from agent_board.agents where auth_user_id = (select user_a from _lfix) and status = 'active'),
|
|
134
|
+
1::bigint, 'exactly one active agent per uid');
|
|
135
|
+
-- 13 (case 11): unverified user -> email_verified BOOLEAN false
|
|
136
|
+
select is((select metadata->'localauth'->>'email_verified' from agent_board.agents where auth_user_id = (select user_b from _lfix)),
|
|
137
|
+
'false', 'unverified user -> metadata.localauth.email_verified = false');
|
|
138
|
+
-- 14 (case 15 / L-4b): the DB backstop unique index exists
|
|
139
|
+
select ok(exists(
|
|
140
|
+
select 1 from pg_indexes
|
|
141
|
+
where schemaname = 'agent_board'
|
|
142
|
+
and indexname = 'idx_agent_board_agents_auth_user_id_unique'),
|
|
143
|
+
'L-4b one-agent-per-real-auth-user backstop index exists');
|
|
144
|
+
|
|
145
|
+
-- ---------------------------------------------------------------------------
|
|
146
|
+
-- Phase 4: fail-closed cases
|
|
147
|
+
-- ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
-- 15 (case 12): authenticated role but NO subject (L-7: clear the sub GUC) -> 42501
|
|
150
|
+
set local role authenticated;
|
|
151
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
152
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
153
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
154
|
+
null, 'unauthenticated (no auth.uid()) is rejected with 42501');
|
|
155
|
+
|
|
156
|
+
-- 16 (case 13): service-role / superuser (reset role) + cleared sub -> 42501
|
|
157
|
+
reset role;
|
|
158
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
159
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
160
|
+
null, 'service-role / superuser (auth.uid() null) is rejected with 42501');
|
|
161
|
+
|
|
162
|
+
-- 17 (case 14): a pre-existing non-active identity cannot self-reactivate -> 42501
|
|
163
|
+
set local role authenticated;
|
|
164
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
165
|
+
select set_config('request.jwt.claim.sub', (select user_c::text from _lfix), true);
|
|
166
|
+
select throws_ok($$ select agent_board.self_enroll_human(null) $$, '42501',
|
|
167
|
+
null, 'a suspended (non-active) identity cannot self-reactivate (42501)');
|
|
168
|
+
|
|
169
|
+
select finish();
|
|
170
|
+
|
|
171
|
+
rollback;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
-- ROOMUIAPI pgTAP: owner/admin-gated list_board_pending_memberships reader +
|
|
2
|
+
-- SECURITY INVOKER resolve_agent_handle resolver.
|
|
3
|
+
--
|
|
4
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
|
|
5
|
+
-- ('authenticated' => false) and request.jwt.claim.sub picks the acting agent.
|
|
6
|
+
-- Fixture style mirrors agent_board_roomdeleg.test.sql (ensure_github_user,
|
|
7
|
+
-- set_config role/sub, suspended-agent-as-null-actor trick).
|
|
8
|
+
--
|
|
9
|
+
-- Pending fixture rows (requested/invited/admin/board-B/dedup/demote-leave) are
|
|
10
|
+
-- seeded by DIRECT INSERT into board_memberships (runs as postgres, bypassing RLS +
|
|
11
|
+
-- the owner-gate coupling of request_join_board/invite_board_member). The owner
|
|
12
|
+
-- membership comes from create_board's ROOMOWN seed.
|
|
13
|
+
--
|
|
14
|
+
-- HAND-COUNTED plan: R1,R2,R3,R4,R5,R6,R7,R8,U-4,H1,H2,H3 = 12 assertions, one each.
|
|
15
|
+
create extension if not exists pgtap with schema extensions;
|
|
16
|
+
set search_path = extensions, public, agent_board;
|
|
17
|
+
|
|
18
|
+
begin;
|
|
19
|
+
|
|
20
|
+
select plan(12);
|
|
21
|
+
|
|
22
|
+
create temp table _roomuiapi_fixture (
|
|
23
|
+
board_a uuid,
|
|
24
|
+
board_b uuid,
|
|
25
|
+
alice uuid, -- creator, seeded owner of board A (and B)
|
|
26
|
+
admin uuid, -- role 'admin' on board A
|
|
27
|
+
plainmember uuid, -- role 'member', joined on board A (PRIVACY CRUX actor)
|
|
28
|
+
nomember uuid, -- active agent, no membership on board A
|
|
29
|
+
suspended uuid, -- suspended agent WITH a valid auth user (null actor)
|
|
30
|
+
demoteleave uuid, -- current row role=member/state=left (U-4 gate reject)
|
|
31
|
+
requester uuid, -- pending 'requested' on board A
|
|
32
|
+
invitee uuid, -- pending 'invited' on board A
|
|
33
|
+
dedup uuid, -- older 'requested' + newer 'joined' (R8: not returned)
|
|
34
|
+
boardb_requester uuid, -- pending 'requested' on board B (R7 isolation)
|
|
35
|
+
requester_handle text,
|
|
36
|
+
suspended_handle text,
|
|
37
|
+
alice_user uuid,
|
|
38
|
+
admin_user uuid,
|
|
39
|
+
plainmember_user uuid,
|
|
40
|
+
nomember_user uuid,
|
|
41
|
+
suspended_user uuid,
|
|
42
|
+
demoteleave_user uuid
|
|
43
|
+
) on commit drop;
|
|
44
|
+
|
|
45
|
+
do $$
|
|
46
|
+
declare
|
|
47
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
48
|
+
v_alice_user uuid;
|
|
49
|
+
v_admin_user uuid;
|
|
50
|
+
v_plainmember_user uuid;
|
|
51
|
+
v_nomember_user uuid;
|
|
52
|
+
v_suspended_user uuid;
|
|
53
|
+
v_demoteleave_user uuid;
|
|
54
|
+
v_alice uuid := gen_random_uuid();
|
|
55
|
+
v_admin uuid := gen_random_uuid();
|
|
56
|
+
v_plainmember uuid := gen_random_uuid();
|
|
57
|
+
v_nomember uuid := gen_random_uuid();
|
|
58
|
+
v_suspended uuid := gen_random_uuid();
|
|
59
|
+
v_demoteleave uuid := gen_random_uuid();
|
|
60
|
+
v_requester uuid := gen_random_uuid();
|
|
61
|
+
v_invitee uuid := gen_random_uuid();
|
|
62
|
+
v_dedup uuid := gen_random_uuid();
|
|
63
|
+
v_boardb_requester uuid := gen_random_uuid();
|
|
64
|
+
v_board_a uuid;
|
|
65
|
+
v_board_b uuid;
|
|
66
|
+
begin
|
|
67
|
+
v_alice_user := public.ensure_github_user('ru-alice-' || v_suffix, 'ru-alice-' || v_suffix || '@example.test', 'ru-alice-' || v_suffix, 'RU Alice');
|
|
68
|
+
v_admin_user := public.ensure_github_user('ru-admin-' || v_suffix, 'ru-admin-' || v_suffix || '@example.test', 'ru-admin-' || v_suffix, 'RU Admin');
|
|
69
|
+
v_plainmember_user := public.ensure_github_user('ru-member-' || v_suffix, 'ru-member-' || v_suffix || '@example.test', 'ru-member-' || v_suffix, 'RU Member');
|
|
70
|
+
v_nomember_user := public.ensure_github_user('ru-nomember-' || v_suffix, 'ru-nomember-' || v_suffix || '@example.test', 'ru-nomember-' || v_suffix, 'RU NoMember');
|
|
71
|
+
v_suspended_user := public.ensure_github_user('ru-susp-' || v_suffix, 'ru-susp-' || v_suffix || '@example.test', 'ru-susp-' || v_suffix, 'RU Suspended');
|
|
72
|
+
v_demoteleave_user := public.ensure_github_user('ru-demo-' || v_suffix, 'ru-demo-' || v_suffix || '@example.test', 'ru-demo-' || v_suffix, 'RU DemoteLeave');
|
|
73
|
+
|
|
74
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
75
|
+
values
|
|
76
|
+
(v_alice, 'agent', 'ru-alice-' || v_suffix, 'RU Alice', 'active', v_alice_user, v_alice_user),
|
|
77
|
+
(v_admin, 'agent', 'ru-admin-' || v_suffix, 'RU Admin', 'active', v_admin_user, v_admin_user),
|
|
78
|
+
(v_plainmember, 'agent', 'ru-member-' || v_suffix, 'RU Member', 'active', v_plainmember_user, v_plainmember_user),
|
|
79
|
+
(v_nomember, 'agent', 'ru-nomember-' || v_suffix, 'RU NoMember', 'active', v_nomember_user, v_nomember_user),
|
|
80
|
+
-- Suspended agent WITH a valid auth user: current_agent_id() filters status='active'
|
|
81
|
+
-- so it resolves to null while auth.uid() still maps (fail-closed test).
|
|
82
|
+
(v_suspended, 'agent', 'ru-susp-' || v_suffix, 'RU Suspended', 'suspended', v_suspended_user, v_suspended_user),
|
|
83
|
+
(v_demoteleave, 'agent', 'ru-demo-' || v_suffix, 'RU DemoteLeave', 'active', v_demoteleave_user, v_demoteleave_user),
|
|
84
|
+
(v_requester, 'agent', 'ru-requester-' || v_suffix, 'RU Requester', 'active', null, null),
|
|
85
|
+
(v_invitee, 'agent', 'ru-invitee-' || v_suffix, 'RU Invitee', 'active', null, null),
|
|
86
|
+
(v_dedup, 'agent', 'ru-dedup-' || v_suffix, 'RU Dedup', 'active', null, null),
|
|
87
|
+
(v_boardb_requester, 'agent', 'ru-brequester-' || v_suffix, 'RU BRequester', 'active', null, null);
|
|
88
|
+
|
|
89
|
+
-- Alice creates both boards (service_role null-actor here -> supplied arg honored);
|
|
90
|
+
-- create_board seeds Alice's joined owner membership (ROOMOWN) on each.
|
|
91
|
+
select board_id into v_board_a
|
|
92
|
+
from agent_board.create_board(v_alice, 'roomuiapi-a-' || v_suffix, 'ROOMUIAPI Board A', 'reader test', '{}'::jsonb, 'private');
|
|
93
|
+
select board_id into v_board_b
|
|
94
|
+
from agent_board.create_board(v_alice, 'roomuiapi-b-' || v_suffix, 'ROOMUIAPI Board B', 'isolation', '{}'::jsonb, 'private');
|
|
95
|
+
|
|
96
|
+
-- Direct-insert the remaining board A memberships (bypasses RLS + owner-gate).
|
|
97
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
98
|
+
values
|
|
99
|
+
(v_board_a, v_admin, 'joined', v_alice, 'admin'),
|
|
100
|
+
(v_board_a, v_plainmember, 'joined', v_alice, 'member'),
|
|
101
|
+
(v_board_a, v_requester, 'requested', v_requester, 'member'),
|
|
102
|
+
(v_board_a, v_invitee, 'invited', v_alice, 'member');
|
|
103
|
+
|
|
104
|
+
-- U-4: demote-and-leave. Under in-place upsert an agent keeps ONE row; here the
|
|
105
|
+
-- current row is role=member/state=left, so the plain-exists owner/admin gate must
|
|
106
|
+
-- reject it (no stale owner/joined row lingers to falsely satisfy exists()).
|
|
107
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, left_at)
|
|
108
|
+
values
|
|
109
|
+
(v_board_a, v_demoteleave, 'left', v_alice, 'member', now());
|
|
110
|
+
|
|
111
|
+
-- R8 dedup: EXPLICIT distinct created_at (the whole suite runs in one begin; so
|
|
112
|
+
-- default now() is constant). Older 'requested' + newer 'joined' -> latest row is
|
|
113
|
+
-- 'joined' -> the reader's distinct-on-latest drops this agent from the pending set.
|
|
114
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, created_at)
|
|
115
|
+
values
|
|
116
|
+
(v_board_a, v_dedup, 'requested', v_dedup, 'member', now() - interval '1 hour'),
|
|
117
|
+
(v_board_a, v_dedup, 'joined', v_alice, 'member', now());
|
|
118
|
+
|
|
119
|
+
-- Board B: its own pending requester (R7 cross-board isolation).
|
|
120
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
121
|
+
values
|
|
122
|
+
(v_board_b, v_boardb_requester, 'requested', v_boardb_requester, 'member');
|
|
123
|
+
|
|
124
|
+
insert into _roomuiapi_fixture values
|
|
125
|
+
(v_board_a, v_board_b, v_alice, v_admin, v_plainmember, v_nomember, v_suspended,
|
|
126
|
+
v_demoteleave, v_requester, v_invitee, v_dedup, v_boardb_requester,
|
|
127
|
+
'ru-requester-' || v_suffix, 'ru-susp-' || v_suffix,
|
|
128
|
+
v_alice_user, v_admin_user, v_plainmember_user, v_nomember_user, v_suspended_user, v_demoteleave_user);
|
|
129
|
+
end
|
|
130
|
+
$$;
|
|
131
|
+
|
|
132
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
133
|
+
|
|
134
|
+
-- === list_board_pending_memberships ===============================================
|
|
135
|
+
-- R1: owner (Alice) reads board A -> exactly the 2 pending rows (requested+invited).
|
|
136
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
|
|
137
|
+
select is(
|
|
138
|
+
(select count(*)::int from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture))),
|
|
139
|
+
2, 'R1: the owner sees exactly the two pending memberships (requested + invited)');
|
|
140
|
+
|
|
141
|
+
-- R2: admin reads board A -> same 2 pending rows.
|
|
142
|
+
select set_config('request.jwt.claim.sub', (select admin_user from _roomuiapi_fixture)::text, true);
|
|
143
|
+
select is(
|
|
144
|
+
(select count(*)::int from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture))),
|
|
145
|
+
2, 'R2: an admin sees the two pending memberships');
|
|
146
|
+
|
|
147
|
+
-- R3: the owner sees the requester's handle (content, not just count).
|
|
148
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
|
|
149
|
+
select is(
|
|
150
|
+
(select p.handle
|
|
151
|
+
from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
|
|
152
|
+
where p.agent_id = (select requester from _roomuiapi_fixture)),
|
|
153
|
+
(select requester_handle from _roomuiapi_fixture),
|
|
154
|
+
'R3: the owner sees the requester''s handle in the pending row');
|
|
155
|
+
|
|
156
|
+
-- R4: a plain joined member is rejected -> 42501 (PRIVACY CRUX).
|
|
157
|
+
select set_config('request.jwt.claim.sub', (select plainmember_user from _roomuiapi_fixture)::text, true);
|
|
158
|
+
select throws_ok(
|
|
159
|
+
(select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
|
|
160
|
+
'42501', 'only a board owner or admin may list pending memberships',
|
|
161
|
+
'R4: a plain joined member cannot enumerate pending memberships');
|
|
162
|
+
|
|
163
|
+
-- R5: a non-member (unmapped sub) is rejected -> 42501.
|
|
164
|
+
select set_config('request.jwt.claim.sub', gen_random_uuid()::text, true);
|
|
165
|
+
select throws_ok(
|
|
166
|
+
(select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
|
|
167
|
+
'42501', 'only a board owner or admin may list pending memberships',
|
|
168
|
+
'R5: a non-member is rejected');
|
|
169
|
+
|
|
170
|
+
-- R6: a suspended principal (current_agent_id() null) is rejected -> 42501 (fail-closed).
|
|
171
|
+
select set_config('request.jwt.claim.sub', (select suspended_user from _roomuiapi_fixture)::text, true);
|
|
172
|
+
select throws_ok(
|
|
173
|
+
(select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
|
|
174
|
+
'42501', 'only a board owner or admin may list pending memberships',
|
|
175
|
+
'R6: a suspended principal (null actor) is rejected (fail-closed)');
|
|
176
|
+
|
|
177
|
+
-- R7: owner of A reading board A does NOT see board B's requester (cross-board isolation).
|
|
178
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
|
|
179
|
+
select is(
|
|
180
|
+
(select count(*)::int
|
|
181
|
+
from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
|
|
182
|
+
where p.agent_id = (select boardb_requester from _roomuiapi_fixture)),
|
|
183
|
+
0, 'R7: board B''s requester is absent from board A''s pending list (cross-board isolation)');
|
|
184
|
+
|
|
185
|
+
-- R8: the dedup agent (latest row 'joined', older 'requested') is NOT returned.
|
|
186
|
+
select is(
|
|
187
|
+
(select count(*)::int
|
|
188
|
+
from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
|
|
189
|
+
where p.agent_id = (select dedup from _roomuiapi_fixture)),
|
|
190
|
+
0, 'R8: an agent whose latest row is joined (older requested) is not returned (dedup)');
|
|
191
|
+
|
|
192
|
+
-- U-4: demote-and-leave actor (current row role=member/state=left) is rejected -> 42501.
|
|
193
|
+
select set_config('request.jwt.claim.sub', (select demoteleave_user from _roomuiapi_fixture)::text, true);
|
|
194
|
+
select throws_ok(
|
|
195
|
+
(select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
|
|
196
|
+
'42501', 'only a board owner or admin may list pending memberships',
|
|
197
|
+
'U-4: a demoted-and-left actor (latest row member/left) is rejected by the gate');
|
|
198
|
+
|
|
199
|
+
-- === resolve_agent_handle (SECURITY INVOKER) ======================================
|
|
200
|
+
-- H1: exact active handle -> the agent_id.
|
|
201
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
|
|
202
|
+
select is(
|
|
203
|
+
(select r.agent_id
|
|
204
|
+
from agent_board.resolve_agent_handle((select requester_handle from _roomuiapi_fixture)) r),
|
|
205
|
+
(select requester from _roomuiapi_fixture),
|
|
206
|
+
'H1: the resolver maps an exact active handle to the agent_id');
|
|
207
|
+
|
|
208
|
+
-- H2: unknown handle -> 0 rows.
|
|
209
|
+
select is(
|
|
210
|
+
(select count(*)::int
|
|
211
|
+
from agent_board.resolve_agent_handle('ru-does-not-exist-' || gen_random_uuid()::text)),
|
|
212
|
+
0, 'H2: an unknown handle resolves to zero rows');
|
|
213
|
+
|
|
214
|
+
-- H3: a suspended agent's handle -> 0 rows (status='active' filter).
|
|
215
|
+
select is(
|
|
216
|
+
(select count(*)::int
|
|
217
|
+
from agent_board.resolve_agent_handle((select suspended_handle from _roomuiapi_fixture))),
|
|
218
|
+
0, 'H3: a suspended agent''s handle resolves to zero rows (active filter)');
|
|
219
|
+
|
|
220
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
221
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
222
|
+
|
|
223
|
+
select finish();
|
|
224
|
+
|
|
225
|
+
rollback;
|