@bongos/core 1.21.63 → 1.21.65
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 +56 -36
- package/docs/adr/0367-the-support-bot-answers-from-public-docs-only.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +42 -42
- package/docs/copy-registry.json +48 -48
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-readings.json +67 -59
- package/modules/discord/db.js +32 -0
- package/modules/discord/discord-bot.js +14 -0
- package/modules/discord/discord-support.js +493 -0
- package/modules/discord/module.json +2 -2
- package/modules/hall-ui/public/watch.css +15 -0
- package/modules/hall-ui/public/watch.html +3 -0
- package/modules/hall-ui/public/watch.js +87 -1
- package/modules/hall-ui/records/oversight-family.md +6 -0
- package/modules/memory/module.json +3 -1
- package/modules/memory/routes/search.js +9 -0
- package/modules/memory/search.js +68 -3
- package/modules/onboarding/routes/access-requests.js +10 -4
- package/package-lock.json +9 -5
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/credential-presence.js +5 -0
- package/src/bongos/auth-admission.js +33 -2
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/applicant_view_guild.mjs +101 -0
- package/tests/application_lifecycle.mjs +38 -1
- package/tests/discord_support.mjs +399 -0
- package/tests/module_api.mjs +1 -0
- package/tests/search_isolation.mjs +21 -0
- package/tests/watch_applications_queue.mjs +112 -2
|
@@ -58,6 +58,15 @@ function searchRateLimit(req, res, next) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
module.exports = function buildSearchRouter() {
|
|
61
|
+
// Port `memory.searchPublicDocs` (task 1002534, ADR 0367): recall for the
|
|
62
|
+
// Discord #support answerer, which has no session. Its scope (public docs on
|
|
63
|
+
// the caller's allowlist, nothing rank-gated, nothing owned) is fixed inside
|
|
64
|
+
// searchPublicDocs; this is an in-process port, not an HTTP surface, so the
|
|
65
|
+
// "no public search route" rule above still holds. hasProvider-guarded so a
|
|
66
|
+
// test that rebuilds the router never double-registers.
|
|
67
|
+
if (!api.hasProvider('memory.searchPublicDocs')) {
|
|
68
|
+
api.registerProvider('memory.searchPublicDocs', (args) => search.searchPublicDocs(args));
|
|
69
|
+
}
|
|
61
70
|
const router = express.Router();
|
|
62
71
|
|
|
63
72
|
// POST /search — two modes (ADR 0095 C1 / task 1658, progressive disclosure):
|
package/modules/memory/search.js
CHANGED
|
@@ -84,7 +84,7 @@ function clampLimit(limit) {
|
|
|
84
84
|
// builderRank — the caller's LIVE rank (min_rank scoping).
|
|
85
85
|
// Optional: limit (clamped 1..50), sourceKind ('doc' | 'db' | null = both).
|
|
86
86
|
async function searchChunks(
|
|
87
|
-
{ q, limit = DEFAULT_LIMIT, sourceKind = null, builderId, builderRank } = {},
|
|
87
|
+
{ q, limit = DEFAULT_LIMIT, sourceKind = null, builderId, builderRank, sourceRefPatterns = null } = {},
|
|
88
88
|
{ pool: p = pool, client = null } = {}
|
|
89
89
|
) {
|
|
90
90
|
const query = typeof q === 'string' ? q.trim() : '';
|
|
@@ -93,9 +93,13 @@ async function searchChunks(
|
|
|
93
93
|
const allowedRanks = ranksAtOrBelow(builderRank);
|
|
94
94
|
const ownerId = Number.isInteger(builderId) ? builderId : (Number(builderId) || null);
|
|
95
95
|
const kind = sourceKind === 'doc' || sourceKind === 'db' ? sourceKind : null;
|
|
96
|
+
// An optional source_ref allowlist (LIKE patterns from refPatterns), applied in
|
|
97
|
+
// the same scoped CTE as the rank/owner predicate. Null = no narrowing (the
|
|
98
|
+
// route never passes it); searchPublicDocs always does.
|
|
99
|
+
const refs = Array.isArray(sourceRefPatterns) ? sourceRefPatterns : null;
|
|
96
100
|
|
|
97
101
|
// $1 q · $2 allowedRanks[] · $3 ownerId · $4 sourceKind|null · $5 legCandidates · $6 finalLimit
|
|
98
|
-
// · $7 RRF_K · $8 usefulnessWeight · $9 usefulnessScale (ADR 0095 C3)
|
|
102
|
+
// · $7 RRF_K · $8 usefulnessWeight · $9 usefulnessScale (ADR 0095 C3) · $10 refPatterns[]|null
|
|
99
103
|
// The scope predicate is shared by both legs (built once as a SQL fragment).
|
|
100
104
|
// websearch_to_tsquery tolerates arbitrary user input (no tsquery syntax to
|
|
101
105
|
// escape); the `<%` operator uses pg_trgm.word_similarity_threshold.
|
|
@@ -106,6 +110,7 @@ async function searchChunks(
|
|
|
106
110
|
WHERE min_rank = ANY($2::text[])
|
|
107
111
|
AND (owner_builder_id IS NULL OR owner_builder_id = $3::bigint)
|
|
108
112
|
AND ($4::text IS NULL OR source_kind = $4::text)
|
|
113
|
+
AND ($10::text[] IS NULL OR source_ref LIKE ANY($10::text[]))
|
|
109
114
|
),
|
|
110
115
|
fts AS (
|
|
111
116
|
SELECT id,
|
|
@@ -150,7 +155,7 @@ async function searchChunks(
|
|
|
150
155
|
ORDER BY score DESC, c.id
|
|
151
156
|
LIMIT $6;
|
|
152
157
|
`;
|
|
153
|
-
const params = [query, allowedRanks, ownerId, kind, LEG_CANDIDATES, lim, RRF_K, USEFULNESS_WEIGHT, USEFULNESS_SCALE];
|
|
158
|
+
const params = [query, allowedRanks, ownerId, kind, LEG_CANDIDATES, lim, RRF_K, USEFULNESS_WEIGHT, USEFULNESS_SCALE, refs];
|
|
154
159
|
let rows;
|
|
155
160
|
if (client) {
|
|
156
161
|
// Test seam (#950 isolation suite): the caller owns the transaction AND the
|
|
@@ -204,6 +209,64 @@ async function searchChunks(
|
|
|
204
209
|
});
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
// refPatterns — a docs allowlist as SQL LIKE patterns. An entry ending in '/' is
|
|
213
|
+
// a folder (prefix match); any other entry is one exact file. LIKE's own
|
|
214
|
+
// wildcards are escaped so a path with '_' or '%' in it matches only itself.
|
|
215
|
+
// Anything that is not a plain repo-relative path (absolute, '..', a backslash,
|
|
216
|
+
// empty) is dropped, so a malformed entry narrows the corpus, never widens it.
|
|
217
|
+
function refPatterns(refs) {
|
|
218
|
+
if (!Array.isArray(refs)) return [];
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const raw of refs) {
|
|
221
|
+
const ref = typeof raw === 'string' ? raw.trim() : '';
|
|
222
|
+
if (!ref || ref.startsWith('/') || ref.includes('\\') || ref.split('/').includes('..')) continue;
|
|
223
|
+
const escaped = ref.replace(/[%_]/g, (c) => `\\${c}`);
|
|
224
|
+
out.push(ref.endsWith('/') ? `${escaped}%` : escaped);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// searchPublicDocs — recall for someone with NO session: the Discord #support
|
|
230
|
+
// answerer (task 1002534, ADR 0367), served through the `memory.searchPublicDocs`
|
|
231
|
+
// port. Its scope is FIXED HERE and never taken from the caller:
|
|
232
|
+
// source_kind = 'doc' (no DB prose: task bodies, learnings, session rows)
|
|
233
|
+
// min_rank = 'xenos' (no rank-gated doc: ADR 0043's protected set is metic)
|
|
234
|
+
// owner = none (no builder's private chunk)
|
|
235
|
+
// source_ref ∈ refs (the caller's allowlist of public docs; REQUIRED)
|
|
236
|
+
// xenos is still a signed-in rank, which is why the allowlist is required: an
|
|
237
|
+
// empty or missing list returns [] rather than everything a xenos may read.
|
|
238
|
+
// Returns up to `limit` hits with the chunk body (capped at maxChars), and the
|
|
239
|
+
// body read re-applies the whole scope, so it can't widen what search found.
|
|
240
|
+
async function searchPublicDocs(
|
|
241
|
+
{ q, refs, limit = 5, maxChars = 1500 } = {},
|
|
242
|
+
{ pool: p = pool, client = null } = {}
|
|
243
|
+
) {
|
|
244
|
+
const patterns = refPatterns(refs);
|
|
245
|
+
if (!patterns.length) return [];
|
|
246
|
+
const hits = await searchChunks(
|
|
247
|
+
{ q, limit: Math.min(clampLimit(limit), 10), sourceKind: 'doc', builderId: null, builderRank: 'xenos', sourceRefPatterns: patterns },
|
|
248
|
+
{ pool: p, client }
|
|
249
|
+
);
|
|
250
|
+
if (!hits.length) return [];
|
|
251
|
+
const cap = Math.max(200, Math.min(Number(maxChars) || 1500, 4000));
|
|
252
|
+
const sql = `
|
|
253
|
+
SELECT id, source_ref, heading_path, title, left(body, $2::int) AS body
|
|
254
|
+
FROM search_chunks
|
|
255
|
+
WHERE id = ANY($1::bigint[])
|
|
256
|
+
AND source_kind = 'doc'
|
|
257
|
+
AND min_rank = 'xenos'
|
|
258
|
+
AND owner_builder_id IS NULL
|
|
259
|
+
AND source_ref LIKE ANY($3::text[])`;
|
|
260
|
+
const params = [hits.map((h) => h.id), cap, patterns];
|
|
261
|
+
const { rows } = client ? await client.query(sql, params) : await p.query(sql, params);
|
|
262
|
+
const byId = new Map(rows.map((r) => [Number(r.id), r]));
|
|
263
|
+
// Keep the search's ranking order.
|
|
264
|
+
return hits.filter((h) => byId.has(h.id)).map((h) => {
|
|
265
|
+
const r = byId.get(h.id);
|
|
266
|
+
return { id: h.id, source_ref: r.source_ref, heading_path: r.heading_path, title: r.title, body: r.body || '', score: h.score };
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
207
270
|
// fetchChunkBody — the EXPAND half of progressive disclosure (ADR 0095 C1 / task
|
|
208
271
|
// 1658). Given a chunk id, return its FULL body — but scoped exactly like
|
|
209
272
|
// searchChunks (same min_rank + owner predicate built from the authenticated
|
|
@@ -505,6 +568,8 @@ function classifyEmptyResult(health) {
|
|
|
505
568
|
|
|
506
569
|
module.exports = {
|
|
507
570
|
searchChunks,
|
|
571
|
+
searchPublicDocs,
|
|
572
|
+
refPatterns,
|
|
508
573
|
// task 1003097: a zero-result search must say WHICH of three things happened —
|
|
509
574
|
// a dead index, a scoping wall, or a genuine miss.
|
|
510
575
|
corpusHealth,
|
|
@@ -139,6 +139,12 @@ const MAX_LIVE_PROFILE_READS = 25;
|
|
|
139
139
|
//
|
|
140
140
|
// Each read fails soft to null independently (the doorway port swallows), so one
|
|
141
141
|
// slow or missing account cannot fail the others or the listing.
|
|
142
|
+
//
|
|
143
|
+
// THE GUILD LABEL RIDES THE SAME READ (ADR 0365 D5, task 1002304): `guild` is
|
|
144
|
+
// { slug, name } when the applicant applied with their guild, else null — so it
|
|
145
|
+
// is keyed on the vouch and budgeted by the cap exactly as the profile is, and
|
|
146
|
+
// the queue groups a guild's applicants by it. An older hub sends no guild, and
|
|
147
|
+
// every row then carries `guild: null` and renders as it always did.
|
|
142
148
|
async function attachApplicantProfiles(rows) {
|
|
143
149
|
// The budget is spent UP FRONT, by index, rather than by decrementing a shared
|
|
144
150
|
// counter inside the concurrent callbacks. Both are correct today — `.map`
|
|
@@ -151,10 +157,10 @@ async function attachApplicantProfiles(rows) {
|
|
|
151
157
|
rows.forEach((r, i) => {
|
|
152
158
|
if (r.applicant_github_id && resolvable.size < MAX_LIVE_PROFILE_READS) resolvable.add(i);
|
|
153
159
|
});
|
|
154
|
-
return Promise.all(rows.map(async (r, i) =>
|
|
155
|
-
|
|
156
|
-
profile:
|
|
157
|
-
}))
|
|
160
|
+
return Promise.all(rows.map(async (r, i) => {
|
|
161
|
+
const view = resolvable.has(i) ? await api.readApplicantViewFromHub(r.applicant_github_id) : null;
|
|
162
|
+
return { ...r, profile: view ? view.profile : null, guild: view ? view.guild : null };
|
|
163
|
+
}));
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
// THE HUB'S NOTICE OF THIS PROJECT'S INVITES (task 1002818, ADR 0353). On a hosted
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.65",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.65",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
|
@@ -772,9 +772,9 @@
|
|
|
772
772
|
"license": "MIT"
|
|
773
773
|
},
|
|
774
774
|
"node_modules/proxy-addr": {
|
|
775
|
-
"version": "2.0.
|
|
776
|
-
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.
|
|
777
|
-
"integrity": "sha512-
|
|
775
|
+
"version": "2.0.8",
|
|
776
|
+
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
|
777
|
+
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
|
778
778
|
"license": "MIT",
|
|
779
779
|
"dependencies": {
|
|
780
780
|
"forwarded": "0.2.0",
|
|
@@ -782,6 +782,10 @@
|
|
|
782
782
|
},
|
|
783
783
|
"engines": {
|
|
784
784
|
"node": ">= 0.10"
|
|
785
|
+
},
|
|
786
|
+
"funding": {
|
|
787
|
+
"type": "opencollective",
|
|
788
|
+
"url": "https://opencollective.com/express"
|
|
785
789
|
}
|
|
786
790
|
},
|
|
787
791
|
"node_modules/qs": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.65",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9154,5 +9154,17 @@
|
|
|
9154
9154
|
"id": "1004583",
|
|
9155
9155
|
"text": "Builders can now accept or decline a guild invite from their projects page, choosing first whether their numbers count toward the guild's totals, and guild owners can answer join requests there too."
|
|
9156
9156
|
}
|
|
9157
|
+
],
|
|
9158
|
+
"1.21.64": [
|
|
9159
|
+
{
|
|
9160
|
+
"id": "1002304",
|
|
9161
|
+
"text": "Owners now see people who applied with a guild grouped under that guild's name in the applications queue, with one \"Invite all\" button that still admits each person individually."
|
|
9162
|
+
}
|
|
9163
|
+
],
|
|
9164
|
+
"1.21.65": [
|
|
9165
|
+
{
|
|
9166
|
+
"id": "1002534",
|
|
9167
|
+
"text": "The Discord bot can now answer questions in #support when someone @-mentions it, using only the project's public newcomer docs. It ships switched off: it starts answering once the owner adds an Anthropic key and turns it on, a"
|
|
9168
|
+
}
|
|
9157
9169
|
]
|
|
9158
9170
|
}
|
|
@@ -96,6 +96,11 @@ const CREDENTIALS = [
|
|
|
96
96
|
names: ['DISCORD_BOT_TOKEN'],
|
|
97
97
|
purpose: 'the Discord bot (modules/discord); also readable from discord-bot.json in the config dir',
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
id: 'discord-support', file: 'web.env', need: 'optional',
|
|
101
|
+
names: ['DISCORD_SUPPORT_ANTHROPIC_API_KEY'],
|
|
102
|
+
purpose: 'the #support answerer (modules/discord/discord-support.js, ADR 0367); its own key, never a host-wide ANTHROPIC_API_KEY',
|
|
103
|
+
},
|
|
99
104
|
{
|
|
100
105
|
id: 'discord-oauth', file: 'web.env', need: 'optional',
|
|
101
106
|
names: ['DISCORD_CLIENT_ID', 'DISCORD_CLIENT_SECRET'],
|
|
@@ -534,7 +534,29 @@ async function safeSyncRankRole(builder) {
|
|
|
534
534
|
// null this function already promises on every other failure.
|
|
535
535
|
const APPLICANT_PROFILE_TIMEOUT_MS = 5000;
|
|
536
536
|
|
|
537
|
-
|
|
537
|
+
// The guild an applicant applied with, as the hub labels it beside their profile
|
|
538
|
+
// (ADR 0365 D5, task 1002304): { slug, name }, or null. Cross-instance input, so
|
|
539
|
+
// it is re-bounded here rather than trusted — the hub's own caps mirrored (a guild
|
|
540
|
+
// name is at most 80 characters, a slug is the handle charset). A slug that does
|
|
541
|
+
// not fit is dropped to null, which the queue already reads as "no public page";
|
|
542
|
+
// a name that does not fit drops the whole label, and the row simply shows no
|
|
543
|
+
// guild. An OLDER hub sends no `guild` at all, which is the same null.
|
|
544
|
+
const GUILD_LABEL_NAME_MAX = 80;
|
|
545
|
+
const GUILD_LABEL_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
|
|
546
|
+
|
|
547
|
+
function applicantGuildLabel(raw) {
|
|
548
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
549
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
550
|
+
if (!name || name.length > GUILD_LABEL_NAME_MAX) return null;
|
|
551
|
+
const slug = typeof raw.slug === 'string' && GUILD_LABEL_SLUG_RE.test(raw.slug) ? raw.slug : null;
|
|
552
|
+
return { slug, name };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// readApplicantViewFromHub(githubId) → { profile, guild } | null — the same live
|
|
556
|
+
// read, with the guild label the hub sends beside the profile (ADR 0365 D5). The
|
|
557
|
+
// label rides ONLY beside a profile, here as on the hub: an applicant who
|
|
558
|
+
// discloses nothing discloses no guild either, so a null profile is a null view.
|
|
559
|
+
async function readApplicantViewFromHub(githubId) {
|
|
538
560
|
const idp = loadIdpConfig();
|
|
539
561
|
if (!idp || githubId == null) return null;
|
|
540
562
|
try {
|
|
@@ -550,13 +572,21 @@ async function readApplicantProfileFromHub(githubId) {
|
|
|
550
572
|
});
|
|
551
573
|
if (!r.ok) return null;
|
|
552
574
|
const body = await r.json().catch(() => null);
|
|
553
|
-
|
|
575
|
+
const profile = (body && body.profile) || null;
|
|
576
|
+
return profile ? { profile, guild: applicantGuildLabel(body.guild) } : null;
|
|
554
577
|
} catch (err) {
|
|
555
578
|
log.info('[auth] hub applicant-profile read failed (non-blocking):', err.message);
|
|
556
579
|
return null;
|
|
557
580
|
}
|
|
558
581
|
}
|
|
559
582
|
|
|
583
|
+
// The profile half alone — the doorway port this read first shipped as, kept so
|
|
584
|
+
// a module on it reads exactly what it always did.
|
|
585
|
+
async function readApplicantProfileFromHub(githubId) {
|
|
586
|
+
const view = await readApplicantViewFromHub(githubId);
|
|
587
|
+
return view ? view.profile : null;
|
|
588
|
+
}
|
|
589
|
+
|
|
560
590
|
// notifyHubOfInvite(githubLogin, { invited }) — tell the hub about THIS project's own
|
|
561
591
|
// invite, so the invitee sees it in My Projects (task 1002818, ADR 0353). `invited:
|
|
562
592
|
// true` after the invite route leaves the login holding an invited row; `false` after
|
|
@@ -601,6 +631,7 @@ module.exports = {
|
|
|
601
631
|
firstTimeAdmissionBlocked,
|
|
602
632
|
instanceNeedsFirstAdmin,
|
|
603
633
|
readApplicantProfileFromHub,
|
|
634
|
+
readApplicantViewFromHub,
|
|
604
635
|
// Re-exported, not defined here (task 1003579 moved it to project-door.js with
|
|
605
636
|
// the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
|
|
606
637
|
// and four test files import it by this path, and the read-back it answers —
|
package/src/bongos/auth.js
CHANGED
|
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
|
|
|
55
55
|
const { branding, userAgent } = require('../branding');
|
|
56
56
|
|
|
57
57
|
const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
|
|
58
|
-
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
58
|
+
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
59
59
|
const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
|
|
60
60
|
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
61
61
|
const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
|
|
@@ -828,6 +828,7 @@ module.exports = {
|
|
|
828
828
|
openEnrollmentEnabled,
|
|
829
829
|
joinabilityMode,
|
|
830
830
|
readApplicantProfileFromHub,
|
|
831
|
+
readApplicantViewFromHub,
|
|
831
832
|
notifyHubOfInvite,
|
|
832
833
|
readApplicationVouch,
|
|
833
834
|
firstTimeAdmissionBlocked,
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.65'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -362,6 +362,12 @@ module.exports = {
|
|
|
362
362
|
// re-address it. Fails soft to null on every error, so a hub outage costs
|
|
363
363
|
// the profile block and never the queue.
|
|
364
364
|
get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
|
|
365
|
+
// readApplicantViewFromHub(githubId) — the same read and the same narrow port,
|
|
366
|
+
// answering { profile, guild } | null: the profile above plus the guild the
|
|
367
|
+
// applicant applied with, { slug, name } or null (ADR 0365 D5, task
|
|
368
|
+
// 1002304). The guild rides only beside a profile, so a null profile is a
|
|
369
|
+
// null view; an older hub that sends no guild answers guild: null.
|
|
370
|
+
get readApplicantViewFromHub() { return require('./bongos/auth').readApplicantViewFromHub; },
|
|
365
371
|
// notifyHubOfInvite(githubLogin, { invited }) — tell the hub about one of THIS
|
|
366
372
|
// project's invites (true) or its rescind (false), so the invitee sees it in
|
|
367
373
|
// My Projects (task 1002818, ADR 0353). The same narrow-port reason as the
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// tests/applicant_view_guild.mjs — the guild label reaches the project (task
|
|
2
|
+
// 1002304, R28 of goal 1000110; ADR 0365 D5).
|
|
3
|
+
//
|
|
4
|
+
// THE GAP. The hub's POST /sso/applicant-profile answers { profile, guild } since
|
|
5
|
+
// task 1002303, and core kept only `body.profile` — so the reviewer queue could
|
|
6
|
+
// never know which applicants came as a guild. readApplicantViewFromHub is the
|
|
7
|
+
// same live read answering both halves, and readApplicantProfileFromHub is kept
|
|
8
|
+
// for any module on the older port.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS PINNED. The label is cross-instance input, so it is re-bounded here
|
|
11
|
+
// (the hub's own caps mirrored) rather than trusted; it rides only beside a
|
|
12
|
+
// profile, as it does on the hub; and an older hub that sends no guild is a
|
|
13
|
+
// guild of null, never an error.
|
|
14
|
+
//
|
|
15
|
+
// DB-free and network-free: auth-config is pinned in require.cache so the
|
|
16
|
+
// federation config resolves without env, and global fetch is a spy (the
|
|
17
|
+
// tests/applicant_profile_read_bounds.mjs idiom).
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test --test-reporter=tap tests/applicant_view_guild.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
const cfgPath = require.resolve('../src/bongos/auth-config.js');
|
|
28
|
+
const realCfg = require('../src/bongos/auth-config.js');
|
|
29
|
+
require.cache[cfgPath] = {
|
|
30
|
+
id: cfgPath,
|
|
31
|
+
filename: cfgPath,
|
|
32
|
+
loaded: true,
|
|
33
|
+
exports: {
|
|
34
|
+
...realCfg,
|
|
35
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'cid', clientSecret: 'sec' }),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const admission = require('../src/bongos/auth-admission.js');
|
|
40
|
+
const api = require('../src/module-api.js');
|
|
41
|
+
|
|
42
|
+
const PROFILE = { view: 'sliver', sliver: { handle: 'bo' } };
|
|
43
|
+
|
|
44
|
+
// The hub answers `body` for every call; the result is what the read returns.
|
|
45
|
+
async function viewFor(body) {
|
|
46
|
+
const real = globalThis.fetch;
|
|
47
|
+
globalThis.fetch = async () => ({ ok: true, json: async () => body });
|
|
48
|
+
try { return await admission.readApplicantViewFromHub(42); } finally { globalThis.fetch = real; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('the guild the hub names rides beside the profile', async () => {
|
|
52
|
+
const view = await viewFor({ profile: PROFILE, guild: { slug: 'lantern-works', name: 'Lantern Works' } });
|
|
53
|
+
assert.deepEqual(view, { profile: PROFILE, guild: { slug: 'lantern-works', name: 'Lantern Works' } });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unlisted guild keeps its name and its null slug', async () => {
|
|
57
|
+
const view = await viewFor({ profile: PROFILE, guild: { slug: null, name: 'Night Shift' } });
|
|
58
|
+
assert.deepEqual(view.guild, { slug: null, name: 'Night Shift' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an OLDER hub that sends no guild is guild null, and the profile is untouched', async () => {
|
|
62
|
+
const view = await viewFor({ profile: PROFILE });
|
|
63
|
+
assert.deepEqual(view, { profile: PROFILE, guild: null });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('no profile, no view — a guild never rides alone (the hub’s own rule, restated here)', async () => {
|
|
67
|
+
assert.equal(await viewFor({ profile: null, guild: { slug: 'lantern-works', name: 'Lantern Works' } }), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a label outside the hub’s own caps is not trusted', async () => {
|
|
71
|
+
const name81 = 'x'.repeat(81);
|
|
72
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: { slug: 'ok', name: name81 } })).guild, null, 'a name over 80 characters drops the label');
|
|
73
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: { slug: 'ok', name: ' ' } })).guild, null, 'a blank name is no name');
|
|
74
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: { slug: 'ok', name: 7 } })).guild, null, 'a name must be text');
|
|
75
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: 'Lantern Works' })).guild, null, 'a label must be an object');
|
|
76
|
+
for (const slug of ['Lantern Works', '-lead', 'trail-', '<script>', 'x'.repeat(40), 42]) {
|
|
77
|
+
assert.deepEqual((await viewFor({ profile: PROFILE, guild: { slug, name: 'Lantern' } })).guild, { slug: null, name: 'Lantern' },
|
|
78
|
+
`a slug outside the handle charset (${JSON.stringify(slug)}) is no address — the name still labels the row`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the older port answers the profile alone, exactly as it always did', async () => {
|
|
83
|
+
const real = globalThis.fetch;
|
|
84
|
+
globalThis.fetch = async () => ({ ok: true, json: async () => ({ profile: PROFILE, guild: { slug: 'a1', name: 'A' } }) });
|
|
85
|
+
try {
|
|
86
|
+
assert.deepEqual(await admission.readApplicantProfileFromHub(42), PROFILE);
|
|
87
|
+
} finally { globalThis.fetch = real; }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a failed hub read is a null view, like every other failure here', async () => {
|
|
91
|
+
const real = globalThis.fetch;
|
|
92
|
+
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
|
93
|
+
try {
|
|
94
|
+
assert.equal(await admission.readApplicantViewFromHub(42), null);
|
|
95
|
+
} finally { globalThis.fetch = real; }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a module reaches it through the doorway, the same narrow port as the profile read', () => {
|
|
99
|
+
assert.equal(api.readApplicantViewFromHub, admission.readApplicantViewFromHub);
|
|
100
|
+
assert.equal(api.readApplicantProfileFromHub, admission.readApplicantProfileFromHub);
|
|
101
|
+
});
|
|
@@ -390,6 +390,10 @@ let actingRank = 'archon';
|
|
|
390
390
|
let actingId = 42;
|
|
391
391
|
// The hub notice port (task 1002818, ADR 0353), recorded instead of sent.
|
|
392
392
|
let notices = [];
|
|
393
|
+
// The hub's applicant view (task 1002304, ADR 0365 D5): github_id → what the hub
|
|
394
|
+
// answers, recorded so a test can see WHICH ids were asked about. Empty = no hub.
|
|
395
|
+
let hubViews = {};
|
|
396
|
+
let hubAsked = [];
|
|
393
397
|
|
|
394
398
|
require.cache[apiPath].exports = {
|
|
395
399
|
...realApi,
|
|
@@ -403,6 +407,7 @@ require.cache[apiPath].exports = {
|
|
|
403
407
|
readApplicationVouch: (token, { githubLogin } = {}) =>
|
|
404
408
|
applicationVouchIdentity(token, { githubLogin, publicKey: hubPublicKey, clientId: SELF_CLIENT_ID }),
|
|
405
409
|
notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
|
|
410
|
+
readApplicantViewFromHub: async (githubId) => { hubAsked.push(githubId); return hubViews[githubId] || null; },
|
|
406
411
|
};
|
|
407
412
|
|
|
408
413
|
seams._reset();
|
|
@@ -447,7 +452,7 @@ function call(method, pathname, body, extraHeaders = {}) {
|
|
|
447
452
|
req.end(payload);
|
|
448
453
|
});
|
|
449
454
|
}
|
|
450
|
-
const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; };
|
|
455
|
+
const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; hubViews = {}; hubAsked = []; };
|
|
451
456
|
|
|
452
457
|
// ===========================================================================
|
|
453
458
|
// The interpreter is the instrument, so it is calibrated first
|
|
@@ -670,6 +675,38 @@ test('list-pending: the queue filters on status and NOTHING else', async () => {
|
|
|
670
675
|
'an extra predicate here would hide rows from the owner, and a placeholder-free one is invisible to the fake pool');
|
|
671
676
|
});
|
|
672
677
|
|
|
678
|
+
// The guild label (task 1002304, R28; ADR 0365 D5): the hub answers { profile,
|
|
679
|
+
// guild } and core used to keep only the profile. It rides the SAME vouch-keyed
|
|
680
|
+
// read, so it can reach a row only the way the profile can.
|
|
681
|
+
test('list-pending: a vouched applicant’s guild rides onto the row beside their profile', async () => {
|
|
682
|
+
reset();
|
|
683
|
+
const profile = { view: 'sliver', sliver: { handle: 'bo-h' } };
|
|
684
|
+
hubViews['9002'] = { profile, guild: { slug: 'lantern-works', name: 'Lantern Works' } };
|
|
685
|
+
const r = await call('GET', '/api/gds/access-requests');
|
|
686
|
+
const bo = r.json.requests.find((x) => x.github_login === 'bo');
|
|
687
|
+
assert.deepEqual(bo.profile, profile);
|
|
688
|
+
assert.deepEqual(bo.guild, { slug: 'lantern-works', name: 'Lantern Works' });
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('list-pending: an unvouched row is never asked about, and carries guild null', async () => {
|
|
692
|
+
reset();
|
|
693
|
+
hubViews['9002'] = { profile: { view: 'sliver', sliver: {} }, guild: { slug: null, name: 'Night Shift' } };
|
|
694
|
+
const r = await call('GET', '/api/gds/access-requests');
|
|
695
|
+
const ada = r.json.requests.find((x) => x.github_login === 'ada');
|
|
696
|
+
assert.equal(ada.guild, null, 'a login anybody could have typed must not borrow a guild');
|
|
697
|
+
assert.equal(ada.profile, null);
|
|
698
|
+
assert.deepEqual(hubAsked, ['9002'], 'only the vouched row reached the hub');
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('list-pending: a hub that answers nothing (or an older one) leaves every row as it was', async () => {
|
|
702
|
+
reset();
|
|
703
|
+
const r = await call('GET', '/api/gds/access-requests');
|
|
704
|
+
for (const row of r.json.requests) {
|
|
705
|
+
assert.equal(row.guild, null, `${row.github_login}: no view, no guild`);
|
|
706
|
+
assert.equal(row.profile, null);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
673
710
|
test('list-pending: ?status=invited|dismissed reads the audit trail, and a bad status is refused', async () => {
|
|
674
711
|
reset();
|
|
675
712
|
const invited = await call('GET', '/api/gds/access-requests?status=invited');
|