feat(iios): policy-enforced membership + threaded replies + dev IdP (generic)
Enriches the platform PLANE, not the kernel: - DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the same opa.decide) — DM capped at 2, add-participant requires member/group-admin, governed self-join for membership threads. Real OPA swaps in unchanged. - Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would. - PolicyDeniedFilter maps fail-closed denials to HTTP 403. Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA policy + an opaque thread attribute): - MessageService.addParticipant (governed membership by userId), governed openThread self-join (scoped to threads with a membership attribute), parentInteractionId on send (reply link), and a generic listThreads. - REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants; socket add_participant + membership/parentInteractionId. ensureParticipant gains a role. Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join / listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed join. 175 unit tests + all smokes green; kernel free of dm/group literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// v1.1 smoke: dev IdP login, policy-enforced DM cap / group membership, listThreads,
|
||||
// and threaded replies — all over REST. Requires the service with IIOS_DEV_TOKENS=1.
|
||||
import 'dotenv/config';
|
||||
|
||||
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||
|
||||
async function login(username, password) {
|
||||
const r = await fetch(`${SERVICE}/v1/dev/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return { status: r.status, body: r.ok ? await r.json() : null };
|
||||
}
|
||||
function call(token, path, method = 'GET', body) {
|
||||
return fetch(`${SERVICE}${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
async function json(token, path, method = 'GET', body) {
|
||||
const r = await call(token, path, method, body);
|
||||
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// 1) dev IdP: correct password issues a token; wrong password is rejected.
|
||||
const ok = await login('alice', 'alice');
|
||||
assert(ok.body?.token, 'dev IdP: alice logs in with password');
|
||||
const bad = await login('alice', 'nope');
|
||||
assert(bad.status === 401, 'dev IdP: wrong password → 401');
|
||||
const alice = ok.body.token;
|
||||
|
||||
// 2) DM is capped at two (policy).
|
||||
const dm = await json(alice, '/v1/threads', 'POST', { membership: 'dm' });
|
||||
assert(dm.threadId, `alice created a DM (${dm.threadId})`);
|
||||
const add2 = await json(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'bob' });
|
||||
assert(add2.participantCount === 2, 'added bob → 2 participants');
|
||||
const add3 = await call(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'carol' });
|
||||
assert(add3.status === 403, `adding a 3rd to a DM → 403 policy denied (${add3.status})`);
|
||||
|
||||
// 3) group: creator (ADMIN) can add several.
|
||||
const grp = await json(alice, '/v1/threads', 'POST', { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'bob' });
|
||||
const g3 = await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'carol' });
|
||||
assert(g3.participantCount === 3, 'group admin added bob + carol → 3 participants');
|
||||
|
||||
// 4) threaded reply round-trips.
|
||||
const first = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'question?' });
|
||||
const reply = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'answer', parentInteractionId: first.id });
|
||||
assert(reply.parentInteractionId === first.id, 'a reply carries parentInteractionId');
|
||||
|
||||
// 5) listThreads shows the caller's threads with membership + last message.
|
||||
const mine = await json(alice, '/v1/threads');
|
||||
const g = mine.find((t) => t.threadId === grp.threadId);
|
||||
assert(g && g.membership === 'group' && g.participantCount === 3, 'GET /v1/threads lists the group with membership + count');
|
||||
assert(g.lastMessage === 'answer', 'thread summary carries the last message');
|
||||
|
||||
console.log('\nv1.1 membership + replies smoke: PASS');
|
||||
process.exit(0);
|
||||
@@ -29,10 +29,12 @@ try {
|
||||
await Promise.all([once(alice, 'connect'), once(bob, 'connect')]);
|
||||
assert(true, `Alice connected to A (${A_URL}), Bob to B (${B_URL})`);
|
||||
|
||||
// Alice opens a thread on instance A; Bob joins the SAME thread on instance B.
|
||||
// Alice opens a thread on instance A; membership is governed so Alice ADDS Bob, who
|
||||
// then opens the SAME thread on instance B.
|
||||
const opened = await alice.emitWithAck('open_thread', {});
|
||||
const threadId = opened.threadId;
|
||||
assert(!!threadId, `Alice opened thread ${threadId} on instance A`);
|
||||
await alice.emitWithAck('add_participant', { threadId, userId: 'bob-cluster' });
|
||||
await bob.emitWithAck('open_thread', { threadId });
|
||||
assert(true, 'Bob joined the same thread on instance B');
|
||||
|
||||
|
||||
@@ -44,10 +44,11 @@ const alice = connect('alice');
|
||||
const bob = connect('bob');
|
||||
|
||||
try {
|
||||
// Alice creates a thread; Bob joins it.
|
||||
// Alice creates a thread; membership is governed, so Alice ADDS Bob (he can't self-join).
|
||||
const opened = await alice.emitWithAck('open_thread', {});
|
||||
const threadId = opened.threadId;
|
||||
assert(!!threadId, `Alice created thread ${threadId}`);
|
||||
await alice.emitWithAck('add_participant', { threadId, userId: 'bob' });
|
||||
await bob.emitWithAck('open_thread', { threadId });
|
||||
|
||||
// Alice sends; Bob should receive it live.
|
||||
|
||||
Reference in New Issue
Block a user