@bongos/core 1.20.61 → 1.20.62

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 (38) hide show
  1. package/.bongos-core.json +80 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +2 -0
  8. package/docs/api/openapi.json +96 -3
  9. package/docs/api-reference.md +4 -3
  10. package/docs/copy-inventory.md +372 -324
  11. package/docs/copy-registry.json +881 -417
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-inventory.json +4 -1
  14. package/docs/page-readings.json +544 -481
  15. package/docs/recipes/upgrading-the-core.md +30 -1
  16. package/modules/provisioning/demo.js +176 -0
  17. package/modules/provisioning/migrations/provisioning_035_demo.sql +26 -0
  18. package/modules/provisioning/module.json +5 -3
  19. package/modules/provisioning/pollers/demo-archive.js +61 -0
  20. package/modules/provisioning/provisioning.js +2 -2
  21. package/modules/provisioning/routes/demo.js +54 -0
  22. package/modules/provisioning/routes/provisioning.js +7 -4
  23. package/modules/public-landing/public/projects-demo.states.json +53 -0
  24. package/modules/public-landing/public/projects.html +300 -23
  25. package/modules/public-landing/public/projects.probes.json +3 -3
  26. package/modules/public-landing/public/projects.states.json +2 -1
  27. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +114 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +14 -0
  31. package/scripts/gds/update-channel.js +11 -2
  32. package/scripts/gds/update-sweep.js +717 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/update_channel_db.mjs +13 -6
  35. package/tests/update_subscription_engine.mjs +5 -5
  36. package/tests/update_sweep_home.mjs +100 -0
  37. package/tests/wizard_demo.mjs +264 -0
  38. package/tests/wizard_front_door.mjs +16 -20
@@ -90,7 +90,7 @@ The sections above are the *manual* `bongos upgrade`. The **subscription** runs
90
90
 
91
91
  A **major** is never automatic; prereleases are never auto-targeted.
92
92
 
93
- **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from its own `DATABASE_URL` / `PGDATABASE`, so the unit needs one of them set. Every run prints each entry's channel and its source.
93
+ **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from `UPDATE_CHANNEL_DB` (a db name or connection string), else `DATABASE_URL` / `PGDATABASE`. On a control plane, set `UPDATE_CHANNEL_DB`, never `PGDATABASE`: the sweep's env reaches every upgrade it spawns, so a tenant entry with no database of its own would migrate against the control plane's. Every run prints each entry's channel and its source.
94
94
 
95
95
  **Enrolling an instance** — add it to `config/update-subscriptions.json` (instance-side) (ships empty). `env` carries per-instance vars merged into the upgrade child — set `PGDATABASE` so `migrate` hits the right DB (see the Gotchas):
96
96
 
@@ -118,6 +118,35 @@ Each subscribed instance: read its installed core → ask the registry (`npm vie
118
118
 
119
119
  **Triple-gated, so nothing moves by accident:** the `core-update-subscription` routine is `requiresAutonomy: true` + **default OFF** in [`config/scheduled-routines.json`](../../config/scheduled-routines.json), the roster ships empty, and every upgrade is health-gated. Arm it by enabling the routine's timer on the control plane once autonomy is on.
120
120
 
121
+ #### Run the sweep from the installed core (task 1004307)
122
+
123
+ The sweep's body is `scripts/gds/update-sweep.js`, which ships in every core release.
124
+ `.claude/scheduled-tasks/core-update-subscription/subscribe.js` is only a shim. A control plane
125
+ should run the body **straight from the core it installs**, so each upgrade brings the next sweep
126
+ current code. A sweep run from a checkout is only as new as that checkout, and on cloudbongos.com
127
+ one sat frozen for a month while every fix to the lane went inert. The roster stays where it is;
128
+ `--instance` names it.
129
+
130
+ ```ini
131
+ # /etc/systemd/system/<instance>-core-update-subscription.service.d/run-from-core.conf
132
+ [Service]
133
+ ExecStart=
134
+ ExecStart=/usr/bin/node <served-root>/node_modules/@bongos/core/scripts/gds/update-sweep.js --apply --instance <roster-root>
135
+ Environment=UPDATE_CHANNEL_DB=<control-plane-db>
136
+ ```
137
+
138
+ `<served-root>` is the root the sweep keeps current (the roster entry for the control plane itself);
139
+ `<roster-root>` is where `config/update-subscriptions.json` lives. Prove it with a dry run as the
140
+ unit's user before restarting anything. Its first line names the core doing the sweeping, and each
141
+ entry's line names its channel and source:
142
+
143
+ ```bash
144
+ sudo -u <user> env UPDATE_CHANNEL_DB=<control-plane-db> node <served-root>/node_modules/@bongos/core/scripts/gds/update-sweep.js --instance <roster-root>
145
+ ```
146
+
147
+ Every run prints `update-sweep · running from <core dir> (core <version>) · roster <root>`, so
148
+ `journalctl -u <instance>-core-update-subscription.service` answers "is the box running current code?".
149
+
121
150
  #### How often it sweeps — the lag knob (task 1003674)
122
151
 
123
152
  **This timer's period IS the delay between a builder's merge and the live site
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/demo.js — THE DEMO ROUTE: training wheels, a soft time box, and
4
+ // "keep your planet?" (task 1004420 / BV2.PS09, goal 1000121; spec
5
+ // docs/specs/bongos-v2-project-startup.md D3 + D9; design of record
6
+ // docs/design/mocks/project-startup/DemoSetup|DemoReady|DemoKeep.dc.html).
7
+ //
8
+ // A DEMO IS A NORMAL PROJECT MARKED "DEMO" (D9). It is created by the same POST
9
+ // /provisioning/instances as any project, with one more field, and it builds in the
10
+ // normal hall. What makes it a demo is the `demo` jsonb column (provisioning_035):
11
+ // { people, hours_each, started_at, ends_at, outcome, settled_at, warned_at, offline_at }
12
+ // and the defaults the demo picks for its founder: Monarchy / BDFL is already every new
13
+ // project's government (the government module's default board), so the demo adds only
14
+ // "no rewards" and "all managed" as answers (`detail`), and "private to the team" as
15
+ // settings (the map door closed, joinable by invite only). Everyone uses their own AI
16
+ // plan, which is how every project already works.
17
+ //
18
+ // THE TIME BOX IS SOFT (D3). `ends_at` is the start plus the hours each person said they
19
+ // have, and it only ever drives a countdown and the "keep your planet?" question. Nothing
20
+ // reads it to block or freeze work.
21
+ //
22
+ // A DEMO NOBODY KEEPS GOES OFFLINE AFTER 30 DAYS (D9). The demo-archive poller takes
23
+ // it offline through the existing teardown path, so it is restorable the way any
24
+ // torn-down project is ("bring it back"), with the repo, database and backups kept. The
25
+ // owner is told first: its own project page shows the date from the start, and from
26
+ // three days before it says plainly that the demo is about to go offline. "Carry it over" makes it a real project and the clock never applies again;
27
+ // "keep it as a demo" settles the question but leaves the 30-day rule in place.
28
+ //
29
+ // No route or SQL lives here beyond the small reads and writes the demo needs, so the
30
+ // two oversized files (provisioning.js, routes/provisioning.js) each gain a line, not a
31
+ // feature.
32
+
33
+ const DEMO_LIMITS = Object.freeze({ people: Object.freeze([1, 12]), hours_each: Object.freeze([1, 40]) });
34
+ const DEMO_OFFLINE_DAYS = 30;
35
+ const DEMO_WARN_DAYS = 3;
36
+ const DAY_MS = 86400000;
37
+ // The demo's picked answers (DemoReady): no rewards, all managed by Bongos. An owner's
38
+ // own answer to either always wins.
39
+ const DEMO_DETAIL = Object.freeze({ reward_intents: Object.freeze([]), framing: 'managed' });
40
+ // Private to the team: off the public map, and joinable by invite only.
41
+ const DEMO_SETTINGS = Object.freeze({ visibility: 'private', joinability: 'invite_only' });
42
+ const OUTCOMES = Object.freeze(['carried', 'kept']);
43
+
44
+ const intIn = (v, [lo, hi]) => Number.isInteger(v) && v >= lo && v <= hi;
45
+
46
+ // parseDemo(raw) — PURE. The create request's `demo` field, or why it is refused.
47
+ function parseDemo(raw) {
48
+ if (raw === undefined || raw === null) return { ok: true, demo: null };
49
+ if (typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'demo must be an object: { people, hours_each }.' };
50
+ const extra = Object.keys(raw).filter((k) => !['people', 'hours_each'].includes(k));
51
+ if (extra.length) return { ok: false, reason: `Unknown demo field "${extra[0]}": a demo takes people and hours_each.` };
52
+ if (!intIn(raw.people, DEMO_LIMITS.people)) return { ok: false, reason: 'people is a whole number from 1 to 12, you included.' };
53
+ if (!intIn(raw.hours_each, DEMO_LIMITS.hours_each)) return { ok: false, reason: 'hours_each is a whole number from 1 to 40.' };
54
+ return { ok: true, demo: { people: raw.people, hours_each: raw.hours_each } };
55
+ }
56
+
57
+ // fitCheck(people, hours) — PURE. The setup screen's first read: builder-hours and what
58
+ // fits in them. The wizard carries a copy of these sentences, held equal by test.
59
+ function fitCheck(people, hours) {
60
+ const total = people * hours;
61
+ const verdict = total < 8 ? 'tight' : total < 20 ? 'small' : 'plenty';
62
+ const sentence = {
63
+ tight: 'Too tight for this idea. Try one list with voting only, or add hours.',
64
+ small: 'Enough for a small version. Trim it to one shared list, voting and a winner reveal; accounts and notifications will not fit.',
65
+ plenty: 'Plenty for this idea, with room for one extra feature.',
66
+ }[verdict];
67
+ return { total, verdict, sentence };
68
+ }
69
+
70
+ // withDemoDetail(detail) — PURE. The demo's picked answers under the owner's own.
71
+ function withDemoDetail(detail) {
72
+ const own = detail && typeof detail === 'object' && !Array.isArray(detail) ? detail : {};
73
+ return { ...DEMO_DETAIL, reward_intents: [], ...own };
74
+ }
75
+
76
+ // demoRecord(demo, now) — PURE. What the column holds at the start.
77
+ function demoRecord(demo, now = new Date()) {
78
+ return {
79
+ people: demo.people,
80
+ hours_each: demo.hours_each,
81
+ started_at: now.toISOString(),
82
+ ends_at: new Date(now.getTime() + demo.hours_each * 3600000).toISOString(),
83
+ outcome: null,
84
+ };
85
+ }
86
+
87
+ const ms = (v) => { const t = v ? new Date(v).getTime() : NaN; return Number.isFinite(t) ? t : NaN; };
88
+
89
+ // demoView(row, now) — PURE. The demo as an owner read shows it, or null for a project
90
+ // that is not one (or was carried over: it is a real project now).
91
+ function demoView(row, now = new Date()) {
92
+ const d = row && row.demo;
93
+ if (!d || typeof d !== 'object' || d.outcome === 'carried') return null;
94
+ const start = ms(d.started_at);
95
+ const end = ms(d.ends_at);
96
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
97
+ const offline = start + DEMO_OFFLINE_DAYS * DAY_MS;
98
+ return {
99
+ people: d.people,
100
+ hours_each: d.hours_each,
101
+ builder_hours: fitCheck(d.people, d.hours_each).total,
102
+ started_at: d.started_at,
103
+ ends_at: d.ends_at,
104
+ remaining_seconds: Math.max(0, Math.round((end - now.getTime()) / 1000)),
105
+ time_up: now.getTime() >= end,
106
+ outcome: OUTCOMES.includes(d.outcome) ? d.outcome : null,
107
+ offline_on: new Date(offline).toISOString(),
108
+ offline_soon: now.getTime() >= offline - DEMO_WARN_DAYS * DAY_MS,
109
+ };
110
+ }
111
+
112
+ // The writes. `db` is the pool (or a client); every write is one statement on the row.
113
+ async function startDemo(db, instanceId, demo, now = new Date()) {
114
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [instanceId, JSON.stringify(demoRecord(demo, now))]);
115
+ }
116
+
117
+ // startDemoProject — right after the create files a demo's row: mark it, and make it
118
+ // private to the team. Returns the updated row, so the catalog projection that follows
119
+ // sees the closed door rather than the public default for a moment. `provisioning` is
120
+ // passed in (this file must not require it: provisioning.js requires this one).
121
+ async function startDemoProject(db, provisioning, instanceId, demo, now = new Date()) {
122
+ await startDemo(db, instanceId, demo, now);
123
+ return provisioning.updateInstanceSettings(db, instanceId, { ...DEMO_SETTINGS });
124
+ }
125
+
126
+ // settleDemo — the "keep your planet?" answer. Carry it over: the marker's outcome says
127
+ // so and the project is a real one from here on. Keep it as a demo: the question is
128
+ // settled and the 30-day rule still applies. Answered once; a second answer is refused.
129
+ async function settleDemo(db, inst, outcome, now = new Date()) {
130
+ if (!OUTCOMES.includes(outcome)) return { ok: false, reason: 'bad_outcome' };
131
+ const d = inst && inst.demo;
132
+ if (!d || typeof d !== 'object') return { ok: false, reason: 'not_a_demo' };
133
+ if (d.outcome) return { ok: false, reason: 'already_settled' };
134
+ const next = { ...d, outcome, settled_at: now.toISOString() };
135
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [inst.id, JSON.stringify(next)]);
136
+ return { ok: true, demo: next };
137
+ }
138
+
139
+ // sweepDemos — the demo-archive poller's tick. For every demo nobody carried over that
140
+ // is still up: stamp the warning three days before (the project page says it is about to go),
141
+ // and at 30 days take it offline through the existing teardown path.
142
+ async function sweepDemos(db, { enqueueTeardown, recordEvent, now = new Date() } = {}) {
143
+ const { rows } = await db.query(
144
+ `SELECT id, owner_builder_id, demo, status FROM provisioning_instances
145
+ WHERE demo IS NOT NULL
146
+ AND COALESCE(demo->>'outcome', '') <> 'carried'
147
+ AND demo->>'offline_at' IS NULL
148
+ AND status NOT IN ('tearing_down', 'torn_down')`,
149
+ );
150
+ const out = { warned: 0, offline: 0 };
151
+ for (const row of rows) {
152
+ const start = ms(row.demo.started_at);
153
+ if (!Number.isFinite(start)) continue;
154
+ const offlineAt = start + DEMO_OFFLINE_DAYS * DAY_MS;
155
+ if (now.getTime() >= offlineAt) {
156
+ await enqueueTeardown(db, row.id, null);
157
+ const next = { ...row.demo, offline_at: now.toISOString() };
158
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [row.id, JSON.stringify(next)]);
159
+ if (recordEvent) {
160
+ await recordEvent(db, { instanceId: row.id, ownerBuilderId: row.owner_builder_id, event: 'demo-offline',
161
+ detail: { days: DEMO_OFFLINE_DAYS }, actor: 'system:demo-archive' }).catch(() => {});
162
+ }
163
+ out.offline += 1;
164
+ } else if (now.getTime() >= offlineAt - DEMO_WARN_DAYS * DAY_MS && !row.demo.warned_at) {
165
+ const next = { ...row.demo, warned_at: now.toISOString() };
166
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [row.id, JSON.stringify(next)]);
167
+ out.warned += 1;
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ module.exports = {
174
+ DEMO_LIMITS, DEMO_OFFLINE_DAYS, DEMO_WARN_DAYS, DEMO_DETAIL, DEMO_SETTINGS, OUTCOMES,
175
+ parseDemo, fitCheck, withDemoDetail, demoRecord, demoView, startDemo, startDemoProject, settleDemo, sweepDemos,
176
+ };
@@ -0,0 +1,26 @@
1
+ -- provisioning_035_demo.sql — the demo marker on a project (task 1004420, BV2.PS09; goal
2
+ -- 1000121, spec docs/specs/bongos-v2-project-startup.md D3 + D9).
3
+ --
4
+ -- WHY THIS EXISTS. The front door's "Try the demo" route creates a normal project marked
5
+ -- "demo" (D9): a small team tries Bongos in a few hours and keeps what they made. The mark
6
+ -- has to live on the project row, because the hub reads it for the countdown and the
7
+ -- "keep your planet?" question, and the demo-archive poller reads it to take a demo
8
+ -- nobody kept offline after 30 days.
9
+ --
10
+ -- WHAT IT ADDS.
11
+ -- provisioning_instances.demo — NULL for every ordinary project. For a demo:
12
+ -- { people, hours_each, started_at, ends_at, outcome, settled_at, warned_at, offline_at }
13
+ -- written at create (modules/provisioning/demo.js demoRecord), then by the "keep your
14
+ -- planet?" answer (outcome 'carried' | 'kept') and by the poller (warned_at, offline_at).
15
+ --
16
+ -- A column and not a `detail` key: detail is what the OWNER said, editable and
17
+ -- un-answerable by them; this is platform state an owner must not be able to erase to
18
+ -- dodge the archive. Nullable, no backfill: no project before this was a demo.
19
+ --
20
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
21
+
22
+ BEGIN;
23
+
24
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS demo jsonb;
25
+
26
+ COMMIT;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "key": "provisioning",
3
3
  "title": "Instance provisioning",
4
- "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state \u2014 it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
4
+ "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state — it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.14.0",
7
7
  "default": false,
@@ -20,13 +20,15 @@
20
20
  "env-manifest",
21
21
  "repo-private",
22
22
  "render-standup",
23
- "look"
23
+ "look",
24
+ "demo"
24
25
  ],
25
26
  "migrations": true,
26
27
  "pollers": [
27
28
  "liveness-sweep",
28
29
  "catalog-backfill",
29
- "app-liveness"
30
+ "app-liveness",
31
+ "demo-archive"
30
32
  ]
31
33
  },
32
34
  "provides": [
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/demo-archive.js — a demo nobody kept goes offline after 30
4
+ // days, and its owner is told first (task 1004420, BV2.PS09; spec D9).
5
+ //
6
+ // One sweep an hour over the demo-marked projects (demo.js sweepDemos): from three days
7
+ // before, a demo carries its offline date on its own card (the warning); at 30 days it is
8
+ // taken offline through the existing teardown intent, so it is restorable the way any
9
+ // torn-down project is. A demo carried over is a real project and is never swept.
10
+ // Kill-switched by PROVISIONING_DEMO_ARCHIVE_DISABLED=1; the liveness sweep's shape
11
+ // (self-rescheduling, jittered, unref'd, idempotent start).
12
+
13
+ const api = require('../../../src/module-api');
14
+ const provisioning = require('../provisioning');
15
+ const demo = require('../demo');
16
+
17
+ const log = api.logger('provisioning');
18
+
19
+ const INTERVAL_MS = 60 * 60 * 1000;
20
+ const FIRST_SWEEP_DELAY_MS = 5 * 60 * 1000;
21
+ const JITTER_MS = 5 * 60 * 1000;
22
+
23
+ let started = false;
24
+ let timer = null;
25
+
26
+ async function sweepOnce(deps = {}) {
27
+ const db = deps.pool || api.pool;
28
+ const out = await demo.sweepDemos(db, {
29
+ enqueueTeardown: deps.enqueueTeardown || provisioning.enqueueTeardown,
30
+ recordEvent: deps.recordEvent || provisioning.recordEvent,
31
+ now: deps.now,
32
+ });
33
+ if (out.warned || out.offline) log.info(`[demo-archive] warned ${out.warned}, took ${out.offline} offline`);
34
+ return out;
35
+ }
36
+
37
+ function arm(deps, delay) {
38
+ timer = setTimeout(() => {
39
+ sweepOnce(deps)
40
+ .catch((e) => log.error(`[demo-archive] sweep crashed: ${e && e.message ? e.message : e}`))
41
+ .then(() => { if (started) arm(deps, INTERVAL_MS + Math.floor(Math.random() * JITTER_MS)); });
42
+ }, delay);
43
+ if (timer.unref) timer.unref();
44
+ }
45
+
46
+ function start(deps = {}) {
47
+ if (process.env.PROVISIONING_DEMO_ARCHIVE_DISABLED === '1') {
48
+ log.info('[demo-archive] disabled (PROVISIONING_DEMO_ARCHIVE_DISABLED=1)');
49
+ return;
50
+ }
51
+ if (started) return;
52
+ started = true;
53
+ arm(deps, FIRST_SWEEP_DELAY_MS);
54
+ }
55
+
56
+ function stop() {
57
+ started = false;
58
+ if (timer) { clearTimeout(timer); timer = null; }
59
+ }
60
+
61
+ module.exports = { start, stop, sweepOnce, INTERVAL_MS };
@@ -23,7 +23,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
23
23
  // pure declarations beside this file, for the same reason: storage and the read
24
24
  // projection both have to read them.
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
- const { softLimitsFor } = require('./soft-limits');
26
+ const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); // the demo marker's owner view (task 1004420)
27
27
  const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look');
28
28
 
29
29
  // ---------------------------------------------------------------------------
@@ -450,7 +450,7 @@ function publicInstance(row) {
450
450
  detail,
451
451
  // Which startup options those answers hide, grey or annotate, each with its reason
452
452
  // (BV2.PS02, spec D6). Advice only — `enforced: false` says so on every read.
453
- soft_limits: softLimitsFor(detail),
453
+ soft_limits: softLimitsFor(detail), demo: demoView(row), // null unless a demo nobody carried over (task 1004420, ./demo.js)
454
454
  // The creation picker's module set (task 1002339): the always-on core, what
455
455
  // the owner ends up with, and whether that came from their own toggles or
456
456
  // from the type's starter bundle because they never answered. Resolved, not
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/routes/demo.js — "keep your planet?" (task 1004420, BV2.PS09).
4
+ //
5
+ // POST /provisioning/instances/:id/demo { outcome: 'carried' | 'kept' } — the owner's
6
+ // answer at the end of a demo. Carried over: the project is a real one from here on, and
7
+ // the 30-day archive never applies. Kept as a demo: settled, and the archive still does.
8
+ // The demo itself is started by the ordinary create (POST /provisioning/instances with a
9
+ // `demo` field), so this is the only route the demo adds. Owner-only, like every other
10
+ // per-project write here; answered once.
11
+
12
+ const express = require('express');
13
+ const api = require('../../../src/module-api');
14
+ const { pool, validateOrRespond, parseId } = api;
15
+ const provisioning = require('../provisioning');
16
+ const demo = require('../demo');
17
+ const { failFrom } = require('../public-refusal');
18
+
19
+ const log = api.logger('provisioning');
20
+
21
+ const REFUSALS = {
22
+ not_a_demo: [409, 'This project is not a demo.'],
23
+ already_settled: [409, 'This demo already has its answer.'],
24
+ bad_outcome: [400, 'outcome is carried or kept.'],
25
+ };
26
+
27
+ module.exports = function buildDemoRouter() {
28
+ const router = express.Router();
29
+
30
+ router.post('/provisioning/instances/:id/demo', api.requireBuilder, async (req, res) => {
31
+ if (validateOrRespond(req, res, {
32
+ outcome: { required: true, type: 'string', enum: [...demo.OUTCOMES] },
33
+ })) return;
34
+ const id = parseId(req, res);
35
+ if (!id) return;
36
+ try {
37
+ const inst = await provisioning.getInstanceById(pool, id);
38
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) return res.fail('instance_not_found', 404);
39
+ const r = await demo.settleDemo(pool, inst, req.body.outcome);
40
+ if (!r.ok) {
41
+ const [status, message] = REFUSALS[r.reason] || [409, r.reason];
42
+ return res.fail(r.reason, { status, message });
43
+ }
44
+ await provisioning.recordEvent(pool, { instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'demo-settled',
45
+ detail: { outcome: req.body.outcome }, actor: 'api:self' }).catch(() => {});
46
+ res.json({ ok: true, outcome: req.body.outcome, demo: demo.demoView({ demo: r.demo }) });
47
+ } catch (err) {
48
+ log.error('[provisioning] POST /provisioning/instances/:id/demo', err);
49
+ failFrom(res, err, 'demo_settle_failed');
50
+ }
51
+ });
52
+
53
+ return router;
54
+ };
@@ -61,7 +61,7 @@ const catalogBridge = require('../catalog-bridge');
61
61
  const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
62
62
  const { callbackPage } = require('./callback-page');
63
63
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
64
- const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators');
64
+ const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo');
65
65
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
66
66
  const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
67
67
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
@@ -324,7 +324,7 @@ module.exports = function provisioningRoutes() {
324
324
  // exactly the set the panel preselected. An explicit [] is the different
325
325
  // answer "none of them". maxItems is a sanity ceiling well above a catalog
326
326
  // that is three modules wide today.
327
- modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, // the latter: the wizard's terms tick (task 1004417, ../terms-ack.js)
327
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
328
328
  })) return;
329
329
  const body = req.body || {};
330
330
  const detailFault = badProjectDetail(body.detail);
@@ -333,6 +333,8 @@ module.exports = function provisioningRoutes() {
333
333
  if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
334
334
  const screened = screenedOut(body.description); // the stop screen (task 1004416): before anything exists
335
335
  if (screened) return res.fail('screened_out', screened);
336
+ const demoAsk = demo.parseDemo(body.demo); // a demo is a normal project marked demo, its picked answers under the owner's own (task 1004420)
337
+ if (!demoAsk.ok) return res.fail('bad_demo', { status: 400, message: demoAsk.reason });
336
338
  const slug = String(body.slug || '').trim().toLowerCase();
337
339
  if (!provisioning.isValidSlug(slug)) {
338
340
  return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
@@ -424,7 +426,7 @@ module.exports = function provisioningRoutes() {
424
426
  type: body.type,
425
427
  // The optional detail step's answers (task 1002334) — absent means the
426
428
  // owner skipped it, which is the supported default, not an error.
427
- description: body.description, detail: body.detail,
429
+ description: body.description, detail: demoAsk.demo ? demo.withDemoDetail(body.detail) : body.detail,
428
430
  // The picker's selection (task 1002339) — absent for the same reason and
429
431
  // with the same standing: skipped, and resolved from the bundle on read.
430
432
  modules: body.modules, termsAck: termsAcknowledgement(body),
@@ -456,7 +458,8 @@ module.exports = function provisioningRoutes() {
456
458
  // description exists — the publish gate alone decides that — but the row has to be
457
459
  // there for the gate to have anything to decide about, and for the wizard's invite
458
460
  // step to have a project to invite into while standup is still running.
459
- await catalogBridge.publishInstanceToCatalog(pool, instance).catch(() => {});
461
+ const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
462
+ await catalogBridge.publishInstanceToCatalog(pool, demoRow || instance).catch(() => {});
460
463
  // A re-request cannot change an existing instance's domain — SAY so instead of
461
464
  // silently dropping it (task 1002692); the domain route is the real path.
462
465
  let domainIgnored;
@@ -0,0 +1,53 @@
1
+ {
2
+ "_": "The demo route (task 1004420) on a demo project's own page: the time box has run out, so the Demo planet card asks keep your planet, with the offline date beside it. The page is projects.states.json's; only the instance read differs (provisioning-instances-demo.json).",
3
+ "page": "/projects",
4
+ "surface": "public-landing",
5
+ "stub": {
6
+ "fixtures": {
7
+ "provisioning-instances": "provisioning-instances-demo"
8
+ }
9
+ },
10
+ "modes": [
11
+ "dark"
12
+ ],
13
+ "modeQuery": false,
14
+ "ignoreRequests": [
15
+ "/api/(gds|bongos)/me$",
16
+ "/my-projects",
17
+ "/provisioning/instances/999",
18
+ "/provisioning/instances/101$",
19
+ "/api/bongos/instance$",
20
+ "/github/repos$",
21
+ "/provisioning/onboard-plan"
22
+ ],
23
+ "ignoreConsole": [
24
+ "/api/(gds|bongos)/me",
25
+ "\\b401\\b",
26
+ "\\b404\\b",
27
+ "Unauthorized"
28
+ ],
29
+ "states": {
30
+ "in-manage-demo": {
31
+ "auth": true,
32
+ "url": "/projects?view=manage&id=101",
33
+ "expect": {
34
+ "visible": [
35
+ "#mDemo",
36
+ "#mDemoClock",
37
+ "#mDemoKeep",
38
+ "#mDemoOffline"
39
+ ],
40
+ "text": [
41
+ [
42
+ "#mDemoKeep .demoKeepH",
43
+ "Keep your planet?"
44
+ ]
45
+ ]
46
+ }
47
+ }
48
+ },
49
+ "ignoreControls": [
50
+ ".skip",
51
+ ".vh"
52
+ ]
53
+ }