@bongos/core 1.19.533 → 1.19.535
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 +121 -56
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
- package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
- package/docs/adr/README.md +4 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +162 -157
- package/docs/copy-registry.json +252 -196
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +21 -6
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-catalog.js +202 -0
- package/modules/platform-identity/routes/my-projects.js +7 -2
- package/modules/platform-identity/routes/projects.js +6 -0
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/provisioning/catalog-bridge.js +105 -0
- package/modules/provisioning/provisioning.js +28 -2
- package/modules/provisioning/routes/provisioning.js +45 -11
- package/modules/provisioning/tests/provisioning.mjs +11 -8
- package/modules/public-landing/public/assets/cosmos.css +15 -0
- package/modules/public-landing/public/projects.html +162 -3
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/provision-net.js +7 -0
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/bongos/project-door.js +138 -0
- package/src/branding.js +8 -0
- package/src/module-api.js +12 -1
- package/tests/access_requests_invite.mjs +2 -2
- package/tests/application_lifecycle.mjs +1 -0
- package/tests/catalog_only_client.mjs +242 -0
- package/tests/currency_label.mjs +8 -5
- package/tests/hall_kit_charts.mjs +173 -0
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_reading_world.mjs +153 -0
- package/tests/module_api.mjs +1 -0
- package/tests/project_catalog_bridge.mjs +355 -0
- package/tests/project_catalog_routes.mjs +213 -0
- package/tests/project_join_door.mjs +166 -0
- package/tests/project_join_door_route.mjs +206 -0
- package/tests/project_modules_ui.mjs +318 -0
- package/tests/projects_hub.mjs +6 -1
- package/tests/projects_hub_pre_uat.mjs +2 -2
- package/tests/provision_settings_apply.mjs +6 -5
- package/tests/provisioning_module_selection.mjs +164 -3
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +7 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// modules/platform-identity/project-catalog.js — CATALOG MEMBERSHIP, split from SSO
|
|
2
|
+
// CREDENTIALS (task 1003576, ADR 0246). How a project the control plane owns gets onto
|
|
3
|
+
// the hub's map at all, and keeps its row current when its owner edits it.
|
|
4
|
+
//
|
|
5
|
+
// THE BUG THIS FILE IS THE ANSWER TO. The owner-facing publish verdict is computed over
|
|
6
|
+
// `provisioning_instances` (the control plane's own row); the public map reads
|
|
7
|
+
// `platform_identity_projects`. The ONLY thing that ever wrote the second from the first
|
|
8
|
+
// was federation — registerClient → upsertProject — which `shouldFederate` refuses for any
|
|
9
|
+
// instance that is not `co-tenant`, and which runs ONCE, at provision time. So a wizard-made
|
|
10
|
+
// (standalone) project was never catalogued at all, and a co-tenant one that answered
|
|
11
|
+
// "tell us about your project" LATER never re-federated. `publishable: true` and an empty
|
|
12
|
+
// map, forever (the BV1.R18 proof, task 1002337 §3).
|
|
13
|
+
//
|
|
14
|
+
// THE SHAPE. The catalog row is a PROJECTION of the provisioning row, re-derived at every
|
|
15
|
+
// write that can change its inputs — not a side effect of standing up a particular hosting
|
|
16
|
+
// shape. Federation mints CREDENTIALS (a secret + a redirect-URI allow-list, so an instance
|
|
17
|
+
// can run the SSO handoff); this mints MEMBERSHIP (a name the map, the invite and the
|
|
18
|
+
// membership directory can resolve). Conflating the two is what made hosting topology decide
|
|
19
|
+
// who is on the map — ADR 0182 D4 says the gate is IDENTITY (a name and a description), and
|
|
20
|
+
// identity has nothing to do with where a project is hosted.
|
|
21
|
+
//
|
|
22
|
+
// A CATALOG-ONLY CLIENT CARRIES NO CREDENTIAL, and that is enforced by the row's own shape
|
|
23
|
+
// rather than by a flag some future caller could forget: `secret_hash = ''` and
|
|
24
|
+
// `redirect_uris = '{}'`. Every SSO leg then refuses it with machinery that already existed —
|
|
25
|
+
// clientSecretValid() fails closed on a secretless client (/sso/token, /sso/membership/check-in,
|
|
26
|
+
// /sso/activity/rollup) and redirectUriAllowed() fails closed on an empty allow-list
|
|
27
|
+
// (/sso/authorize). The ONE leg that checked neither was /sso/device/start, which resolves a
|
|
28
|
+
// client from a typed origin; clientIsCredentialed() below is the guard it now applies.
|
|
29
|
+
// No migration: `secret_hash` is NOT NULL, and '' satisfies NOT NULL while hashing to nothing.
|
|
30
|
+
//
|
|
31
|
+
// TWO CLAIM RULES, because this writes rows keyed on values an owner supplies (a slug, a
|
|
32
|
+
// bring-your-own domain) and neither table proves who owns an origin:
|
|
33
|
+
// 1. THE CLIENT — an existing row is refreshed ONLY when it is uncredentialed. The bridge
|
|
34
|
+
// can therefore never rewrite a real client's `origin` (which is what binds the CLI
|
|
35
|
+
// device-mint audience) nor blank its secret. A slug that collides with a credentialed
|
|
36
|
+
// client is simply not claimed.
|
|
37
|
+
// 2. THE PROJECT — an existing row is refreshed ONLY when its `owner_github_id` already
|
|
38
|
+
// equals this instance owner's. A NULL owner on either side compares NULL, i.e. not
|
|
39
|
+
// true, so the hand-seeded catalog rows (owner null) can never be claimed by someone
|
|
40
|
+
// creating an instance at their address. First INSERT wins and stamps the owner; every
|
|
41
|
+
// later write must match it.
|
|
42
|
+
// Both are expressed as the ON CONFLICT action's own WHERE, so the check and the write are
|
|
43
|
+
// one statement and there is no read-then-write window to lose.
|
|
44
|
+
//
|
|
45
|
+
// WHAT IS AUTHORITATIVE AND WHAT IS NOT. `description` and `visibility` are written
|
|
46
|
+
// AUTHORITATIVELY (not COALESCEd): clearing a description must take the project OFF the map,
|
|
47
|
+
// and the owner's door must be able to change — a COALESCE is precisely why the catalog kept
|
|
48
|
+
// a NULL description while the owner stared at `publishable: true`. `tagline`, `art_url`,
|
|
49
|
+
// `featured` and `template` are CURATION and are never touched here. `owner_github_id` is
|
|
50
|
+
// ATTRIBUTION, never authority (ADR 0238): ownership stays `provisioning_instances.owner_builder_id`.
|
|
51
|
+
// Proof: ../../tests/project_catalog_bridge.mjs, ../../tests/catalog_only_client.mjs
|
|
52
|
+
'use strict';
|
|
53
|
+
|
|
54
|
+
const { pool: defaultPool } = require('../../src/module-api');
|
|
55
|
+
const { ORB_BY_STATE, FALLBACK_PROJECT_STATE } = require('./projects-feed');
|
|
56
|
+
|
|
57
|
+
// The catalog row's operational lifecycle, mirrored from migration 004's CHECK. 'hidden'
|
|
58
|
+
// keeps a reclaimed project out of every listing without deleting its history.
|
|
59
|
+
const CATALOG_STATUSES = Object.freeze(['active', 'hidden']);
|
|
60
|
+
|
|
61
|
+
// Does this client hold a usable SSO credential? A catalog-only row is stored with an EMPTY
|
|
62
|
+
// secret_hash, so this is the one question that separates "the hub can name this project"
|
|
63
|
+
// from "this project may run the sign-in handoff". Exported because /sso/device/start
|
|
64
|
+
// resolves a client from a typed origin and checks neither the secret nor the redirect
|
|
65
|
+
// allow-list — the other legs fail closed on their own. PURE.
|
|
66
|
+
function clientIsCredentialed(client) {
|
|
67
|
+
return !!(client && typeof client.secret_hash === 'string' && client.secret_hash.length > 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The entry, normalised to values the two CHECK constraints accept. Both fall back to the
|
|
71
|
+
// CLOSED direction: an unreadable door is anonymous (the feed's own FALLBACK_PROJECT_STATE),
|
|
72
|
+
// and an unreadable lifecycle is hidden. A caller that sends garbage loses visibility, never
|
|
73
|
+
// gains it. PURE.
|
|
74
|
+
function normalizeEntry(entry) {
|
|
75
|
+
const e = entry || {};
|
|
76
|
+
const origin = String(e.origin || '').replace(/\/+$/, '');
|
|
77
|
+
const clientId = String(e.clientId || '');
|
|
78
|
+
const ownerGithubId = Number(e.ownerGithubId);
|
|
79
|
+
if (!origin) throw new Error('project-catalog: origin required');
|
|
80
|
+
if (!clientId) throw new Error('project-catalog: clientId required');
|
|
81
|
+
// No owner → no claim rule can hold, so the row would be un-refreshable forever. Refuse
|
|
82
|
+
// to write one rather than strand it (the bridge resolves the owner before calling).
|
|
83
|
+
if (!Number.isFinite(ownerGithubId) || ownerGithubId <= 0) {
|
|
84
|
+
throw new Error('project-catalog: ownerGithubId required — the catalog row is un-refreshable without it');
|
|
85
|
+
}
|
|
86
|
+
const visibility = Object.prototype.hasOwnProperty.call(ORB_BY_STATE, e.visibility)
|
|
87
|
+
? e.visibility : FALLBACK_PROJECT_STATE;
|
|
88
|
+
const status = CATALOG_STATUSES.includes(e.status) ? e.status : 'hidden';
|
|
89
|
+
return {
|
|
90
|
+
origin,
|
|
91
|
+
clientId,
|
|
92
|
+
ownerGithubId,
|
|
93
|
+
name: String(e.name || clientId),
|
|
94
|
+
description: e.description ? String(e.description) : null,
|
|
95
|
+
type: e.type ? String(e.type) : null,
|
|
96
|
+
visibility,
|
|
97
|
+
status,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The catalog-only SSO client — an IDENTITY the invite route and the membership directory can
|
|
102
|
+
// resolve, with no credential attached. The literal '' / '{}' are in the SQL, never bound
|
|
103
|
+
// values, so no caller can hand this function a real secret or an allow-list entry. The
|
|
104
|
+
// ON CONFLICT WHERE is claim rule 1: a credentialed row is left completely alone (its origin
|
|
105
|
+
// binds the CLI mint audience). Returns the row, or null when the client_id is already a real
|
|
106
|
+
// client's. Never touches `status` — a suspended client stays suspended.
|
|
107
|
+
async function upsertCatalogOnlyClient(entry, { pool = defaultPool } = {}) {
|
|
108
|
+
const e = normalizeEntry(entry);
|
|
109
|
+
const { rows } = await pool.query(
|
|
110
|
+
`INSERT INTO platform_identity_sso_clients
|
|
111
|
+
(client_id, secret_hash, name, redirect_uris, origin, owner_github_id)
|
|
112
|
+
VALUES ($1, '', $2, '{}'::text[], $3, $4)
|
|
113
|
+
ON CONFLICT (client_id) DO UPDATE SET
|
|
114
|
+
name = EXCLUDED.name,
|
|
115
|
+
origin = EXCLUDED.origin,
|
|
116
|
+
owner_github_id = EXCLUDED.owner_github_id,
|
|
117
|
+
updated_at = now()
|
|
118
|
+
WHERE platform_identity_sso_clients.secret_hash = ''
|
|
119
|
+
RETURNING client_id, name, origin, owner_github_id, status`,
|
|
120
|
+
[e.clientId, e.name, e.origin, e.ownerGithubId],
|
|
121
|
+
);
|
|
122
|
+
return rows[0] || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The catalog row itself, keyed on origin. `description` and `visibility` are assigned, not
|
|
126
|
+
// COALESCEd — see the header: a once-at-provision COALESCE is the whole bug. The ON CONFLICT
|
|
127
|
+
// WHERE is claim rule 2 (the owner must already match), so an origin someone else's row
|
|
128
|
+
// occupies is refused rather than overwritten. Returns the row, or null when the claim
|
|
129
|
+
// refused. `featured`, `tagline`, `art_url` and `template` are curation and stay untouched.
|
|
130
|
+
async function upsertOwnedProject(entry, { pool = defaultPool } = {}) {
|
|
131
|
+
const e = normalizeEntry(entry);
|
|
132
|
+
const { rows } = await pool.query(
|
|
133
|
+
`INSERT INTO platform_identity_projects
|
|
134
|
+
(origin, name, description, owner_github_id, type, visibility, status)
|
|
135
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
136
|
+
ON CONFLICT (origin) DO UPDATE SET
|
|
137
|
+
name = EXCLUDED.name,
|
|
138
|
+
description = EXCLUDED.description,
|
|
139
|
+
visibility = EXCLUDED.visibility,
|
|
140
|
+
status = EXCLUDED.status,
|
|
141
|
+
type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
|
|
142
|
+
updated_at = now()
|
|
143
|
+
WHERE platform_identity_projects.owner_github_id = EXCLUDED.owner_github_id
|
|
144
|
+
RETURNING id, origin, name, description, owner_github_id, type, status, featured, visibility`,
|
|
145
|
+
[e.origin, e.name, e.description, e.ownerGithubId, e.type, e.visibility, e.status],
|
|
146
|
+
);
|
|
147
|
+
return rows[0] || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Take a catalog row OFF the map when its project moved to a different address (the domain
|
|
151
|
+
// re-point, task 1003237): an abandoned origin left `active` would keep drawing an orb that
|
|
152
|
+
// points at a hostname the project no longer answers on — the ADR 0181 §2 leak, in the
|
|
153
|
+
// catalog rather than in DNS. Owner-scoped for the same reason the upsert is, so a re-point
|
|
154
|
+
// can only retire the caller's own row. Returns the retired row or null.
|
|
155
|
+
async function retireOwnedProject({ origin, ownerGithubId }, { pool = defaultPool } = {}) {
|
|
156
|
+
const norm = String(origin || '').replace(/\/+$/, '');
|
|
157
|
+
const owner = Number(ownerGithubId);
|
|
158
|
+
if (!norm || !Number.isFinite(owner) || owner <= 0) return null;
|
|
159
|
+
const { rows } = await pool.query(
|
|
160
|
+
`UPDATE platform_identity_projects SET status = 'hidden', updated_at = now()
|
|
161
|
+
WHERE origin = $1 AND owner_github_id = $2
|
|
162
|
+
RETURNING id, origin, status`,
|
|
163
|
+
[norm, owner],
|
|
164
|
+
);
|
|
165
|
+
return rows[0] || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ONE control-plane project, projected into the catalog: the identity row first, then the map
|
|
169
|
+
// row, then (on a re-point) the address it left. Both writes are claim-gated, so `claimed`
|
|
170
|
+
// tells the caller whether the map actually carries this project — a false is a refusal, not
|
|
171
|
+
// an error, and the caller decides whether it is worth saying anything about.
|
|
172
|
+
async function publishControlPlaneProject(entry, { retireOrigin = null, pool = defaultPool } = {}) {
|
|
173
|
+
const e = normalizeEntry(entry);
|
|
174
|
+
const client = await upsertCatalogOnlyClient(e, { pool });
|
|
175
|
+
const project = await upsertOwnedProject(e, { pool });
|
|
176
|
+
let retired = null;
|
|
177
|
+
if (retireOrigin && String(retireOrigin).replace(/\/+$/, '') !== e.origin) {
|
|
178
|
+
retired = await retireOwnedProject({ origin: retireOrigin, ownerGithubId: e.ownerGithubId }, { pool });
|
|
179
|
+
}
|
|
180
|
+
return { claimed: !!project, client, project, retired };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The KERNEL SEAM PORT — `project.catalog`, registered by routes/projects.js and declared in
|
|
184
|
+
// module.json. The `project.publishGate` precedent, and for the same reason: provisioning is
|
|
185
|
+
// the plane that OWNS these projects and may not import this module (the ADR 0083 one-way
|
|
186
|
+
// rule), while this module owns the catalog's tables. One definition, reached through the
|
|
187
|
+
// switchboard. `publish` is deliberately the only verb — a consumer maps its own row onto the
|
|
188
|
+
// entry shape, so the catalog never learns another module's schema.
|
|
189
|
+
const projectCatalogPort = Object.freeze({
|
|
190
|
+
publish: (entry, opts) => publishControlPlaneProject(entry, opts),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
CATALOG_STATUSES,
|
|
195
|
+
clientIsCredentialed,
|
|
196
|
+
normalizeEntry,
|
|
197
|
+
upsertCatalogOnlyClient,
|
|
198
|
+
upsertOwnedProject,
|
|
199
|
+
retireOwnedProject,
|
|
200
|
+
publishControlPlaneProject,
|
|
201
|
+
projectCatalogPort,
|
|
202
|
+
};
|
|
@@ -42,13 +42,18 @@ const RELAY_SENTENCE = {
|
|
|
42
42
|
invite_only: 'This project is invite-only — it doesn’t take requests. Ask its owner for an invite; once invited, sign in to it.',
|
|
43
43
|
open_enrollment: 'This project is open — no request needed. Sign in to it and you’re in.',
|
|
44
44
|
members_only: 'This project is only visible to its members right now, so it can’t take a request from here — ask its owner.',
|
|
45
|
+
// Dark matter (BV1.R12, task 1002331): the project's own door says it is not
|
|
46
|
+
// published yet. Relayed as its own outcome rather than left to fall through,
|
|
47
|
+
// because the fall-through is a 502 "could not be reached" — the exact lie about
|
|
48
|
+
// someone else's door that ADR 0194 D5 forbids.
|
|
49
|
+
not_published: 'This project isn’t published yet, so it isn’t taking anyone on. Its owner has to finish setting it up first.',
|
|
45
50
|
};
|
|
46
51
|
function joinRelayOutcome(status, body) {
|
|
47
52
|
const err = (body && body.error) || null;
|
|
48
53
|
const code = err && (err.code || err);
|
|
49
54
|
let outcome;
|
|
50
55
|
if (status >= 200 && status < 300) outcome = 'requested';
|
|
51
|
-
else if (code === 'invite_only' || code === 'open_enrollment') outcome = code;
|
|
56
|
+
else if (code === 'invite_only' || code === 'open_enrollment' || code === 'not_published') outcome = code;
|
|
52
57
|
else if (status === 409) outcome = 'already_member';
|
|
53
58
|
else if (status === 401) outcome = 'members_only';
|
|
54
59
|
else return null;
|
|
@@ -184,7 +189,7 @@ module.exports = function myProjectsRoutes() {
|
|
|
184
189
|
}
|
|
185
190
|
if (!outcome) return res.fail('join_relay_failed', { status: 502, message: 'the project could not be reached to request access' });
|
|
186
191
|
// The echo (ADR 0201 D1.3) — written ONLY when the project actually took an
|
|
187
|
-
// application. 'already_member', the
|
|
192
|
+
// application. 'already_member', the three door refusals and 'members_only'
|
|
188
193
|
// are not applications and must leave no trace; a re-apply IS one ('requested'
|
|
189
194
|
// covers the project's own already_pending 200, D1.4), and refreshes the date.
|
|
190
195
|
// Best-effort in both directions: a lost echo must never turn a filed
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
const express = require('express');
|
|
22
22
|
const api = require('../../../src/module-api');
|
|
23
23
|
const pi = require('../platform-identity');
|
|
24
|
+
const projectCatalog = require('../project-catalog');
|
|
24
25
|
const projectStats = require('../project-stats');
|
|
25
26
|
|
|
26
27
|
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
|
|
@@ -41,9 +42,14 @@ function hostOfOrigin(origin) {
|
|
|
41
42
|
// catalogued here — ask THIS module's gate whether a project is on the map,
|
|
42
43
|
// without importing it (the ADR 0083 one-way rule) and without keeping a
|
|
43
44
|
// second copy of the rule that could drift from publishableSql().
|
|
45
|
+
// • PROVIDES port `project.catalog` → { publish(entry) } (task 1003576, ADR 0246).
|
|
46
|
+
// The write half of the same seam: the plane that OWNS a project (provisioning)
|
|
47
|
+
// projects it into the catalog these tables hold, so getting onto the map stops
|
|
48
|
+
// depending on whether the project happened to federate as a co-tenant.
|
|
44
49
|
function registerPublishGateSeam() {
|
|
45
50
|
if (api.hasProvider('project.publishGate')) return;
|
|
46
51
|
api.registerProvider('project.publishGate', pi.publishGatePort);
|
|
52
|
+
api.registerProvider('project.catalog', projectCatalog.projectCatalogPort);
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
module.exports = function projectsRoutes() {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const express = require('express');
|
|
18
18
|
const api = require('../../../src/module-api');
|
|
19
19
|
const pi = require('../platform-identity');
|
|
20
|
+
const projectCatalog = require('../project-catalog');
|
|
20
21
|
const scoutingAuthz = require('../scouting-authz');
|
|
21
22
|
const canonicalProfile = require('../canonical-profile');
|
|
22
23
|
const hubKeys = require('../hub-keys');
|
|
@@ -325,8 +326,17 @@ module.exports = function ssoRoutes() {
|
|
|
325
326
|
try {
|
|
326
327
|
// Resolve the client from the user-typed origin — NOT from any client_id the
|
|
327
328
|
// caller supplies. An unregistered origin has no client → refuse (correct default).
|
|
329
|
+
//
|
|
330
|
+
// …and a CATALOG-ONLY client is not a federation partner (task 1003576, ADR 0246).
|
|
331
|
+
// This is the one SSO leg that checks neither the secret nor the redirect allow-list,
|
|
332
|
+
// so it is the one that needs the credential asked for explicitly: /sso/token,
|
|
333
|
+
// /sso/membership/check-in and /sso/activity/rollup already fail closed on a
|
|
334
|
+
// secretless client (clientSecretValid), and /sso/authorize on an empty allow-list
|
|
335
|
+
// (redirectUriAllowed). Without this, minting a catalog row for every control-plane
|
|
336
|
+
// project would hand each of them a device flow nobody registered a credential for.
|
|
328
337
|
const client = await pi.getClientByOrigin(req.body.origin, { pool });
|
|
329
338
|
if (!client || client.status !== 'active') return res.fail('invalid_client', 401);
|
|
339
|
+
if (!projectCatalog.clientIsCredentialed(client)) return res.fail('invalid_client', 401);
|
|
330
340
|
const out = await hubDevice.startHubGithubDevice(client.client_id);
|
|
331
341
|
res.json(out);
|
|
332
342
|
} catch (err) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// modules/provisioning/catalog-bridge.js — the provisioning row → hub catalog projection
|
|
2
|
+
// (task 1003576, ADR 0246). The half of the bridge this module owns: WHAT a control-plane
|
|
3
|
+
// project looks like on the map, and WHEN that picture is re-taken.
|
|
4
|
+
//
|
|
5
|
+
// WHY IT EXISTS. An owner's publish verdict is computed here, over this module's row
|
|
6
|
+
// (`publishVerdict` in routes/provisioning.js). The map is `platform_identity_projects`, a
|
|
7
|
+
// table this module may not touch (ADR 0083's one-way rule). Until this file the ONLY thing
|
|
8
|
+
// that ever carried the first into the second was federation at provision time, gated on
|
|
9
|
+
// `hosting_shape === 'co-tenant'` — so a wizard-made standalone project was never on the map
|
|
10
|
+
// and a co-tenant one that answered later never updated. The projection below is re-taken at
|
|
11
|
+
// every write that can change its inputs (create, the detail answers, the settings door, a
|
|
12
|
+
// re-pointed address), which is the property a once-at-standup write can never have.
|
|
13
|
+
//
|
|
14
|
+
// IT GRANTS NOTHING. The catalog row is attribution (ADR 0238): ownership stays
|
|
15
|
+
// `provisioning_instances.owner_builder_id`, which is what `instanceAccessDenied` already
|
|
16
|
+
// enforces on every route that calls this. The projection carries the owner's github id only
|
|
17
|
+
// so the hub can attribute the row and claim-check its own writes.
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const api = require('../../src/module-api');
|
|
21
|
+
const { effectiveSettings } = require('./provisioning');
|
|
22
|
+
|
|
23
|
+
const log = api.logger('provisioning');
|
|
24
|
+
|
|
25
|
+
// The instance states in which the project is being, or has been, reclaimed — the two that
|
|
26
|
+
// map to a HIDDEN catalog row. Every other state is a project that exists: 'requested',
|
|
27
|
+
// 'provisioning' and 'error' are all "the owner is standing this up", and the map is a
|
|
28
|
+
// directory of projects, not an uptime board (an unreachable 'active' instance already stays
|
|
29
|
+
// listed; liveness is an overlay, never a state).
|
|
30
|
+
const CATALOG_HIDDEN_STATES = new Set(['tearing_down', 'torn_down']);
|
|
31
|
+
|
|
32
|
+
// ONE instance row → the catalog entry, or null when it cannot be catalogued. PURE.
|
|
33
|
+
//
|
|
34
|
+
// Null has exactly two causes, both honest refusals rather than failures:
|
|
35
|
+
// • NO ADDRESS — the catalog is keyed on `origin`, so a `no_address: true` instance has
|
|
36
|
+
// nothing to key a row on. That opt-out is a deliberate choice not to have a public
|
|
37
|
+
// address, and it therefore means not being on the public map.
|
|
38
|
+
// • NO OWNER GITHUB ID — the hub's claim rule compares owners, so a row written without
|
|
39
|
+
// one could never be refreshed again. Refusing to write it beats stranding it.
|
|
40
|
+
//
|
|
41
|
+
// The MAPPING is the part this module owns: an instance's `slug` IS its project's name on
|
|
42
|
+
// this plane (the same mapping `publishVerdict` feeds the publish gate) and its `client_id`
|
|
43
|
+
// on the hub (the same one `federateInstance` uses, so a later real federation rotates the
|
|
44
|
+
// SAME row rather than orphaning it). The door comes from `effectiveSettings`, never the raw
|
|
45
|
+
// jsonb, so an off-vocabulary stored value resolves to today's behaviour exactly as it does
|
|
46
|
+
// on every other surface.
|
|
47
|
+
function catalogEntryForInstance(row, { ownerGithubId } = {}) {
|
|
48
|
+
if (!row || !row.slug || !row.domain) return null;
|
|
49
|
+
const owner = Number(ownerGithubId);
|
|
50
|
+
if (!Number.isFinite(owner) || owner <= 0) return null;
|
|
51
|
+
return {
|
|
52
|
+
clientId: row.slug,
|
|
53
|
+
origin: `https://${row.domain}`,
|
|
54
|
+
name: row.slug,
|
|
55
|
+
description: row.description || null,
|
|
56
|
+
type: row.type || null,
|
|
57
|
+
visibility: effectiveSettings(row).visibility,
|
|
58
|
+
status: CATALOG_HIDDEN_STATES.has(row.status) ? 'hidden' : 'active',
|
|
59
|
+
ownerGithubId: owner,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The owner's stable GitHub id, for attribution on the catalog row. Reads core's `builders`
|
|
64
|
+
// table directly, which is `federateInstance`'s own precedent for exactly this lookup — a
|
|
65
|
+
// nullable, best-effort field, so a miss must never fail the caller.
|
|
66
|
+
async function ownerGithubIdForInstance(db, row) {
|
|
67
|
+
if (!row || !row.owner_builder_id) return null;
|
|
68
|
+
const { rows } = await db.query('SELECT github_id FROM builders WHERE id = $1', [row.owner_builder_id]);
|
|
69
|
+
return rows[0] ? rows[0].github_id : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Re-take the projection for one instance and hand it to the hub through the `project.catalog`
|
|
73
|
+
// port. `retireOrigin` is the address a re-point moved OFF, so the abandoned row leaves the map
|
|
74
|
+
// with the same call that puts the new one on it.
|
|
75
|
+
//
|
|
76
|
+
// resolveOptional, not resolve, and NOT declared in `consumes` — the publishVerdict precedent:
|
|
77
|
+
// platform-identity is hub-only (`default: false`), so an instance running provisioning without
|
|
78
|
+
// it has no map to be on and the honest result is "not published", never an error.
|
|
79
|
+
//
|
|
80
|
+
// BEST-EFFORT BY CONSTRUCTION. Every caller is a route whose real work — the create, the
|
|
81
|
+
// detail answers, the settings save — is already durably written when this runs. A catalog
|
|
82
|
+
// hiccup must not turn a saved change into a 500, so this never throws; it reports why.
|
|
83
|
+
async function publishInstanceToCatalog(db, row, { retireOrigin = null } = {}) {
|
|
84
|
+
const catalog = api.resolveOptional('project.catalog');
|
|
85
|
+
if (!catalog) return { published: false, reason: 'no_catalog' };
|
|
86
|
+
try {
|
|
87
|
+
const ownerGithubId = await ownerGithubIdForInstance(db, row);
|
|
88
|
+
const entry = catalogEntryForInstance(row, { ownerGithubId });
|
|
89
|
+
if (!entry) return { published: false, reason: row && row.domain ? 'no_owner_identity' : 'no_address' };
|
|
90
|
+
const out = await catalog.publish(entry, { retireOrigin: retireOrigin ? `https://${retireOrigin}` : null });
|
|
91
|
+
if (!out.claimed) {
|
|
92
|
+
log.warn({ slug: row.slug, origin: entry.origin }, 'catalog claim refused — that address is already another project\'s row');
|
|
93
|
+
}
|
|
94
|
+
return { published: !!out.claimed, reason: out.claimed ? null : 'origin_held', entry };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log.error({ err: err.message, slug: row && row.slug }, 'catalog projection failed (non-fatal)');
|
|
97
|
+
return { published: false, reason: 'error' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
CATALOG_HIDDEN_STATES,
|
|
103
|
+
catalogEntryForInstance,
|
|
104
|
+
publishInstanceToCatalog,
|
|
105
|
+
};
|
|
@@ -136,6 +136,8 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
|
|
|
136
136
|
// visibility how the project stands on the PLATFORM's map: named and
|
|
137
137
|
// open (public), named and closed (private), or present but
|
|
138
138
|
// anonymous (stealth)
|
|
139
|
+
// join_grant what a PUBLIC join GRANTS: a look (view), a request the
|
|
140
|
+
// owner reviews (apply), or membership itself (full)
|
|
139
141
|
//
|
|
140
142
|
// The name is ADR 0182 D1's deliberate choice — `goals.visibility` established the
|
|
141
143
|
// word and one word across both planes beat an accurate name used in one place —
|
|
@@ -148,15 +150,27 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
|
|
|
148
150
|
// so it rides web.env like its two neighbours and a restart applies it. The instance
|
|
149
151
|
// reads it back as `project.visibility` (src/branding.js), which is what lets a later
|
|
150
152
|
// task enforce the door there and report the value to the hub catalog (R08).
|
|
153
|
+
//
|
|
154
|
+
// `join_grant` is the owner's answer to "what does joining my public project GET
|
|
155
|
+
// someone?" (BV1.R12, task 1002331 — the project interview's own words). It is the
|
|
156
|
+
// CEILING on the visibility axis that ADR 0182 D6 says `joinability` may only
|
|
157
|
+
// narrow, and it governs the `public` state alone: `private` and `stealth` set
|
|
158
|
+
// their own ceilings and never consult it. `full` is "this axis narrows nothing",
|
|
159
|
+
// which is why it is the default — DEFAULT_SETTINGS must be today's behaviour, and
|
|
160
|
+
// today a public project runs whatever door its joinability runs. Composed at the
|
|
161
|
+
// door by src/bongos/project-door.js; ADR 0247.
|
|
151
162
|
const SETTINGS_VOCAB = Object.freeze({
|
|
152
163
|
platform_visibility: ['public', 'gated'],
|
|
153
164
|
joinability: ['open', 'apply', 'invite_only'],
|
|
154
165
|
visibility: ['public', 'private', 'stealth'],
|
|
166
|
+
join_grant: ['view', 'apply', 'full'],
|
|
155
167
|
});
|
|
156
168
|
// Default = today's behavior on every axis, including the join door: ADR 0182's
|
|
157
169
|
// migration gives the hub column the same `public` default, "existing rows keep
|
|
158
170
|
// today's behaviour".
|
|
159
|
-
const DEFAULT_SETTINGS = Object.freeze({
|
|
171
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
172
|
+
platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full',
|
|
173
|
+
});
|
|
160
174
|
|
|
161
175
|
// The project's PLANET (task 1003347, direction record §16): how the platform
|
|
162
176
|
// DRAWS it on the sky, in the owner's list and on the card — its template
|
|
@@ -646,7 +660,14 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
646
660
|
// merge. Passing description: null clears it — and un-publishes the project, which
|
|
647
661
|
// is the honest consequence of deleting half the identity gate, not a bug.
|
|
648
662
|
// Undefined leaves the column alone.
|
|
649
|
-
|
|
663
|
+
//
|
|
664
|
+
// `modules` is the SAME way back for the wizard's other optional step (BV1.R23,
|
|
665
|
+
// task 1002342, ADR 0245). It REPLACES like `description` and unlike `detail`: a
|
|
666
|
+
// module selection is one whole list, so merging would make removing a module
|
|
667
|
+
// impossible — the very half of "add modules later" an owner needs most. Only an
|
|
668
|
+
// array ever reaches here, so the column's NULL ("never asked", ADR 0240 §2)
|
|
669
|
+
// stays reachable only by never answering; no write can un-ask the question.
|
|
670
|
+
async function updateInstanceDetail(db, id, { description, detail, modules } = {}) {
|
|
650
671
|
const sets = ['updated_at = now()'];
|
|
651
672
|
const params = [id];
|
|
652
673
|
if (description !== undefined) {
|
|
@@ -657,6 +678,11 @@ async function updateInstanceDetail(db, id, { description, detail } = {}) {
|
|
|
657
678
|
params.push(JSON.stringify(normalizeProjectDetail(detail)));
|
|
658
679
|
sets.push(`detail = detail || $${params.length}::jsonb`);
|
|
659
680
|
}
|
|
681
|
+
if (modules !== undefined) {
|
|
682
|
+
const picked = normalizeModuleSelection(modules);
|
|
683
|
+
params.push(picked === null ? null : JSON.stringify(picked));
|
|
684
|
+
sets.push(`modules = $${params.length}::jsonb`);
|
|
685
|
+
}
|
|
660
686
|
const { rows } = await db.query(
|
|
661
687
|
`UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
|
|
662
688
|
params
|
|
@@ -42,6 +42,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
|
42
42
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
43
43
|
const { recommendationsForDetails } = require('../recommendations');
|
|
44
44
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
45
|
+
const catalogBridge = require('../catalog-bridge');
|
|
45
46
|
const { callbackPage } = require('./callback-page');
|
|
46
47
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
47
48
|
const log = api.logger('provisioning');
|
|
@@ -479,6 +480,12 @@ module.exports = function provisioningRoutes() {
|
|
|
479
480
|
actor: 'api:self',
|
|
480
481
|
}).catch(() => {});
|
|
481
482
|
}
|
|
483
|
+
// The project joins the hub catalog the moment it EXISTS, not when (or whether) it
|
|
484
|
+
// later federates as a co-tenant (task 1003576, ADR 0246). It is dark matter until a
|
|
485
|
+
// description exists — the publish gate alone decides that — but the row has to be
|
|
486
|
+
// there for the gate to have anything to decide about, and for the wizard's invite
|
|
487
|
+
// step to have a project to invite into while standup is still running.
|
|
488
|
+
await catalogBridge.publishInstanceToCatalog(pool, instance).catch(() => {});
|
|
482
489
|
// A re-request cannot change an existing instance's domain — SAY so instead of
|
|
483
490
|
// silently dropping it (task 1002692); the domain route is the real path.
|
|
484
491
|
let domainIgnored;
|
|
@@ -520,14 +527,13 @@ module.exports = function provisioningRoutes() {
|
|
|
520
527
|
hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
|
|
521
528
|
};
|
|
522
529
|
}
|
|
523
|
-
// And the same for the picker's module set (task 1002339). Like
|
|
524
|
-
// and unlike
|
|
525
|
-
//
|
|
526
|
-
// than naming a way back that does not exist.
|
|
530
|
+
// And the same for the picker's module set (task 1002339). Like detail —
|
|
531
|
+
// and unlike `type` — this one HAS a way back since BV1.R23 (task 1002342),
|
|
532
|
+
// so the receipt names it instead of leaving the owner to hunt for one.
|
|
527
533
|
let modulesIgnored;
|
|
528
534
|
if (!created && body.modules !== undefined) {
|
|
529
535
|
modulesIgnored = {
|
|
530
|
-
hint:
|
|
536
|
+
hint: `a re-request never changes an existing instance's module selection — PATCH /provisioning/instances/${instance.id}/detail with { modules } instead`,
|
|
531
537
|
};
|
|
532
538
|
}
|
|
533
539
|
res.status(created ? 201 : 200).json({
|
|
@@ -653,9 +659,9 @@ module.exports = function provisioningRoutes() {
|
|
|
653
659
|
|
|
654
660
|
// ── The optional project detail (task 1002334, goal 1000046) ──────────────
|
|
655
661
|
// PATCH /provisioning/instances/:id/detail — answer (or re-answer) the
|
|
656
|
-
//
|
|
657
|
-
// { description, detail }. rank: any authenticated builder (own
|
|
658
|
-
// archon may change any — same gate as settings).
|
|
662
|
+
// wizard's OPTIONAL creation steps after creation. Body: any subset of
|
|
663
|
+
// { description, detail, modules }. rank: any authenticated builder (own
|
|
664
|
+
// resource; archon may change any — same gate as settings).
|
|
659
665
|
//
|
|
660
666
|
// THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the
|
|
661
667
|
// project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is
|
|
@@ -668,19 +674,31 @@ module.exports = function provisioningRoutes() {
|
|
|
668
674
|
// answers live on the platform row until a catalog row materializes (the
|
|
669
675
|
// provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that
|
|
670
676
|
// reads `description` to compute publishability and name what is still missing.
|
|
677
|
+
//
|
|
678
|
+
// `modules` rides the SAME route for the same reason (BV1.R23, task 1002342,
|
|
679
|
+
// ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs
|
|
680
|
+
// the same way back, and it lands on the same platform row under the same
|
|
681
|
+
// ownership gate. Only an ARRAY is a change — an omitted or null key leaves the
|
|
682
|
+
// column alone, so the "never asked" NULL (ADR 0240 §2) can never be written by
|
|
683
|
+
// a later edit. The always-on core stays unremovable by construction:
|
|
684
|
+
// badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
|
|
671
685
|
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
|
|
672
686
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
673
687
|
if (!id) return;
|
|
674
688
|
if (validateOrRespond(req, res, {
|
|
675
689
|
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
676
690
|
detail: { type: 'object' },
|
|
691
|
+
modules: { type: 'array', itemsType: 'string', maxItems: 50 },
|
|
677
692
|
})) return;
|
|
678
693
|
const body = req.body || {};
|
|
679
694
|
const detailFault = badProjectDetail(body.detail);
|
|
680
695
|
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
681
|
-
|
|
696
|
+
const modulesFault = badModuleSelection(body.modules);
|
|
697
|
+
if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
|
|
698
|
+
const wantsModules = Array.isArray(body.modules);
|
|
699
|
+
if (body.description === undefined && body.detail === undefined && !wantsModules) {
|
|
682
700
|
return res.fail('bad_detail', { status: 400,
|
|
683
|
-
message: 'The body carries nothing to change — send description, detail, or
|
|
701
|
+
message: 'The body carries nothing to change — send description, detail, modules, or any mix.' });
|
|
684
702
|
}
|
|
685
703
|
try {
|
|
686
704
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
@@ -692,12 +710,18 @@ module.exports = function provisioningRoutes() {
|
|
|
692
710
|
const patch = {};
|
|
693
711
|
if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
|
|
694
712
|
if (body.detail !== undefined) patch.detail = body.detail;
|
|
713
|
+
if (wantsModules) patch.modules = body.modules;
|
|
695
714
|
const updated = await provisioning.updateInstanceDetail(pool, id, patch);
|
|
696
715
|
await provisioning.recordEvent(pool, {
|
|
697
716
|
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
|
|
698
717
|
detail: `project detail changed: ${Object.keys(patch).join(', ')}`,
|
|
699
718
|
actor,
|
|
700
719
|
}).catch(() => {});
|
|
720
|
+
// Re-take the catalog projection (task 1003576, ADR 0246) — this is THE write the
|
|
721
|
+
// R18 proof caught going nowhere. A description added here has to reach the map, and
|
|
722
|
+
// one CLEARED here has to leave it, which is why the hub assigns `description`
|
|
723
|
+
// rather than COALESCEing it.
|
|
724
|
+
await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
|
|
701
725
|
res.json({
|
|
702
726
|
ok: true,
|
|
703
727
|
description: updated.description || null,
|
|
@@ -809,6 +833,11 @@ module.exports = function provisioningRoutes() {
|
|
|
809
833
|
detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
|
810
834
|
actor,
|
|
811
835
|
}).catch(() => {});
|
|
836
|
+
// The door the owner just set is what the map draws this project's orb from
|
|
837
|
+
// (planet / star / black hole), so the catalog projection is re-taken here as well
|
|
838
|
+
// as on the detail write (task 1003576, ADR 0246). Unlike the push below this
|
|
839
|
+
// needs no restart and no reachable shape — it is the PLATFORM's own row.
|
|
840
|
+
await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
|
|
812
841
|
}
|
|
813
842
|
// The push (task 1003140). The row is now the platform's truth, but a
|
|
814
843
|
// RUNNING instance reads its settings from web.env through a pack memoized
|
|
@@ -1054,7 +1083,12 @@ module.exports = function provisioningRoutes() {
|
|
|
1054
1083
|
// Record BEFORE enqueueing: the runner reads the row's domain, so the intent
|
|
1055
1084
|
// must never race ahead of the column it wires. The release queue rides the
|
|
1056
1085
|
// SAME statement — see updateInstanceDomain.
|
|
1057
|
-
await provisioning.updateInstanceDomain(pool, id, domain, releasing);
|
|
1086
|
+
const rewired = await provisioning.updateInstanceDomain(pool, id, domain, releasing);
|
|
1087
|
+
// The catalog is keyed on ORIGIN, so an address change moves the project's row (task
|
|
1088
|
+
// 1003576, ADR 0246). `retireOrigin` takes the abandoned one off the map in the same
|
|
1089
|
+
// call — a row left `active` at a hostname the project no longer answers on is the
|
|
1090
|
+
// ADR 0181 §2 leak, in the catalog instead of in DNS.
|
|
1091
|
+
await catalogBridge.publishInstanceToCatalog(pool, rewired, { retireOrigin: current }).catch(() => {});
|
|
1058
1092
|
let queued = false;
|
|
1059
1093
|
if (inst.status === 'active') {
|
|
1060
1094
|
const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
|
|
@@ -263,24 +263,27 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
|
|
|
263
263
|
console.log('\nPer-project settings (task 1003040):');
|
|
264
264
|
|
|
265
265
|
t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
|
|
266
|
-
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
267
|
-
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
268
|
-
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
|
|
269
269
|
assert.deepEqual(
|
|
270
270
|
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
-
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full' },
|
|
272
272
|
'a stored key overlays only itself'
|
|
273
273
|
);
|
|
274
274
|
assert.deepEqual(
|
|
275
|
-
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted' } }),
|
|
276
|
-
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
|
|
275
|
+
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted', join_grant: 'everything' } }),
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' },
|
|
277
277
|
'off-vocabulary values fall back to the default; unknown keys never leak out'
|
|
278
278
|
);
|
|
279
279
|
assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
|
|
280
|
-
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'a non-object settings value reads as defaults');
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'a non-object settings value reads as defaults');
|
|
281
281
|
// the join door reads back like any other policy key (task 1002324)
|
|
282
282
|
assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
|
|
283
|
-
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
|
|
283
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' }, 'a stored door overlays only itself');
|
|
284
|
+
// what a public join grants is a policy key like any other (task 1002331)
|
|
285
|
+
assert.deepEqual(P.effectiveSettings({ settings: { join_grant: 'view' } }),
|
|
286
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view' }, 'the grant overlays only itself');
|
|
284
287
|
});
|
|
285
288
|
|
|
286
289
|
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|