@bongos/core 1.19.533 → 1.19.534

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.
Files changed (51) hide show
  1. package/.bongos-core.json +106 -46
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  5. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  6. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  7. package/docs/adr/README.md +4 -0
  8. package/docs/api/openapi.json +8 -1
  9. package/docs/api-reference.md +1 -1
  10. package/docs/copy-inventory.md +129 -126
  11. package/docs/copy-registry.json +189 -161
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +14 -0
  14. package/modules/onboarding/module.json +1 -1
  15. package/modules/onboarding/routes/access-requests.js +21 -6
  16. package/modules/platform-identity/module.json +1 -1
  17. package/modules/platform-identity/project-catalog.js +202 -0
  18. package/modules/platform-identity/routes/my-projects.js +7 -2
  19. package/modules/platform-identity/routes/projects.js +6 -0
  20. package/modules/platform-identity/routes/sso.js +10 -0
  21. package/modules/provisioning/catalog-bridge.js +105 -0
  22. package/modules/provisioning/provisioning.js +28 -2
  23. package/modules/provisioning/routes/provisioning.js +45 -11
  24. package/modules/provisioning/tests/provisioning.mjs +11 -8
  25. package/modules/public-landing/public/assets/cosmos.css +15 -0
  26. package/modules/public-landing/public/projects.html +162 -3
  27. package/modules/ui-design/kit/serve.js +1 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/fitness.js +9 -0
  31. package/scripts/gds/provision-net.js +7 -0
  32. package/src/bongos/project-door.js +138 -0
  33. package/src/branding.js +8 -0
  34. package/src/module-api.js +12 -1
  35. package/tests/access_requests_invite.mjs +2 -2
  36. package/tests/application_lifecycle.mjs +1 -0
  37. package/tests/catalog_only_client.mjs +242 -0
  38. package/tests/currency_label.mjs +8 -5
  39. package/tests/module_api.mjs +1 -0
  40. package/tests/project_catalog_bridge.mjs +355 -0
  41. package/tests/project_catalog_routes.mjs +213 -0
  42. package/tests/project_join_door.mjs +166 -0
  43. package/tests/project_join_door_route.mjs +206 -0
  44. package/tests/project_modules_ui.mjs +318 -0
  45. package/tests/projects_hub.mjs +6 -1
  46. package/tests/projects_hub_pre_uat.mjs +2 -2
  47. package/tests/provision_settings_apply.mjs +6 -5
  48. package/tests/provisioning_module_selection.mjs +164 -3
  49. package/tests/provisioning_settings.mjs +10 -7
  50. package/tests/provisioning_settings_apply.mjs +3 -3
  51. package/tests/provisioning_settings_env.mjs +7 -4
package/src/branding.js CHANGED
@@ -65,6 +65,10 @@ const ENV_OVERRIDES = [
65
65
  // this instance's own sign-in door; this one is how the project stands on the
66
66
  // platform's map, and D6 makes it the ceiling JOINABILITY may only narrow.
67
67
  ['VISIBILITY', ['project', 'visibility']],
68
+ // What a join of this project grants while it is PUBLIC (BV1.R12, task 1002331) —
69
+ // view | apply | full. The ceiling on the visibility axis that JOINABILITY may
70
+ // only narrow; composed into one door by src/bongos/project-door.js.
71
+ ['JOIN_GRANT', ['project', 'joinGrant']],
68
72
  ];
69
73
 
70
74
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -224,6 +228,10 @@ function clientBranding(b = branding()) {
224
228
  // two neighbours: it is how the project asks to be seen on the map, and it is
225
229
  // the READ-BACK a manage page needs to tell a saved choice from a live one.
226
230
  visibility: b.project?.visibility || 'public',
231
+ // What a public join grants (task 1002331). Public for the same reason: a
232
+ // stranger's client should be able to say what THIS door does before knocking.
233
+ // `full` = this axis narrows nothing, so an un-pushed instance is unchanged.
234
+ joinGrant: b.project?.joinGrant || 'full',
227
235
  },
228
236
  };
229
237
  }
package/src/module-api.js CHANGED
@@ -15,6 +15,7 @@ const util = require('node:util');
15
15
  const { logger: coreLogger } = require('./bongos/logger');
16
16
  const { pool, instanceDbName } = require('./bongos/pool');
17
17
  const branding = require('./branding');
18
+ const projectDoor = require('./bongos/project-door');
18
19
  const instanceConfig = require('./instance-config');
19
20
  const staleTimer = require('./stale-timer');
20
21
  // Destructured, not namespaced, deliberately — the same form src/bongos/routes/
@@ -45,7 +46,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
46
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
47
  // the entry to that file. Look for a version's history there, not here.
47
48
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.533'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
+ const CORE_VERSION = '1.19.534'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
50
 
50
51
  // A namespaced logger so a module's log lines are attributable + consistent.
51
52
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -242,6 +243,16 @@ module.exports = {
242
243
  // reader, and the sign-in flows and the public access-request route
243
244
  // must answer with the same door (SR-19).
244
245
  joinabilityMode: auth.joinabilityMode,
246
+ // projectJoinDoor() — the project's COMPOSED join/apply door: 'open' | 'apply' |
247
+ // 'invite_only' | 'locked' (BV1.R12, task 1002331; ADR 0182 D6, ADR 0247).
248
+ // `joinabilityMode` above answers one knob; this answers the whole door,
249
+ // because the project's place on the platform's map (`visibility`) and what a
250
+ // public join grants (`joinGrant`) each narrow it too, and "the stricter of
251
+ // the two always wins" is only true if one function says so. Every join/apply
252
+ // surface calls THIS; a module that read a single knob would re-open a door
253
+ // the other two had closed. Optional `{ publishable }` for a caller that
254
+ // holds a publish verdict — see src/bongos/project-door.js.
255
+ projectJoinDoor: (opts) => projectDoor.effectiveJoinDoor(opts),
245
256
  // userAgent — the instance's HTTP User-Agent string (branding-derived). The
246
257
  // carved lifecycle module's github-push.js stamps every GitHub API request with
247
258
  // it instead of reaching into src/branding.js directly (BV1.R86).
@@ -9,7 +9,7 @@
9
9
  // The doorway is stubbed in require.cache BEFORE the route loads (the route
10
10
  // destructures pool/validators at require time — the web_admission_status
11
11
  // pattern): the session gates admit a fixed Archon, the pool replays a script
12
- // of results and records every query, and joinabilityMode is a switch.
12
+ // of results and records every query, and projectJoinDoor is a switch.
13
13
  //
14
14
  // Run: node --test tests/access_requests_invite.mjs
15
15
 
@@ -39,7 +39,7 @@ const pool = {
39
39
  require.cache[apiPath].exports = {
40
40
  ...realApi,
41
41
  pool,
42
- joinabilityMode: () => mode,
42
+ projectJoinDoor: () => mode,
43
43
  requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'archon' }; next(); },
44
44
  requirePermission: () => (_req, _res, next) => next(),
45
45
  };
@@ -252,6 +252,7 @@ require.cache[apiPath].exports = {
252
252
  ...realApi,
253
253
  pool,
254
254
  joinabilityMode: () => 'apply',
255
+ projectJoinDoor: () => 'apply',
255
256
  logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
256
257
  requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
257
258
  // requirePermission is NOT stubbed: realApi's own gate is used, so the denial
@@ -0,0 +1,242 @@
1
+ // tests/catalog_only_client.mjs — A CATALOG-ONLY CLIENT IS AN IDENTITY, NEVER A CREDENTIAL
2
+ // (task 1003576, ADR 0246). The security half of the catalog bridge.
3
+ //
4
+ // The bridge mints an `platform_identity_sso_clients` row for EVERY control-plane project so
5
+ // the map, the membership directory and `POST /projects/invite` have something to name — a
6
+ // large increase in the number of registered clients. This file is the proof that the row it
7
+ // mints can do nothing but be named: it carries an EMPTY secret_hash and an EMPTY
8
+ // redirect-URI allow-list, and every SSO leg refuses it.
9
+ //
10
+ // Four of the five legs already fail closed on machinery that predates this task —
11
+ // clientSecretValid() on a secretless client, redirectUriAllowed() on an empty allow-list —
12
+ // and that is exactly why those are asserted here rather than assumed: they are load-bearing
13
+ // for a decision made after they were written. The fifth, /sso/device/start, resolved a
14
+ // client from a typed origin and checked NEITHER, so it grew an explicit guard.
15
+ //
16
+ // Also pinned: the invite route stops 404ing for a wizard-made project (task 1003580's
17
+ // GAP 1). Its `project.curate` gate is untouched and is 1003580's own work.
18
+ //
19
+ // DB-free: the real routers driven over loopback, with the data-access functions stubbed on
20
+ // the cached module objects (the provisioning_settings.mjs pattern).
21
+ //
22
+ // Run: node --test --test-reporter=tap tests/catalog_only_client.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test, after } from 'node:test';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const pi = require('../modules/platform-identity/platform-identity.js');
31
+ const catalog = require('../modules/platform-identity/project-catalog.js');
32
+ const hubKeys = require('../modules/platform-identity/hub-keys.js');
33
+ const hubDevice = require('../modules/platform-identity/hub-device.js');
34
+ const api = require('../src/module-api.js');
35
+
36
+ // The row the bridge writes: named, active, and holding nothing it could authenticate with.
37
+ const CATALOG_ONLY = Object.freeze({
38
+ client_id: 'spike-harbor', secret_hash: '', name: 'spike-harbor',
39
+ redirect_uris: [], origin: 'https://spike-harbor.cloudbongos.test',
40
+ owner_github_id: 9001, status: 'active',
41
+ });
42
+ // A real federated client, for the contrast — the same shape with a credential.
43
+ const FEDERATED = Object.freeze({
44
+ ...CATALOG_ONLY, client_id: 'mercury', secret_hash: 'a'.repeat(64),
45
+ redirect_uris: ['https://mercury.test/api/gds/auth/web/callback'], origin: 'https://mercury.test',
46
+ });
47
+
48
+ // ── the predicate itself ─────────────────────────────────────────────────────
49
+
50
+ test('clientIsCredentialed separates "the hub can name it" from "it may sign people in"', () => {
51
+ assert.equal(catalog.clientIsCredentialed(FEDERATED), true);
52
+ assert.equal(catalog.clientIsCredentialed(CATALOG_ONLY), false);
53
+ for (const secret_hash of [null, undefined, '']) {
54
+ assert.equal(catalog.clientIsCredentialed({ ...FEDERATED, secret_hash }), false);
55
+ }
56
+ assert.equal(catalog.clientIsCredentialed(null), false, 'no client is not a credentialed one');
57
+ });
58
+
59
+ test('clientSecretValid fails closed on a secretless client, whatever is presented', () => {
60
+ // The load-bearing pre-existing guard: it hashes and constant-time-compares against a decoy
61
+ // when there is no real secret, then refuses because there was none. So the empty string,
62
+ // the literal decoy input and a lucky guess all lose.
63
+ for (const presented of ['', 'anything', 'a'.repeat(64), 'spike-harbor']) {
64
+ assert.equal(pi.clientSecretValid(CATALOG_ONLY, presented), false);
65
+ }
66
+ assert.equal(pi.clientSecretValid(FEDERATED, 'wrong'), false, 'and a real client still needs the real secret');
67
+ });
68
+
69
+ test('redirectUriAllowed fails closed on an empty allow-list', () => {
70
+ assert.equal(pi.redirectUriAllowed(CATALOG_ONLY, 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback'), false);
71
+ assert.equal(pi.redirectUriAllowed(CATALOG_ONLY, ''), false);
72
+ assert.equal(pi.redirectUriAllowed(FEDERATED, FEDERATED.redirect_uris[0]), true);
73
+ });
74
+
75
+ // ── the routes, executed ─────────────────────────────────────────────────────
76
+
77
+ // The auth middleware is DESTRUCTURED off the doorway at route-module load, so these stubs
78
+ // have to be in place before the routers are required.
79
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'curator', rank: 'metic' }; next(); };
80
+ api.requirePermission = () => (_req, _res, next) => next();
81
+
82
+ const express = require('express');
83
+ const ssoRoutes = require('../modules/platform-identity/routes/sso.js');
84
+ const projectsRoutes = require('../modules/platform-identity/routes/projects.js');
85
+
86
+ // The routes read the hub's own config through these; the SSO legs 503 without them, which
87
+ // would hide the refusals under test.
88
+ hubKeys.hubSigningConfigured = () => true;
89
+ hubKeys.hubPublicKeyB64 = () => 'stub-public-key';
90
+ hubDevice.hubDeviceConfigured = () => true;
91
+ let deviceStarts = 0;
92
+ hubDevice.startHubGithubDevice = async () => { deviceStarts++; return { device_code: 'd', user_code: 'U' }; };
93
+
94
+ // The client registry, stubbed on the cached module object.
95
+ const CLIENTS = new Map([[CATALOG_ONLY.client_id, CATALOG_ONLY], [FEDERATED.client_id, FEDERATED]]);
96
+ pi.getClient = async (id) => CLIENTS.get(id) || null;
97
+ pi.getClientByOrigin = async (origin) => {
98
+ const norm = String(origin || '').replace(/\/+$/, '');
99
+ return [...CLIENTS.values()].find((c) => c.origin === norm && c.status === 'active') || null;
100
+ };
101
+ // The invite leg's two other reads — the point is which refusal the route reaches, not what
102
+ // a real membership write does.
103
+ pi.getAccountByLogin = async (login) => (login === 'ghost' ? null : { github_id: 5150, github_login: login });
104
+ pi.inviteToProject = async ({ clientId, githubId }) => ({ invited: true, clientId, githubId });
105
+
106
+ const app = express();
107
+ app.use(express.json());
108
+ app.use((_req, res, next) => {
109
+ res.fail = (code, opts, details) => {
110
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
111
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
112
+ };
113
+ next();
114
+ });
115
+ app.use('/api/bongos', ssoRoutes());
116
+ app.use('/api/bongos', projectsRoutes());
117
+
118
+ const server = app.listen(0);
119
+ await new Promise((r) => server.once('listening', r));
120
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
121
+ after(() => server.close());
122
+
123
+ function call(method, path, body) {
124
+ return new Promise((resolve, reject) => {
125
+ const payload = body === undefined ? null : JSON.stringify(body);
126
+ const req = http.request(`${base}${path}`, {
127
+ method,
128
+ headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
129
+ }, (res) => {
130
+ let d = '';
131
+ res.on('data', (c) => { d += c; });
132
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* html */ } resolve({ status: res.statusCode, body: j, raw: d }); });
133
+ });
134
+ req.on('error', reject);
135
+ if (payload) req.write(payload);
136
+ req.end();
137
+ });
138
+ }
139
+
140
+ test('/sso/authorize REFUSES a catalog-only client — no redirect_uri is registered for it', async () => {
141
+ // The open-redirect guard doing double duty: an empty allow-list means every candidate
142
+ // callback fails exact match, so the browser leg never mints a code and never redirects.
143
+ const uri = 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback';
144
+ const res = await call('GET', `/sso/authorize?client_id=spike-harbor&redirect_uri=${encodeURIComponent(uri)}`);
145
+ assert.equal(res.status, 400);
146
+ assert.match(res.raw, /redirect_uri is not registered/);
147
+ assert.doesNotMatch(res.raw, /code=/, 'no authorization code may appear on any refusal path');
148
+ });
149
+
150
+ test('/sso/token REFUSES a catalog-only client for every secret it could be handed', async () => {
151
+ for (const client_secret of ['', 'guess', 'spike-harbor', 'a'.repeat(64)]) {
152
+ const res = await call('POST', '/sso/token', {
153
+ client_id: 'spike-harbor', client_secret: client_secret || 'x',
154
+ code: 'c', redirect_uri: 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback',
155
+ });
156
+ assert.equal(res.status, 401, `secret "${client_secret}" must not exchange`);
157
+ assert.equal(res.body.error.code, 'invalid_client');
158
+ }
159
+ });
160
+
161
+ test('/sso/device/start REFUSES a catalog-only origin — the leg that checked neither guard', async () => {
162
+ // This is the ONE that needed new code. It resolves the client from the USER-TYPED origin
163
+ // and checked only `status`, so without the guard every catalogued project would have had a
164
+ // device flow nobody registered a credential for.
165
+ deviceStarts = 0;
166
+ const res = await call('POST', '/sso/device/start', { origin: 'https://spike-harbor.cloudbongos.test' });
167
+ assert.equal(res.status, 401);
168
+ assert.equal(res.body.error.code, 'invalid_client');
169
+ assert.equal(deviceStarts, 0, 'and the GitHub device flow is never even started');
170
+ });
171
+
172
+ test('/sso/device/start still serves a real federated client', async () => {
173
+ // The guard must refuse the new row shape and nothing else.
174
+ deviceStarts = 0;
175
+ const res = await call('POST', '/sso/device/start', { origin: 'https://mercury.test' });
176
+ assert.equal(res.status, 200);
177
+ assert.equal(res.body.user_code, 'U');
178
+ assert.equal(deviceStarts, 1);
179
+ });
180
+
181
+ test('/sso/membership/check-in REFUSES a catalog-only client', async () => {
182
+ const res = await call('POST', '/sso/membership/check-in', {
183
+ client_id: 'spike-harbor', client_secret: 'x', github_id: 5150, github_login: 'someone',
184
+ });
185
+ assert.equal(res.status, 401);
186
+ assert.equal(res.body.error.code, 'invalid_client');
187
+ });
188
+
189
+ test('/sso/activity/rollup REFUSES a catalog-only client', async () => {
190
+ const res = await call('POST', '/sso/activity/rollup', {
191
+ client_id: 'spike-harbor', client_secret: 'x', github_id: 5150, credits: 999, rank: 'archon',
192
+ });
193
+ assert.equal(res.status, 401);
194
+ assert.equal(res.body.error.code, 'invalid_client');
195
+ });
196
+
197
+ // ── what the catalog row DOES buy: a resolvable invite target (task 1003580) ──
198
+
199
+ test('POST /projects/invite no longer 404s for a wizard-made project', async () => {
200
+ // GAP 1 of task 1003580: the route resolves its target with getClient() and refused
201
+ // `unknown_project` because a standalone project was never an sso_clients row at all. It
202
+ // now is. The route's `project.curate` gate is deliberately untouched — widening it to the
203
+ // project's own owner is 1003580's job, and this test stubs the permission middleware so
204
+ // the assertion is about the TARGET resolving, not about who may call it.
205
+ const res = await call('POST', '/projects/invite', { client_id: 'spike-harbor', github_login: 'someone' });
206
+ assert.notEqual(res.status, 404);
207
+ assert.equal(res.status, 201);
208
+ assert.equal(res.body.invited, true);
209
+ assert.equal(res.body.clientId, 'spike-harbor');
210
+ });
211
+
212
+ test('an unknown project still 404s — the catalog row is what resolves, not the route', async () => {
213
+ const res = await call('POST', '/projects/invite', { client_id: 'never-provisioned', github_login: 'someone' });
214
+ assert.equal(res.status, 404);
215
+ assert.equal(res.body.error.code, 'unknown_project');
216
+ });
217
+
218
+ // ── the seam the write half travels on ───────────────────────────────────────
219
+
220
+ test('mounting the projects routes registers the `project.catalog` port', async () => {
221
+ // The write half of the same seam `project.publishGate` is the read half of. provisioning
222
+ // owns these projects and may not import this module (ADR 0083), so the switchboard is how
223
+ // the projection reaches the tables that hold the map.
224
+ assert.equal(api.hasProvider('project.catalog'), true);
225
+ assert.equal(api.resolve('project.catalog'), catalog.projectCatalogPort);
226
+ assert.equal(typeof api.resolve('project.catalog').publish, 'function');
227
+ assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
228
+ });
229
+
230
+ test('the port declares the catalog write and nothing else', () => {
231
+ // One verb. A consumer maps its own row onto the entry shape, so the catalog never learns
232
+ // another module’s schema and no module gets a general-purpose handle on these tables.
233
+ assert.deepEqual(Object.keys(catalog.projectCatalogPort), ['publish']);
234
+ });
235
+
236
+ test('the module manifest DECLARES the port it registers', async () => {
237
+ // verifyPortsSatisfied reads the manifests at boot, not the registry — an undeclared
238
+ // provider is invisible to it.
239
+ const manifest = JSON.parse(await (await import('node:fs/promises')).readFile(
240
+ new URL('../modules/platform-identity/module.json', import.meta.url), 'utf8'));
241
+ assert.ok(manifest.provides.includes('project.catalog'));
242
+ });
@@ -48,11 +48,14 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
48
48
  });
49
49
 
50
50
  test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
51
- // `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1)
52
- assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply', visibility: 'public' });
51
+ // `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1);
52
+ // `joinGrant` — what a PUBLIC join grants — in task 1002331 (ADR 0247), defaulting
53
+ // to `full`, the value that narrows nothing, so an un-pushed instance is unchanged
54
+ assert.deepEqual(clientBranding({}).project,
55
+ { platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full' });
53
56
  assert.deepEqual(
54
- clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' } }).project,
55
- { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' },
57
+ clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view' } }).project,
58
+ { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view' },
56
59
  'what the instance actually resolved (env override included) is what it publishes');
57
60
  });
58
61
 
@@ -71,7 +74,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
71
74
  // `project` joined the allow-list in task 1003140: the per-project policy is public
72
75
  // by nature and is the settings read-back a manage page needs (see clientBranding).
73
76
  assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
74
- assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility', 'visibility']);
77
+ assert.deepEqual(Object.keys(projected.project).sort(), ['joinGrant', 'joinability', 'platformVisibility', 'visibility']);
75
78
  for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
76
79
  assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
77
80
  }
@@ -36,6 +36,7 @@ const PUBLISHED_SURFACE = [
36
36
  'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
37
37
  'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
38
38
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
39
+ 'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
39
40
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
40
41
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
41
42
  'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',