@bongos/core 1.19.408 → 1.19.410
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/.bongos-core.json +50 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +124 -4
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +43 -36
- package/docs/copy-registry.json +122 -39
- package/migrations/core_229_goals_accepting_requests.sql +37 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goals-page.js +26 -2
- package/modules/hall-ui/public/goals-render.js +35 -0
- package/modules/lifecycle/db-goals.js +22 -1
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/goal-edits.js +40 -0
- package/modules/lifecycle/routes/goal-route-authz.js +51 -1
- package/modules/lifecycle/routes/goals.js +46 -1
- package/modules/platform-identity/migrations/platform_identity_012_recent_ships.sql +40 -0
- package/modules/platform-identity/platform-identity.js +87 -6
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/platform-identity/tests/platform-identity.mjs +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-admission.js +89 -8
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/routes/auth.js +21 -0
- package/src/module-api.js +5 -1
- package/tests/goal_membership_requests.mjs +154 -1
- package/tests/hub_recent_ships.mjs +373 -0
- package/tests/prose_edits.mjs +8 -4
|
@@ -30,6 +30,7 @@ const express = require('express');
|
|
|
30
30
|
const crypto = require('node:crypto');
|
|
31
31
|
|
|
32
32
|
const auth = require('../auth');
|
|
33
|
+
const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
|
|
33
34
|
const db = require('../db');
|
|
34
35
|
const { pairStore } = require('../app-pair');
|
|
35
36
|
const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
|
|
@@ -376,7 +377,27 @@ const DISCORD_REDIRECT_URI = `${DISCORD_REDIRECT_ORIGIN}/api/gds/auth/discord/ca
|
|
|
376
377
|
// sign-in started on a builders.* subdomain must still use the apex.
|
|
377
378
|
const WEB_REDIRECT_ORIGIN = process.env.GDS_PUBLIC_ORIGIN || configuredOAuthOrigin();
|
|
378
379
|
|
|
380
|
+
// The ship-time hub push (task 1002803, ADR 0171 amendment R01) lives here
|
|
381
|
+
// because this file already owns the OTHER trigger: the federated sign-in flows
|
|
382
|
+
// below are what fire the check-in + rollup today, and the reporter they call is
|
|
383
|
+
// this router's own auth-admission sibling. `task.shipped` is an EVENT, not a
|
|
384
|
+
// port — many listeners may react to a ship and none of them returns anything
|
|
385
|
+
// (the discord/sessions listener precedent). off→on guarded so a test that
|
|
386
|
+
// re-instantiates the router never stacks duplicate listeners.
|
|
387
|
+
let _shipRollupListenerOff = null;
|
|
388
|
+
function registerSeams() {
|
|
389
|
+
if (_shipRollupListenerOff) _shipRollupListenerOff();
|
|
390
|
+
// Fire-and-forget: reportActivityRollupForShippedTask swallows every error and
|
|
391
|
+
// no-ops on an unfederated instance, so the .catch is belt-and-braces against a
|
|
392
|
+
// future edit letting one escape into an unhandledRejection (the sign-in
|
|
393
|
+
// chain's reasoning, auth-idp.js).
|
|
394
|
+
_shipRollupListenerOff = seams.on('task.shipped', (task) => {
|
|
395
|
+
reportActivityRollupForShippedTask(task).catch(() => {});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
379
399
|
module.exports = function buildAuthRouter() {
|
|
400
|
+
registerSeams();
|
|
380
401
|
const router = express.Router();
|
|
381
402
|
|
|
382
403
|
// /auth/device — CLI flow
|
package/src/module-api.js
CHANGED
|
@@ -1247,8 +1247,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1247
1247
|
// landed since 1.19.406 with no explicit bump. run 33214451873. (task 1002620)
|
|
1248
1248
|
// 1.19.408 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1249
1249
|
// landed since 1.19.407 with no explicit bump. run 33216756796. (task 1002620)
|
|
1250
|
+
// 1.19.409 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1251
|
+
// landed since 1.19.408 with no explicit bump. run 33217065997. (task 1002620)
|
|
1252
|
+
// 1.19.410 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1253
|
+
// landed since 1.19.409 with no explicit bump. run 33217599338. (task 1002620)
|
|
1250
1254
|
// ---------------------------------------------------------------------------
|
|
1251
|
-
const CORE_VERSION = '1.19.
|
|
1255
|
+
const CORE_VERSION = '1.19.410'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1252
1256
|
|
|
1253
1257
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1254
1258
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
// goal is still OPEN (a stale invite can't admit to an achieved/archived goal);
|
|
20
20
|
// declining stays allowed, so the route declines before ever reading the goal.
|
|
21
21
|
// • the migration is additive + private-safe (default 'public', partial unique).
|
|
22
|
+
// • setGoalAcceptingRequests + the D6 closed-to-requests gate (task 1002971) —
|
|
23
|
+
// a private goal can stop taking join-requests WITHOUT stopping invitations.
|
|
24
|
+
// • authorizeRescindInvitation (task 1002971) — the manager-side rescind, the
|
|
25
|
+
// missing half of core_188's 'cancelled' state.
|
|
22
26
|
|
|
23
27
|
import { strict as assert } from 'node:assert';
|
|
24
28
|
import { createRequire } from 'node:module';
|
|
@@ -32,6 +36,7 @@ process.env.NODE_ENV = 'test';
|
|
|
32
36
|
const require = createRequire(import.meta.url);
|
|
33
37
|
const {
|
|
34
38
|
setGoalVisibility,
|
|
39
|
+
setGoalAcceptingRequests,
|
|
35
40
|
createMembershipRequest,
|
|
36
41
|
getMembershipRequest,
|
|
37
42
|
resolveMembershipRequest,
|
|
@@ -48,6 +53,8 @@ const {
|
|
|
48
53
|
authorizeInvitationAccept,
|
|
49
54
|
authorizeJoinRequestResponse,
|
|
50
55
|
authorizeWithdrawJoinRequest,
|
|
56
|
+
authorizeRescindInvitation,
|
|
57
|
+
authorizeGoalManagement,
|
|
51
58
|
decorateGoalScope,
|
|
52
59
|
} = require('../modules/lifecycle/routes/goals.js');
|
|
53
60
|
const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
|
|
@@ -76,6 +83,27 @@ await test('setGoalVisibility: writes visibility + returns the goal row', async
|
|
|
76
83
|
assert.deepEqual(q.params, [7, 'private']);
|
|
77
84
|
});
|
|
78
85
|
|
|
86
|
+
// =========================================================================
|
|
87
|
+
// setGoalAcceptingRequests (task 1002971, spec D6) — the same shape as its
|
|
88
|
+
// visibility sibling: the route owns WHO may call it, the helper just writes.
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
await test('setGoalAcceptingRequests: writes the flag + returns the goal row', async () => {
|
|
92
|
+
const GOAL = { id: 7, visibility: 'private', accepting_requests: false };
|
|
93
|
+
const pool = fakePool((s) => (/UPDATE goals SET accepting_requests = \$2/.test(s) ? { rows: [GOAL] } : null));
|
|
94
|
+
const g = await setGoalAcceptingRequests({ goalId: 7, acceptingRequests: false }, { pool });
|
|
95
|
+
assert.deepEqual(g, GOAL);
|
|
96
|
+
const q = find(pool.queries, /UPDATE goals SET accepting_requests/);
|
|
97
|
+
assert.deepEqual(q.params, [7, false]);
|
|
98
|
+
assert.ok(/RETURNING .*accepting_requests/.test(norm(q.sql)),
|
|
99
|
+
'the returned row carries the flag, so the hall can render the toggle state');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('setGoalAcceptingRequests: unknown goal → null', async () => {
|
|
103
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
104
|
+
assert.equal(await setGoalAcceptingRequests({ goalId: 999, acceptingRequests: true }, { pool }), null);
|
|
105
|
+
});
|
|
106
|
+
|
|
79
107
|
await test('setGoalVisibility: unknown goal → null', async () => {
|
|
80
108
|
const pool = fakePool(() => ({ rows: [] }));
|
|
81
109
|
assert.equal(await setGoalVisibility({ goalId: 999, visibility: 'public' }, { pool }), null);
|
|
@@ -302,6 +330,15 @@ await test('migration core_188: additive, defaults public, partial-unique pendin
|
|
|
302
330
|
'migration is purely additive');
|
|
303
331
|
});
|
|
304
332
|
|
|
333
|
+
await test('migration core_229: accepting_requests is additive and defaults TRUE', () => {
|
|
334
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
335
|
+
const sql = readFileSync(join(here, '..', 'migrations', 'core_229_goals_accepting_requests.sql'), 'utf8');
|
|
336
|
+
assert.ok(/ADD COLUMN IF NOT EXISTS accepting_requests boolean NOT NULL DEFAULT true/.test(sql),
|
|
337
|
+
'defaults true so every existing private goal keeps taking requests');
|
|
338
|
+
assert.ok(!/\bDROP\s+(TABLE|COLUMN|INDEX)\b|\bDELETE\s+FROM\b|\bTRUNCATE\b/i.test(sql),
|
|
339
|
+
'migration is purely additive');
|
|
340
|
+
});
|
|
341
|
+
|
|
305
342
|
// =========================================================================
|
|
306
343
|
// The pure route gates (task 1952) — DB-free, ordering included.
|
|
307
344
|
// =========================================================================
|
|
@@ -348,6 +385,45 @@ await test('authorizeCreateJoinRequest: public but PROTECTED scope → ALLOW (no
|
|
|
348
385
|
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'public' }, isMember: false, scopeProtected: true }), { ok: true });
|
|
349
386
|
});
|
|
350
387
|
|
|
388
|
+
// The D6 closed-to-requests arm (task 1002971). A private goal whose lead closed
|
|
389
|
+
// applications refuses the ask with honest copy instead of queueing it.
|
|
390
|
+
const CLOSED_PRIVATE = { id: 5, status: 'open', created_by: '1', visibility: 'private', accepting_requests: false };
|
|
391
|
+
await test('authorizeCreateJoinRequest: private + closed → 409 not_accepting_requests, honest copy', () => {
|
|
392
|
+
const d = authorizeCreateJoinRequest({ goal: CLOSED_PRIVATE, isMember: false, scopeProtected: false });
|
|
393
|
+
assert.equal(d.status, 409);
|
|
394
|
+
assert.equal(d.body.error, 'not_accepting_requests');
|
|
395
|
+
assert.equal(d.body.message, "This goal isn't taking requests right now.",
|
|
396
|
+
'the spec D6 copy verbatim — the refusal says why, it does not pretend the goal is gone');
|
|
397
|
+
});
|
|
398
|
+
await test('authorizeCreateJoinRequest: private + explicitly accepting → ALLOW', () => {
|
|
399
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { ...CLOSED_PRIVATE, accepting_requests: true }, isMember: false, scopeProtected: false }), { ok: true });
|
|
400
|
+
});
|
|
401
|
+
// A goal row read before the migration (or by an older release) has no such key.
|
|
402
|
+
// Only an explicit false closes the door — undefined must never read as closed.
|
|
403
|
+
await test('authorizeCreateJoinRequest: private, flag absent → ALLOW (fails OPEN, not closed)', () => {
|
|
404
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'private' }, isMember: false, scopeProtected: false }), { ok: true });
|
|
405
|
+
});
|
|
406
|
+
// D6 calls the flag meaningless on a public goal, so a PUBLIC protected-scope goal
|
|
407
|
+
// keeps its Archon-mediated request door open whatever the column says.
|
|
408
|
+
await test('authorizeCreateJoinRequest: PUBLIC protected goal + closed flag → ALLOW (flag is private-only)', () => {
|
|
409
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'public', accepting_requests: false }, isMember: false, scopeProtected: true }), { ok: true });
|
|
410
|
+
});
|
|
411
|
+
// The whole point of D6: closing applications is about unsolicited inbound. The
|
|
412
|
+
// invite path is gated by authorizeGoalManagement, which never reads the flag —
|
|
413
|
+
// so a lead can still invite into a goal that is closed to requests.
|
|
414
|
+
await test('closed to requests: INVITATIONS are unaffected — the invite gate still allows the owner', () => {
|
|
415
|
+
assert.deepEqual(authorizeGoalManagement({ goal: CLOSED_PRIVATE, actorId: '1', actorRank: 'metic', actorMembership: null }), { ok: true });
|
|
416
|
+
});
|
|
417
|
+
await test('routes: the invitation paths never read accepting_requests (only the request gate does)', () => {
|
|
418
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
419
|
+
const authz = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goal-route-authz.js'), 'utf8');
|
|
420
|
+
const gates = authz.match(/^function authorize\w+[\s\S]*?^}/gm) || [];
|
|
421
|
+
const reading = gates.filter((g) => /goal\.accepting_requests/.test(g));
|
|
422
|
+
assert.equal(reading.length, 1, 'exactly one gate reads the flag');
|
|
423
|
+
assert.ok(/^function authorizeCreateJoinRequest/.test(reading[0]),
|
|
424
|
+
'and it is the JOIN-REQUEST gate — invitations must stay unaffected (spec D6)');
|
|
425
|
+
});
|
|
426
|
+
|
|
351
427
|
// authorizeInvitationResponse — only the invitee resolves their own pending invite.
|
|
352
428
|
const INVITE = { id: 9, goal_id: '5', builder_id: '2', kind: 'invite', status: 'pending' };
|
|
353
429
|
await test('authorizeInvitationResponse: unknown request → 404', () => {
|
|
@@ -480,6 +556,80 @@ await test('authorizeWithdrawJoinRequest: the requester (string vs number id)
|
|
|
480
556
|
assert.deepEqual(authorizeWithdrawJoinRequest({ request: REQUEST_FOR_WITHDRAW, goalId: '5', actorId: 2 }), { ok: true });
|
|
481
557
|
});
|
|
482
558
|
|
|
559
|
+
// =========================================================================
|
|
560
|
+
// authorizeRescindInvitation (task 1002971) — the manager-side mirror of
|
|
561
|
+
// withdraw, and the missing half of core_188's 'cancelled' state. Authority is
|
|
562
|
+
// authorizeGoalManagement (owner|manager|Archon), checked BEFORE the request
|
|
563
|
+
// lookup so an unauthorized actor never learns an invitation exists.
|
|
564
|
+
// =========================================================================
|
|
565
|
+
|
|
566
|
+
const RESCIND_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
|
|
567
|
+
const PENDING_INVITE = { id: 12, goal_id: '5', builder_id: '2', kind: 'invite', status: 'pending' };
|
|
568
|
+
const rescind = (over = {}) => authorizeRescindInvitation({
|
|
569
|
+
goal: RESCIND_GOAL, actorId: '1', actorRank: 'metic', actorMembership: null,
|
|
570
|
+
request: PENDING_INVITE, goalId: 5, ...over,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await test('authorizeRescindInvitation: the goal OWNER → ALLOW', () => {
|
|
574
|
+
assert.deepEqual(rescind(), { ok: true });
|
|
575
|
+
});
|
|
576
|
+
await test('authorizeRescindInvitation: a manager (lead) → ALLOW', () => {
|
|
577
|
+
assert.deepEqual(rescind({ actorId: '3', actorMembership: mgr(3) }), { ok: true });
|
|
578
|
+
});
|
|
579
|
+
await test('authorizeRescindInvitation: an Archon → ALLOW (acts as owner anywhere)', () => {
|
|
580
|
+
assert.deepEqual(rescind({ actorId: '99', actorRank: 'archon' }), { ok: true });
|
|
581
|
+
});
|
|
582
|
+
await test('authorizeRescindInvitation: a non-owner/manager → 403 (before any request lookup — no leak)', () => {
|
|
583
|
+
const d = rescind({ actorId: '7' });
|
|
584
|
+
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
|
585
|
+
const blind = authorizeRescindInvitation({
|
|
586
|
+
goal: RESCIND_GOAL, actorId: '7', actorRank: 'metic', actorMembership: null, request: null, goalId: 5,
|
|
587
|
+
});
|
|
588
|
+
assert.deepEqual(blind, d, 'the answer is identical whether or not the invitation exists');
|
|
589
|
+
});
|
|
590
|
+
await test('authorizeRescindInvitation: the INVITEE cannot rescind their own invitation → 403', () => {
|
|
591
|
+
const d = rescind({ actorId: '2' });
|
|
592
|
+
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
|
593
|
+
});
|
|
594
|
+
await test('authorizeRescindInvitation: unknown request → 404', () => {
|
|
595
|
+
const d = rescind({ request: null });
|
|
596
|
+
assert.equal(d.status, 404); assert.equal(d.body.error, 'request_not_found');
|
|
597
|
+
});
|
|
598
|
+
await test('authorizeRescindInvitation: invitation on a different goal → 404 (containment)', () => {
|
|
599
|
+
assert.equal(rescind({ request: { ...PENDING_INVITE, goal_id: '99' } }).status, 404);
|
|
600
|
+
});
|
|
601
|
+
await test('authorizeRescindInvitation: row is a join-request, not an invitation → 400', () => {
|
|
602
|
+
const d = rescind({ request: { ...PENDING_INVITE, kind: 'request' } });
|
|
603
|
+
assert.equal(d.status, 400); assert.equal(d.body.error, 'not_an_invitation');
|
|
604
|
+
});
|
|
605
|
+
// Rescinding an invite that is already resolved changes nothing: the gate refuses
|
|
606
|
+
// 409 not_pending, and db.resolveMembershipRequest moves only from 'pending', so a
|
|
607
|
+
// repeat rescind can never re-cancel an accepted membership.
|
|
608
|
+
await test('authorizeRescindInvitation: already resolved → 409 not_pending (no state change on a repeat)', () => {
|
|
609
|
+
for (const status of ['accepted', 'rejected', 'cancelled']) {
|
|
610
|
+
const d = rescind({ request: { ...PENDING_INVITE, status } });
|
|
611
|
+
assert.equal(d.status, 409); assert.equal(d.body.error, 'not_pending'); assert.equal(d.body.status, status);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
await test('authorizeRescindInvitation: a closed goal cannot be managed → 409 goal_not_open', () => {
|
|
615
|
+
const d = rescind({ goal: { ...RESCIND_GOAL, status: 'archived' } });
|
|
616
|
+
assert.equal(d.status, 409); assert.equal(d.body.error, 'goal_not_open');
|
|
617
|
+
});
|
|
618
|
+
// The route must land the state core_188 already defined for "rescinded by the
|
|
619
|
+
// opener" — not a second word for the same thing (source-pinned, the addGoalMember
|
|
620
|
+
// resolvedBy precedent; the live route is exercised in goal_routes.mjs).
|
|
621
|
+
await test('routes: the rescind route resolves to cancelled, attributed to the actor', () => {
|
|
622
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
623
|
+
const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
|
|
624
|
+
const handler = src.slice(src.indexOf("router.post('/goals/:id/invitations/:reqId/rescind'"));
|
|
625
|
+
assert.ok(handler.startsWith("router.post('/goals/:id/invitations/:reqId/rescind'"), 'the rescind route exists');
|
|
626
|
+
const body = handler.slice(0, handler.indexOf('\n });'));
|
|
627
|
+
assert.ok(/db\.resolveMembershipRequest\(\{ id: reqId, status: 'cancelled', resolvedBy: req\.builder\.id \}\)/.test(body),
|
|
628
|
+
"cancelled — core_188's 'withdrawn/rescinded by the opener' state, attributed to the rescinder");
|
|
629
|
+
assert.ok(body.indexOf('authorizeRescindInvitation({') < body.indexOf('db.resolveMembershipRequest('),
|
|
630
|
+
'the gate runs before the write');
|
|
631
|
+
});
|
|
632
|
+
|
|
483
633
|
// =========================================================================
|
|
484
634
|
// decorateGoalScope (task 1002689) — goal payloads carry a derived
|
|
485
635
|
// scope_protected flag so the hall can render the join-gate on a
|
|
@@ -509,7 +659,7 @@ await test('decorateGoalScope: returns a copy, never mutates the row', () => {
|
|
|
509
659
|
// stay 'any-builder'; an accidental requireRank re-gate must be a red build.
|
|
510
660
|
// =========================================================================
|
|
511
661
|
|
|
512
|
-
await test('route-rank pins: the
|
|
662
|
+
await test('route-rank pins: the ten private-goal routes are pinned any-builder', () => {
|
|
513
663
|
const pins = MODULE_EXPECTED_RANKS.lifecycle;
|
|
514
664
|
for (const r of [
|
|
515
665
|
'GET /goals/inbox',
|
|
@@ -520,6 +670,9 @@ await test('route-rank pins: the eight private-goal routes are pinned any-builde
|
|
|
520
670
|
'POST /goals/:id/invitations/:reqId/respond',
|
|
521
671
|
'GET /goals/:id/requests',
|
|
522
672
|
'POST /goals/:id/requests/:reqId/withdraw',
|
|
673
|
+
// task 1002971 — the D6 toggle + the manager-side rescind.
|
|
674
|
+
'PATCH /goals/:id/accepting-requests',
|
|
675
|
+
'POST /goals/:id/invitations/:reqId/rescind',
|
|
523
676
|
]) {
|
|
524
677
|
assert.equal(pins[r], 'any-builder', `${r} must be pinned any-builder (wall is in-handler)`);
|
|
525
678
|
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// tests/hub_recent_ships.mjs — task 1002803 (P07, goal 1000045 "public builder
|
|
2
|
+
// profiles"; profile-page spec "The page spec" row 4 + ADR 0171 amendment R01).
|
|
3
|
+
//
|
|
4
|
+
// "Recent works" reaches the hub by PUSH, not pull: the hub has no `tasks` table
|
|
5
|
+
// for other projects, so each project reports at most 3 of a builder's latest
|
|
6
|
+
// shipped works on POST /sso/activity/rollup. Three deliverables, and each one
|
|
7
|
+
// has a failure mode that a code read does not catch:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE CAP IS THE HUB'S, NOT THE SENDER'S. A reporting instance is
|
|
10
|
+
// third-party self-report (ADR 0171 D7). If the cap lived only in the
|
|
11
|
+
// sender, an old, buggy or hostile client would set the size of a public
|
|
12
|
+
// profile section. So the cap, the field shape and the link bound are all
|
|
13
|
+
// asserted against what upsertActivity BINDS, not against what was passed.
|
|
14
|
+
// 2. A SHIP LINK IS THE ONE FREE-TEXT URL ON A PUBLIC PORTFOLIO. It may only
|
|
15
|
+
// point back into the reporting client's own registered origin, or it is a
|
|
16
|
+
// phishing hop published under someone else's name.
|
|
17
|
+
// 3. THE ORDERING IS THE WHOLE POINT OF THE SEQUENCING FIX. The hub refuses a
|
|
18
|
+
// rollup without a membership row, and the check-in is what creates it —
|
|
19
|
+
// so asserting "both fired" would still pass while a first sign-in raced
|
|
20
|
+
// itself. This asserts the rollup has NOT fired while the check-in is still
|
|
21
|
+
// in flight, which is the only shape that fails when the chain is broken.
|
|
22
|
+
//
|
|
23
|
+
// Plus the privacy edges the goal turns on: a work title is at least as
|
|
24
|
+
// identifying as the project name it came from, so it may only surface where
|
|
25
|
+
// that name may (a non-public project stays counted-but-unnamed, ships and all),
|
|
26
|
+
// and hide_stats suppresses the whole cross-project read including recent_ships.
|
|
27
|
+
//
|
|
28
|
+
// DB-free: every DB function takes an injectable pool and is driven by a
|
|
29
|
+
// recording fake (the modules/platform-identity/tests/platform-identity.mjs
|
|
30
|
+
// idiom); the core halves pin require.cache (the tests/profile_route.mjs idiom)
|
|
31
|
+
// so the REAL handlers and the REAL chain run against spies.
|
|
32
|
+
//
|
|
33
|
+
// Run: node tests/hub_recent_ships.mjs
|
|
34
|
+
|
|
35
|
+
import { strict as assert } from 'node:assert';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
|
|
43
|
+
let passed = 0, failed = 0;
|
|
44
|
+
async function t(name, fn) {
|
|
45
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
46
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A recording fake pool: answers by SQL shape and records every call, so a test
|
|
50
|
+
// asserts the params the real query actually bound.
|
|
51
|
+
function mockPool(answer = () => ({ rows: [] })) {
|
|
52
|
+
const calls = [];
|
|
53
|
+
return {
|
|
54
|
+
calls,
|
|
55
|
+
query: async (text, params = []) => {
|
|
56
|
+
calls.push({ text: String(text), params });
|
|
57
|
+
return answer(String(text), params) || { rows: [] };
|
|
58
|
+
},
|
|
59
|
+
find: (re) => calls.find((c) => re.test(c.text)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
64
|
+
|
|
65
|
+
const ORIGIN = 'https://p07.example';
|
|
66
|
+
const ship = (over = {}) => ({
|
|
67
|
+
id: 1002803, title: 'A shipped work', shipped_at: '2026-08-28T10:00:00.000Z',
|
|
68
|
+
url: `${ORIGIN}/builders#/task/1002803`, ...over,
|
|
69
|
+
});
|
|
70
|
+
// What upsertActivity BOUND as recent_ships — the jsonb param, parsed back.
|
|
71
|
+
async function storedShips(recentShips, clientOrigin = ORIGIN) {
|
|
72
|
+
const pool = mockPool(() => ({ rows: [{ id: 1 }] }));
|
|
73
|
+
await pi.upsertActivity({ clientId: 'c1', githubId: 42, recentShips, clientOrigin }, { pool });
|
|
74
|
+
const call = pool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
75
|
+
assert.ok(call, 'the activity upsert ran');
|
|
76
|
+
const raw = call.params[7];
|
|
77
|
+
assert.equal(typeof raw, 'string', 'jsonb is bound as a JSON string, not a JS array (pg would emit a Postgres array literal)');
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('\n1. the cap and the shape are the HUB\'s, not the sender\'s:');
|
|
82
|
+
|
|
83
|
+
await t('a client reporting 5 works stores exactly 3', async () => {
|
|
84
|
+
const five = [1, 2, 3, 4, 5].map((n) => ship({ id: n, title: `work ${n}` }));
|
|
85
|
+
const out = await storedShips(five);
|
|
86
|
+
assert.equal(out.length, 3);
|
|
87
|
+
assert.deepEqual(out.map((s) => s.id), [1, 2, 3], 'the first three, in reported order');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await t('no works stores an empty list, never null and never a stray shape', async () => {
|
|
91
|
+
for (const nothing of [[], undefined, null, 'nope', { id: 1 }]) {
|
|
92
|
+
const out = await storedShips(nothing);
|
|
93
|
+
assert.deepEqual(out, [], `${JSON.stringify(nothing) ?? 'undefined'} → []`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('a stored work carries exactly id + title + shipped_at + url', async () => {
|
|
98
|
+
const [s] = await storedShips([ship({ extra: 'DO-NOT-STORE' })]);
|
|
99
|
+
assert.deepEqual(Object.keys(s).sort(), ['id', 'shipped_at', 'title', 'url']);
|
|
100
|
+
assert.equal(s.id, 1002803);
|
|
101
|
+
assert.equal(s.title, 'A shipped work');
|
|
102
|
+
assert.equal(s.shipped_at, '2026-08-28T10:00:00.000Z');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('a work with no usable id or title is dropped, not stored half-empty', async () => {
|
|
106
|
+
const out = await storedShips([
|
|
107
|
+
ship({ id: 'not-a-number' }),
|
|
108
|
+
ship({ id: 7, title: ' ' }),
|
|
109
|
+
ship({ id: -1 }),
|
|
110
|
+
ship({ id: 9, title: 'kept' }),
|
|
111
|
+
]);
|
|
112
|
+
assert.deepEqual(out.map((s) => s.id), [9]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('an unparseable date nulls only the date, never the work', async () => {
|
|
116
|
+
const [s] = await storedShips([ship({ shipped_at: 'yesterday-ish' })]);
|
|
117
|
+
assert.equal(s.shipped_at, null);
|
|
118
|
+
assert.equal(s.id, 1002803, 'the work still publishes without a date');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await t('a title longer than the cap is truncated, not rejected', async () => {
|
|
122
|
+
const [s] = await storedShips([ship({ title: 'x'.repeat(400) })]);
|
|
123
|
+
assert.equal(s.title.length, 200);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
console.log('\n2. a ship link may only point back into the reporting instance:');
|
|
127
|
+
|
|
128
|
+
for (const [label, url] of [
|
|
129
|
+
['another site', 'https://evil.example/phish'],
|
|
130
|
+
['a javascript: scheme', 'javascript:alert(1)'],
|
|
131
|
+
['embedded credentials', 'https://user:pw@p07.example/x'],
|
|
132
|
+
['a lookalike suffix', 'https://notp07.example/x'],
|
|
133
|
+
['a non-string', 12345],
|
|
134
|
+
// The mirror of the subdomain rule is NOT offered: on a shared multi-tenant
|
|
135
|
+
// apex it would let a client registered at foo.github.io publish links on bare
|
|
136
|
+
// github.io. The bound is downward only.
|
|
137
|
+
['an ancestor of the registered origin', 'https://example/x'],
|
|
138
|
+
]) {
|
|
139
|
+
await t(`${label} stores no link (the work still publishes)`, async () => {
|
|
140
|
+
const [s] = await storedShips([ship({ url })]);
|
|
141
|
+
assert.equal(s.url, null);
|
|
142
|
+
assert.equal(s.id, 1002803);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await t('the client\'s own origin and a subdomain of it are kept', async () => {
|
|
147
|
+
const same = await storedShips([ship({ url: `${ORIGIN}/builders#/task/1` })]);
|
|
148
|
+
assert.equal(same[0].url, `${ORIGIN}/builders#/task/1`);
|
|
149
|
+
const sub = await storedShips([ship({ url: 'https://builders.p07.example/builders#/task/1' })]);
|
|
150
|
+
assert.equal(sub[0].url, 'https://builders.p07.example/builders#/task/1', 'an instance hall on a subdomain of its registered origin');
|
|
151
|
+
// …but never UP. A client registered on a subdomain may not publish links on
|
|
152
|
+
// the apex above it — that apex may be shared (the github.io shape).
|
|
153
|
+
const up = await storedShips([ship({ url: 'https://p07.example/x' })], 'https://builders.p07.example');
|
|
154
|
+
assert.equal(up[0].url, null, 'the bound is downward only');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
console.log('\n3. POST /sso/activity/rollup accepts the field and hands the hub the trusted origin:');
|
|
158
|
+
|
|
159
|
+
await t('the route passes recent_ships through and takes the origin from the CLIENT row', async () => {
|
|
160
|
+
const SECRET = 'p07-secret';
|
|
161
|
+
const rollupPool = mockPool((sql) => {
|
|
162
|
+
if (/FROM platform_identity_sso_clients/.test(sql)) {
|
|
163
|
+
return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: ORIGIN }] };
|
|
164
|
+
}
|
|
165
|
+
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
166
|
+
return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member' }] };
|
|
167
|
+
}
|
|
168
|
+
return { rows: [{ id: 1 }] };
|
|
169
|
+
});
|
|
170
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
171
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
172
|
+
const realApi = require('../src/module-api.js');
|
|
173
|
+
require.cache[moduleApiPath] = {
|
|
174
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
175
|
+
exports: {
|
|
176
|
+
pool: rollupPool,
|
|
177
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
178
|
+
resolveSession: async () => null,
|
|
179
|
+
logger: () => ({ info() {}, warn() {}, error() {} }),
|
|
180
|
+
LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
|
|
181
|
+
corsPublicGet: () => (_q, _s, next) => next(),
|
|
182
|
+
on: () => () => {},
|
|
183
|
+
hasProvider: () => false,
|
|
184
|
+
registerProvider: () => {},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
const ssoRoutes = require('../modules/platform-identity/routes/sso.js');
|
|
188
|
+
const router = ssoRoutes();
|
|
189
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
|
|
190
|
+
assert.ok(layer, 'the rollup route is mounted');
|
|
191
|
+
|
|
192
|
+
const res = { body: null, failure: null };
|
|
193
|
+
res.json = (b) => { res.body = b; return res; };
|
|
194
|
+
res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
|
|
195
|
+
const req = {
|
|
196
|
+
body: {
|
|
197
|
+
client_id: 'c1', client_secret: SECRET, github_id: 42,
|
|
198
|
+
credits: 10, tasks_shipped: 2, karma: 1, rank: 'metic', disciplines: ['engineer'],
|
|
199
|
+
// Five, so the HUB's cap is what decides — a sender that ignores its own
|
|
200
|
+
// cap must not widen the section.
|
|
201
|
+
recent_ships: [1, 2, 3, 4, 5].map((n) => ship({ id: n, title: `work ${n}`, url: `${ORIGIN}/builders#/task/${n}` })),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
await layer.route.stack[0].handle(req, res, () => {});
|
|
205
|
+
assert.equal(res.failure, null, `no failure (validateOrRespond runs in STRICT mode, so an unknown recent_ships would 400 here): ${JSON.stringify(res.failure)}`);
|
|
206
|
+
assert.deepEqual(res.body, { ok: true });
|
|
207
|
+
|
|
208
|
+
const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
209
|
+
const stored = JSON.parse(insert.params[7]);
|
|
210
|
+
assert.equal(stored.length, 3, 'the hub capped what the sender did not');
|
|
211
|
+
assert.equal(stored[0].url, `${ORIGIN}/builders#/task/1`, 'links pass the origin bound taken from the authenticated client row');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
console.log('\n4. a work title surfaces only where the project name may:');
|
|
215
|
+
|
|
216
|
+
await t('getRollupForBuilder attaches recent_ships to publicly-named projects only', async () => {
|
|
217
|
+
const pool = mockPool((sql) => {
|
|
218
|
+
if (/WITH agg AS/.test(sql)) {
|
|
219
|
+
return { rows: [{ projects: 2, credits: 5, works_shipped: 2, karma: 0, last_active: null, disciplines: ['engineer'] }] };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
rows: [
|
|
223
|
+
{ name: 'Public Project', origin: ORIGIN, rank_held: 'metic', is_public: true, recent_ships: [ship()] },
|
|
224
|
+
{ name: 'SECRET-PROJECT', origin: 'https://priv.example', rank_held: 'thetes', is_public: false, recent_ships: [ship({ id: 5, title: 'SECRET-WORK' })] },
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const out = await pi.getRollupForBuilder(42, { pool });
|
|
229
|
+
assert.equal(out.projects.length, 1);
|
|
230
|
+
assert.equal(out.more_projects, 1);
|
|
231
|
+
assert.deepEqual(out.projects[0].recent_ships, [ship()]);
|
|
232
|
+
const serialized = JSON.stringify(out);
|
|
233
|
+
assert.ok(!serialized.includes('SECRET-WORK'), 'a non-public project\'s work titles never leave the hub');
|
|
234
|
+
assert.ok(!serialized.includes('SECRET-PROJECT'), 'nor does its name (the pre-existing rule this one follows)');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await t('a project that reported no works reads as an empty list, not null', async () => {
|
|
238
|
+
const pool = mockPool((sql) => {
|
|
239
|
+
if (/WITH agg AS/.test(sql)) return { rows: [{ projects: 1, credits: 0, works_shipped: 0, karma: 0, last_active: null, disciplines: null }] };
|
|
240
|
+
return { rows: [{ name: 'P', origin: ORIGIN, rank_held: null, is_public: true, recent_ships: null }] };
|
|
241
|
+
});
|
|
242
|
+
const out = await pi.getRollupForBuilder(42, { pool });
|
|
243
|
+
assert.deepEqual(out.projects[0].recent_ships, []);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await t('hide_stats suppresses the whole cross-project read, recent_ships included', async () => {
|
|
247
|
+
for (const acct of [
|
|
248
|
+
{ profile_state: 'active', hide_stats: true },
|
|
249
|
+
{ profile_state: 'provisional', hide_stats: false },
|
|
250
|
+
]) {
|
|
251
|
+
const pool = mockPool(() => ({ rows: [acct] }));
|
|
252
|
+
assert.equal(await pi.getPublicProfileExtras(42, { pool }), null, JSON.stringify(acct));
|
|
253
|
+
assert.ok(!pool.find(/WITH agg AS/), 'the rollup query never even runs — no partial numbers, no leak by subtraction');
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// The core halves. Everything below pins require.cache BEFORE the core files
|
|
259
|
+
// load, so the REAL handlers run against spies.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
const admissionPath = require.resolve('../src/bongos/auth-admission.js');
|
|
263
|
+
const realAdmission = require('../src/bongos/auth-admission.js');
|
|
264
|
+
const spy = { shipPushes: [], order: [] };
|
|
265
|
+
// A mutable exports object: section 6 re-points the two reporters at spies
|
|
266
|
+
// after section 5 has used the real ones.
|
|
267
|
+
const admissionExports = {
|
|
268
|
+
...realAdmission,
|
|
269
|
+
reportActivityRollupForShippedTask: async (task) => { spy.shipPushes.push(task); },
|
|
270
|
+
};
|
|
271
|
+
require.cache[admissionPath] = {
|
|
272
|
+
id: admissionPath, filename: admissionPath, loaded: true, exports: admissionExports,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
console.log('\n5. the ship-time push — hub numbers stop being as stale as the last sign-in:');
|
|
276
|
+
|
|
277
|
+
await t('a task reaching shipped fires the rollup push exactly once', async () => {
|
|
278
|
+
const seams = require('../src/module-seams.js');
|
|
279
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
280
|
+
buildAuthRouter();
|
|
281
|
+
spy.shipPushes.length = 0;
|
|
282
|
+
const invoked = await seams.emitAsync('task.shipped', { id: 1002803, shipped_by: 7 });
|
|
283
|
+
assert.ok(invoked >= 1, 'the core auth router registered a task.shipped listener');
|
|
284
|
+
assert.equal(spy.shipPushes.length, 1, 'the ship push fired once');
|
|
285
|
+
assert.equal(spy.shipPushes[0].shipped_by, 7);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await t('re-instantiating the router does not stack a second listener', async () => {
|
|
289
|
+
const seams = require('../src/module-seams.js');
|
|
290
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
291
|
+
buildAuthRouter();
|
|
292
|
+
buildAuthRouter();
|
|
293
|
+
spy.shipPushes.length = 0;
|
|
294
|
+
await seams.emitAsync('task.shipped', { id: 1, shipped_by: 7 });
|
|
295
|
+
assert.equal(spy.shipPushes.length, 1, 'off→on guarded — a ship is pushed once, not once per router build');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
console.log('\n6. the check-in is AWAITED before the rollup (ADR 0171 D7 sequencing):');
|
|
299
|
+
|
|
300
|
+
await t('a federated sign-in does not report activity until its membership check-in has resolved', async () => {
|
|
301
|
+
// Re-point the reporters at ordering spies, then load a FRESH auth-idp so it
|
|
302
|
+
// destructures these rather than the real ones section 5 left in place.
|
|
303
|
+
let releaseCheckIn;
|
|
304
|
+
const checkInLanded = new Promise((r) => { releaseCheckIn = r; });
|
|
305
|
+
admissionExports.reportMembershipCheckIn = async () => {
|
|
306
|
+
spy.order.push('check-in:start');
|
|
307
|
+
await checkInLanded;
|
|
308
|
+
spy.order.push('check-in:done');
|
|
309
|
+
};
|
|
310
|
+
admissionExports.reportActivityRollup = async () => { spy.order.push('rollup'); };
|
|
311
|
+
admissionExports.openEnrollmentEnabled = () => true;
|
|
312
|
+
admissionExports.maybeSeatFirstAdmin = async (b) => b;
|
|
313
|
+
admissionExports.safeSyncRankRole = async () => {};
|
|
314
|
+
|
|
315
|
+
const pin = (rel, exports) => {
|
|
316
|
+
const p = require.resolve(rel);
|
|
317
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
318
|
+
return p;
|
|
319
|
+
};
|
|
320
|
+
pin('../src/bongos/db-kernel.js', {
|
|
321
|
+
RANKS: ['xenos', 'thetes', 'metic', 'archon'],
|
|
322
|
+
upsertBuilder: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
|
|
323
|
+
getBuilderByGithubId: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
|
|
324
|
+
getBuilderById: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
|
|
325
|
+
createSession: async () => ({}),
|
|
326
|
+
lookupSession: async () => null,
|
|
327
|
+
seatFirstAdminIfUnclaimed: async () => null,
|
|
328
|
+
hasInvitedAccessRequest: async () => true,
|
|
329
|
+
accessStateByLogin: async () => ({}),
|
|
330
|
+
});
|
|
331
|
+
pin('../src/bongos/pool.js', { pool: { query: async () => ({ rows: [] }) } });
|
|
332
|
+
pin('../src/bongos/auth-config.js', {
|
|
333
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
|
|
334
|
+
});
|
|
335
|
+
pin('../src/bongos/auth-github.js', { newSessionToken: () => 'tok' });
|
|
336
|
+
delete require.cache[require.resolve('../src/bongos/auth-idp.js')];
|
|
337
|
+
|
|
338
|
+
const realFetch = globalThis.fetch;
|
|
339
|
+
globalThis.fetch = async () => ({
|
|
340
|
+
ok: true,
|
|
341
|
+
json: async () => ({ github_id: 42, github_login: 'p07', display_name: 'P07', avatar_url: null }),
|
|
342
|
+
text: async () => '',
|
|
343
|
+
});
|
|
344
|
+
try {
|
|
345
|
+
const idp = require('../src/bongos/auth-idp.js');
|
|
346
|
+
await idp.completeIdpFlow({ code: 'x', redirectUri: 'https://p07.example/cb' });
|
|
347
|
+
// The sign-in has RETURNED and the check-in is still in flight. If the two
|
|
348
|
+
// pushes were fired side by side, the rollup would already be in `order`
|
|
349
|
+
// here — and on a first sign-in it would reach a hub with no membership row.
|
|
350
|
+
await new Promise((r) => setImmediate(r));
|
|
351
|
+
assert.deepEqual(spy.order, ['check-in:start'], `rollup must not have fired yet, saw ${JSON.stringify(spy.order)}`);
|
|
352
|
+
releaseCheckIn();
|
|
353
|
+
await new Promise((r) => setImmediate(r));
|
|
354
|
+
await new Promise((r) => setImmediate(r));
|
|
355
|
+
assert.deepEqual(spy.order, ['check-in:start', 'check-in:done', 'rollup'], 'the rollup runs only after the check-in resolved');
|
|
356
|
+
} finally {
|
|
357
|
+
globalThis.fetch = realFetch;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await t('the CLI device exchange chains the same way (both federated sign-ins, one rule)', async () => {
|
|
362
|
+
// completeDeviceExchange needs a hub-signed Ed25519 assertion to reach its
|
|
363
|
+
// chain, which is a different test's subject — so this half is pinned at the
|
|
364
|
+
// source, normalised to LF first (this checkout reads back CRLF, and an
|
|
365
|
+
// anchored multi-line match silently finds nothing otherwise).
|
|
366
|
+
const fs = require('node:fs');
|
|
367
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth-idp.js'), 'utf8').replace(/\r\n/g, '\n');
|
|
368
|
+
const chains = src.match(/reportMembershipCheckIn\([\s\S]{0,400}?\n\s*\.then\(\(\) => reportActivityRollup\(/g) || [];
|
|
369
|
+
assert.equal(chains.length, 2, 'both the web flow and the device exchange chain the rollup after the check-in');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
console.log(`\nhub_recent_ships.mjs: ${passed} passed, ${failed} failed`);
|
|
373
|
+
process.exit(failed ? 1 : 0);
|