@bongos/core 1.19.429 → 1.19.430

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.
@@ -124,6 +124,26 @@ const SETTINGS_VOCAB = Object.freeze({
124
124
  });
125
125
  const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
126
126
 
127
+ // The project's PLANET (task 1003347, direction record §16): how the platform
128
+ // DRAWS it on the sky, in the owner's list and on the card — its template
129
+ // (shape × material × style, the thirty-six names platform-identity's
130
+ // PROJECT_TEMPLATES pins; mirrored here so provisioning never imports the hub's
131
+ // module, the PROJECT_TYPES precedent) and its accent (one of the world's five
132
+ // TOKEN NAMES — a name, never a colour: the Fifteen Rule). Stored in the same
133
+ // jsonb `settings` column, but deliberately NOT in SETTINGS_VOCAB: a setting is a
134
+ // policy the instance obeys (it rides web.env and a restart applies it); the
135
+ // planet is the platform's to draw, so it has no default (null = the platform's
136
+ // own pick: a curated template, else the walk), never rides the env transport,
137
+ // and never restarts anything.
138
+ const PLANET_SHAPES = ['blob', 'pair', 'bead'];
139
+ const PLANET_MATERIALS = ['frost', 'gold', 'obsidian'];
140
+ const PLANET_TEMPLATES = Object.freeze([
141
+ ...PLANET_SHAPES.flatMap((s) => ['tint', 'wash', 'chrome'].map((y) => `${s}-${y}`)),
142
+ ...PLANET_SHAPES.flatMap((s) => PLANET_MATERIALS.flatMap((m) => ['tint', 'wash', 'plain'].map((y) => `${s}-${m}-${y}`))),
143
+ ]);
144
+ const PLANET_ACCENTS = Object.freeze(['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
145
+ const PLANET_VOCAB = Object.freeze({ planet_template: PLANET_TEMPLATES, planet_accent: PLANET_ACCENTS });
146
+
127
147
  // The optional project-detail interview (BV1.R15, task 1002334) — the "tell us
128
148
  // about your project" step every creation form offers and every owner may skip.
129
149
  // The interview asks two things, and the vocabulary is the contract for the
@@ -285,6 +305,10 @@ function publicInstance(row) {
285
305
  // instances so a declaration shows before the catalog carries it. Nullable:
286
306
  // rows predating provisioning_007, or a declined answer, project null.
287
307
  type: row.type || null,
308
+ // The owner's planet (task 1003347): how the platform draws this project —
309
+ // null = the platform's own pick. Public by nature (the sky shows it), so
310
+ // the list and the manage page read it off the row, no second read.
311
+ ...planetChoice(row),
288
312
  // The optional project-detail interview (task 1002334): what the owner said
289
313
  // they are building, and the answers that feed R17's recommendations. Both
290
314
  // are legitimately empty — the step is skippable by design — and `description`
@@ -366,6 +390,18 @@ function effectiveSettings(row) {
366
390
  return out;
367
391
  }
368
392
 
393
+ // The owner's planet choice as READ (task 1003347): each key its stored
394
+ // on-vocabulary value or null — unset, or a name that has since left the
395
+ // vocabulary, which never leaks. PURE; the shape is constant so a wire never
396
+ // drops a key.
397
+ function planetChoice(row) {
398
+ const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
399
+ ? row.settings : {};
400
+ const out = {};
401
+ for (const [key, vocab] of Object.entries(PLANET_VOCAB)) out[key] = vocab.includes(stored[key]) ? stored[key] : null;
402
+ return out;
403
+ }
404
+
369
405
  // A description as it may be STORED: trimmed, collapsed of runaway whitespace,
370
406
  // capped, and empty-means-absent. Returns null for anything that isn't real prose,
371
407
  // because null is what leaves the project dark-matter — an empty string stored in a
@@ -1033,6 +1069,30 @@ async function instanceStateForSlug(db, slug) {
1033
1069
  };
1034
1070
  }
1035
1071
 
1072
+ // The `provisioning.projectPlanets` port (task 1003347): the owners' planet
1073
+ // choices for a set of HOSTS (the catalog's origins, host-matched — the join the
1074
+ // hub's sky makes), so platform-identity's public featured feed can carry them
1075
+ // without importing this module. The newest row per host wins; a host with no
1076
+ // row, or no choice, is absent. Read-only.
1077
+ async function planetChoicesForHosts(db, hosts) {
1078
+ const wanted = [...new Set((hosts || []).map((h) => String(h || '').toLowerCase()).filter(Boolean))];
1079
+ if (!wanted.length) return {};
1080
+ const { rows } = await db.query(
1081
+ `SELECT lower(domain) AS host, settings
1082
+ FROM provisioning_instances
1083
+ WHERE domain IS NOT NULL AND lower(domain) = ANY($1)
1084
+ ORDER BY updated_at DESC`,
1085
+ [wanted],
1086
+ );
1087
+ const out = {};
1088
+ for (const r of rows) {
1089
+ if (out[r.host]) continue;
1090
+ const choice = planetChoice(r);
1091
+ if (choice.planet_template || choice.planet_accent) out[r.host] = choice;
1092
+ }
1093
+ return out;
1094
+ }
1095
+
1036
1096
  // Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
1037
1097
  // instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
1038
1098
  async function costLedger(db, { limit = 500 } = {}) {
@@ -1066,9 +1126,10 @@ module.exports = {
1066
1126
  RESTART_FROM, RESTART_SHAPES,
1067
1127
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
1068
1128
  SETTINGS_VOCAB, DEFAULT_SETTINGS, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
1129
+ PLANET_TEMPLATES, PLANET_ACCENTS, PLANET_VOCAB,
1069
1130
  // pure
1070
1131
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
1071
- defaultDomainForSlug, isReservedHostname, effectiveSettings,
1132
+ defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
1072
1133
  normalizeDescription, normalizeProjectDetail, projectDetail,
1073
1134
  // pure — GitHub App Manifest flow (task 2080)
1074
1135
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
@@ -1080,7 +1141,7 @@ module.exports = {
1080
1141
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
1081
1142
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
1082
1143
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1083
- recordEvent, instanceStateForSlug, costLedger,
1144
+ recordEvent, instanceStateForSlug, planetChoicesForHosts, costLedger,
1084
1145
  // data access — GitHub App Manifest attempts (task 2080)
1085
1146
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
1086
1147
  getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
@@ -253,9 +253,13 @@ function callbackPage({ ok, title, body, state, domain }) {
253
253
  // • PROVIDES port `provisioning.instanceState` (slug) → { status, … } | null.
254
254
  // Lets other code read an instance's provisioning status via
255
255
  // resolveOptional without importing this module (box.hasEverConnected pattern).
256
+ // • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
257
+ // planet_accent } } — the owners' planet choices (task 1003347), so the hub's
258
+ // public featured feed can carry them without importing this module.
256
259
  function registerProvisioningSeams() {
257
260
  if (api.hasProvider('provisioning.instanceState')) return;
258
261
  api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
262
+ api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
259
263
  }
260
264
 
261
265
  // Project an instance row for the fleet view — the owner identity + the same
@@ -764,7 +768,10 @@ module.exports = function provisioningRoutes() {
764
768
  try {
765
769
  const inst = await provisioning.getInstanceById(pool, id);
766
770
  if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
767
- res.json({ ok: true, settings: provisioning.effectiveSettings(inst), options: provisioning.SETTINGS_VOCAB });
771
+ // `planet` rides beside `settings` (task 1003347): the same row, read by a
772
+ // different rule — no defaults, null = the platform's own pick.
773
+ res.json({ ok: true, settings: provisioning.effectiveSettings(inst), planet: provisioning.planetChoice(inst),
774
+ options: { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB } });
768
775
  } catch (err) {
769
776
  log.error('[provisioning] GET /provisioning/instances/:id/settings', err);
770
777
  res.fail('settings_read_failed', { status: 500, message: 'internal error' });
@@ -792,14 +799,18 @@ module.exports = function provisioningRoutes() {
792
799
  if (validateOrRespond(req, res, {
793
800
  platform_visibility: { type: 'string', enum: provisioning.SETTINGS_VOCAB.platform_visibility },
794
801
  joinability: { type: 'string', enum: provisioning.SETTINGS_VOCAB.joinability },
802
+ // the planet (task 1003347): nullable on purpose — null hands the pick back to the platform
803
+ planet_template: { type: 'string', enum: provisioning.PLANET_VOCAB.planet_template },
804
+ planet_accent: { type: 'string', enum: provisioning.PLANET_VOCAB.planet_accent },
795
805
  })) return;
806
+ const settingKeys = [...Object.keys(provisioning.SETTINGS_VOCAB), ...Object.keys(provisioning.PLANET_VOCAB)];
796
807
  const patch = {};
797
- for (const key of Object.keys(provisioning.SETTINGS_VOCAB)) {
808
+ for (const key of settingKeys) {
798
809
  if ((req.body || {})[key] !== undefined) patch[key] = req.body[key];
799
810
  }
800
811
  if (!Object.keys(patch).length) {
801
812
  return res.fail('bad_settings', { status: 400,
802
- message: `The body carries no settings to change — send any of: ${Object.keys(provisioning.SETTINGS_VOCAB).join(', ')}.` });
813
+ message: `The body carries no settings to change — send any of: ${settingKeys.join(', ')}.` });
803
814
  }
804
815
  try {
805
816
  const inst = await provisioning.getInstanceById(pool, id);
@@ -811,8 +822,17 @@ module.exports = function provisioningRoutes() {
811
822
  // whole-form save or a retried request must not bounce a live project. The
812
823
  // one exception is the owner re-kick (the domain route's F19 shape): the same
813
824
  // values re-sent while a retired push left its error_note re-queue the push.
814
- const before = provisioning.effectiveSettings(inst);
825
+ const before = { ...provisioning.effectiveSettings(inst), ...provisioning.planetChoice(inst) };
815
826
  const changed = Object.entries(patch).some(([k, v]) => before[k] !== v);
827
+ // The planet is the platform's to draw (task 1003347): a change to it
828
+ // alone is applied the moment it is read back — the sky, the list and the
829
+ // card repaint from the row — so it queues no push and restarts nothing.
830
+ // Only a POLICY key (SETTINGS_VOCAB) reaches the instance, and only a
831
+ // policy key re-sent over a retired push's error_note re-kicks that push.
832
+ const policyKeys = Object.keys(provisioning.SETTINGS_VOCAB);
833
+ const policyTouched = policyKeys.some((k) => patch[k] !== undefined);
834
+ const policyChanged = policyKeys.some((k) => patch[k] !== undefined && before[k] !== patch[k]);
835
+ const policyResent = !!inst.error_note && policyTouched;
816
836
  const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
817
837
  if (changed) {
818
838
  await provisioning.recordEvent(pool, {
@@ -834,8 +854,10 @@ module.exports = function provisioningRoutes() {
834
854
  const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
835
855
  const receipt = (fields) => ({ reachable, ...fields });
836
856
  let push;
837
- if (!changed && !inst.error_note) {
857
+ if (!changed && !policyResent) {
838
858
  push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
859
+ } else if (!policyChanged && !policyResent) {
860
+ push = receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' });
839
861
  } else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
840
862
  const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
841
863
  push = receipt({ queued: false, restart: false, message: offline
@@ -867,7 +889,7 @@ module.exports = function provisioningRoutes() {
867
889
  : 'Saved. A restart is already queued for this project and will carry this change.' });
868
890
  }
869
891
  }
870
- res.json({ ok: true, saved: provisioning.effectiveSettings(updated), push });
892
+ res.json({ ok: true, saved: provisioning.effectiveSettings(updated), planet: provisioning.planetChoice(updated), push });
871
893
  } catch (err) {
872
894
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
873
895
  res.fail('settings_update_failed', { status: 500, message: 'internal error' });
@@ -330,6 +330,40 @@ t('projectDetail: a stored value that has since LEFT the vocabulary never leaks
330
330
  assert.deepEqual(P.projectDetail(null), {});
331
331
  });
332
332
 
333
+ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJECT_TEMPLATES name for name; the accents are the world\'s five token names', () => {
334
+ const pi = require('../../platform-identity/platform-identity.js');
335
+ assert.deepEqual([...P.PLANET_TEMPLATES], [...pi.PROJECT_TEMPLATES], 'the two modules never import each other — the mirror is pinned instead (the PROJECT_TYPES precedent)');
336
+ assert.equal(P.PLANET_TEMPLATES.length, 36);
337
+ assert.deepEqual([...P.PLANET_ACCENTS], ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
338
+ for (const a of P.PLANET_ACCENTS) assert.doesNotMatch(a, /^#|rgb|hsl/, 'a name, never a colour (the Fifteen Rule)');
339
+ assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent']);
340
+ // the deliberate asymmetry: the planet is NOT a setting — no default, never in SETTINGS_VOCAB
341
+ for (const k of Object.keys(P.PLANET_VOCAB)) {
342
+ assert.ok(!(k in P.SETTINGS_VOCAB), `${k} is not a policy the instance obeys`);
343
+ assert.ok(!(k in P.DEFAULT_SETTINGS), `${k} has no default — null is the platform's own pick`);
344
+ }
345
+ });
346
+
347
+ t('planetChoice: null for a bare row or an unset key; a stored on-vocabulary value reads back; junk reads null, never leaks', () => {
348
+ assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null });
349
+ assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null });
350
+ assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null });
351
+ assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' });
352
+ assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null });
353
+ assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null });
354
+ });
355
+
356
+ t('publicInstance carries the planet — both keys, null when unset — so the list and the manage page read it off the row', () => {
357
+ const chosen = P.publicInstance({ id: 1, slug: 'x', status: 'active', settings: { planet_template: 'bead-gold-tint', planet_accent: 'sea' } });
358
+ assert.equal(chosen.planet_template, 'bead-gold-tint');
359
+ assert.equal(chosen.planet_accent, 'sea');
360
+ const unset = P.publicInstance({ id: 2, slug: 'y', status: 'active' });
361
+ assert.ok('planet_template' in unset && 'planet_accent' in unset, 'the keys are always present');
362
+ assert.equal(unset.planet_template, null);
363
+ assert.equal(unset.planet_accent, null);
364
+ assert.ok(!('settings' in unset), 'the raw jsonb never rides the projection');
365
+ });
366
+
333
367
  t('PROJECT_DETAIL_VOCAB carries NO defaults — an unanswered question stays unanswered', () => {
334
368
  // The deliberate asymmetry with SETTINGS_VOCAB: a setting is a policy the
335
369
  // instance must obey, so it needs a default; a detail answer is something the
@@ -1 +1 @@
1
- {"_":"The hub's catalog (task 1003345): the landing's featured.json plus Tidewater, an all-null row nobody owns — the card-floor state opens ITS card, because Demo is owned by the signed-in stub (provisioning-instances.json) and an owned orb's plain click opens the manage view, never the card. Served under the name \"featured\" by projects.states.json's stub.fixtures alias; the landing keeps featured.json.","projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","template":"bead-wash","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"template":"pair-gold-wash","created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"template":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null},{"origin":"https://tidewater.cloudbongos.com","name":"Tidewater","tagline":"A project that has declared nothing yet.","art_url":null,"type":null,"template":null,"created_at":"2026-07-20T08:00:00.000Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null}]}
1
+ {"_":"The hub's catalog (task 1003345): the landing's featured.json plus Tidewater, an all-null row nobody owns — the card-floor state opens ITS card, because Demo is owned by the signed-in stub (provisioning-instances.json) and an owned orb's plain click opens the manage view, never the card. Served under the name \"featured\" by projects.states.json's stub.fixtures alias; the landing keeps featured.json.","projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","template":"bead-wash","planet_template":null,"planet_accent":null,"created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"template":"pair-gold-wash","planet_template":"pair-obsidian-tint","planet_accent":"sea","created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"template":null,"planet_template":null,"planet_accent":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null},{"origin":"https://tidewater.cloudbongos.com","name":"Tidewater","tagline":"A project that has declared nothing yet.","art_url":null,"type":null,"template":null,"planet_template":null,"planet_accent":null,"created_at":"2026-07-20T08:00:00.000Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null}]}
@@ -1 +1 @@
1
- {"projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","template":"bead-wash","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"template":"pair-gold-wash","created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"template":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null}]}
1
+ {"projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","template":"bead-wash","planet_template":null,"planet_accent":null,"created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"template":"pair-gold-wash","planet_template":"pair-obsidian-tint","planet_accent":"sea","created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"template":null,"planet_template":null,"planet_accent":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null}]}
@@ -22,7 +22,7 @@
22
22
  {
23
23
  "id": 102,
24
24
  "slug": "harbor",
25
- "status": "provisioning",
25
+ "status": "provisioning", "planet_template": "blob-frost-tint", "planet_accent": "sun-lit",
26
26
  "domain": null,
27
27
  "target_ref": "builder/harbor",
28
28
  "hosting_shape": "standalone",
@@ -133,11 +133,33 @@ const FLOW_RECEIPTS = {
133
133
  // after a receipt (doDelete → loadManage), and what it must then see is the queued intent —
134
134
  // the same open_intent shape the route stamps — not the untouched fixture row
135
135
  const ASKED = new Map();
136
+ // the planet picker (task 1003347): a settings PATCH that carries planet_* is REMEMBERED for
137
+ // the run — the page repaints from the row it re-reads, so the row must carry the choice; a
138
+ // PATCH with only planet keys gets the route's no-restart receipt, a policy key the push
139
+ const PLANETS = new Map();
140
+ const PLANET_KEYS = ['planet_template', 'planet_accent'];
141
+ const NO_PLANET = { planet_template: null, planet_accent: null };
142
+ function readBody(req, cb) {
143
+ let d = '';
144
+ req.on('data', (c) => { d += c; });
145
+ req.on('end', () => { let b = {}; try { b = JSON.parse(d || '{}'); } catch { b = {}; } cb(b); });
146
+ }
147
+ function settingsPatch(res, id, body) {
148
+ if (PLANET_KEYS.some((k) => k in body)) PLANETS.set(String(id), { planet_template: body.planet_template || null, planet_accent: body.planet_accent || null });
149
+ const policy = ['platform_visibility', 'joinability'].some((k) => k in body);
150
+ const push = policy ? FLOW_RECEIPTS.settings.push
151
+ : { queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' };
152
+ return json(res, 200, { ok: true, saved: FLOW_RECEIPTS.settings.settings, planet: PLANETS.get(String(id)) || NO_PLANET, push });
153
+ }
154
+ // the row carries its planet: a saved pick for the run, else the fixture's own (or null)
155
+ function withPlanet(inst) {
156
+ const saved = PLANETS.get(String(inst.id));
157
+ return { ...inst, planet_template: inst.planet_template || null, planet_accent: inst.planet_accent || null, ...(saved || {}) };
158
+ }
136
159
  function flowReceipt(rest, method) {
137
160
  if (method === 'POST' && rest === '/provisioning/instances') return FLOW_RECEIPTS.provision;
138
- const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain|settings)$/);
161
+ const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain)$/);
139
162
  if (!m) return null;
140
- if (m[2] === 'settings') return method === 'PATCH' ? FLOW_RECEIPTS.settings : null;
141
163
  if (method !== 'POST') return null;
142
164
  if (m[2] === 'teardown' || m[2] === 'restart') ASKED.set(m[1], m[2]);
143
165
  return FLOW_RECEIPTS[m[2]];
@@ -151,11 +173,11 @@ function instances(res, id, settings) {
151
173
  if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
152
174
  const file = path.join(FIXTURES, (ALIAS[name] || name) + '.json');
153
175
  if (!fs.existsSync(file)) return json(res, 404, { error: { code: 'no_fixture', message: name } });
154
- const list = stamp((JSON.parse(fs.readFileSync(file, 'utf8')).instances || []).map(withAsk));
176
+ const list = stamp((JSON.parse(fs.readFileSync(file, 'utf8')).instances || []).map(withAsk).map(withPlanet));
155
177
  if (id === null) return json(res, 200, { instances: list });
156
178
  const inst = list.find((i) => String(i.id) === String(id));
157
179
  if (!inst) return json(res, 404, { error: { code: 'not_found', message: 'no such instance' } });
158
- if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open' }, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] } });
180
+ if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] } });
159
181
  return json(res, 200, { instance: inst, open_intent: inst.open_intent || null });
160
182
  }
161
183
  // A project's art in the featured fixture may be root-relative (the surface's
@@ -215,6 +237,8 @@ const server = http.createServer((req, res) => {
215
237
  // (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
216
238
  // attach / restart / teardown / revive / join end to end; nothing changes in the fixture
217
239
  if (req.method !== 'GET') {
240
+ const ps = rest.match(/^\/provisioning\/instances\/(\d+)\/settings$/);
241
+ if (ps && req.method === 'PATCH') return AUTH ? readBody(req, (body) => settingsPatch(res, ps[1], body)) : json(res, 401, UNAUTH);
218
242
  const flow = flowReceipt(rest, req.method);
219
243
  if (flow) return AUTH ? json(res, 200, flow) : json(res, 401, UNAUTH);
220
244
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.429",
3
+ "version": "1.19.430",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.429",
9
+ "version": "1.19.430",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.429",
3
+ "version": "1.19.430",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -1357,6 +1357,29 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1357
1357
  #view-manage .visOpt:has(input:checked) b::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
1358
1358
  #view-manage .visOpt:focus-within{outline:2px solid var(--accent);outline-offset:3px;}
1359
1359
  #view-manage #mVisRuns b,#view-manage #mJoinRuns b{color:var(--ok);font-weight:500;}
1360
+ /* ── its planet (task 1003347; direction record §16, v1 the bands): the preview
1361
+ planet left; four bands right — shape · material · style as hairline cells of
1362
+ one band, a word each, the chosen one carrying the dot; the colour as five
1363
+ swatches, the chosen one ringed by a hairline of the ink. The cells outrank
1364
+ the section's generic radiogroup rule by their class. ── */
1365
+ #view-manage .pick{display:grid;grid-template-columns:168px 1fr;gap:0 34px;align-items:start;}
1366
+ #view-manage .pickPreview{display:grid;gap:12px;justify-items:center;padding-top:6px;}
1367
+ #view-manage .pickWords{margin:0;font-size:14px;font-weight:400;line-height:1.45;color:var(--ink);text-align:center;max-width:168px;}
1368
+ #view-manage .pickWords small{display:block;font-size:12.5px;color:var(--ink-soft);}
1369
+ #view-manage .pickBands{display:grid;border-bottom:1px solid var(--rule);}
1370
+ #view-manage .pickBand{display:grid;grid-template-columns:88px 1fr;gap:0 18px;align-items:center;border-top:1px solid var(--rule);}
1371
+ #view-manage .pickLabel{font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);padding:14px 0;}
1372
+ #view-manage .pickCells[role="radiogroup"]{display:grid;grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:1fr;border:0;}
1373
+ #view-manage .pickCell{position:relative;display:flex;align-items:center;gap:10px;min-height:48px;margin:0;padding:10px 16px;border:0;border-left:1px solid var(--rule-soft);border-radius:0;cursor:pointer;font-family:var(--font-body);font-weight:400;font-size:14.5px;color:var(--ink-soft);}
1374
+ #view-manage .pickCell::before{content:"";width:7px;height:7px;border-radius:50%;border:1px solid var(--rule);background:var(--bg);flex:none;}
1375
+ #view-manage .pickCell:hover,#view-manage .pickCell.on{color:var(--ink);}
1376
+ #view-manage .pickCell.on::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
1377
+ #view-manage .pickCell input,#view-manage .pickSw input,#view-manage .pickSw span{position:absolute;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);}
1378
+ #view-manage .pickCell:focus-within,#view-manage .pickSw:focus-within{outline:2px solid var(--accent);outline-offset:3px;}
1379
+ #view-manage .pickSwatches[role="radiogroup"]{display:flex;gap:14px;align-items:center;padding:12px 0 12px 16px;border:0;border-left:1px solid var(--rule-soft);}
1380
+ #view-manage .pickSw{position:relative;display:block;width:28px;height:28px;margin:0;padding:0;border-radius:50%;border:1px solid var(--rule);background:var(--sw);cursor:pointer;}
1381
+ #view-manage .pickSw.on{outline:1px solid var(--ink);outline-offset:3px;}
1382
+ #view-manage #mPick .actions{gap:14px;align-items:center;}
1360
1383
 
1361
1384
  /* this block stays HERE, after the rules it narrows: a same-specificity mobile
1362
1385
  override only wins by source order, so folding it into the hub's earlier
@@ -1382,6 +1405,20 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1382
1405
  #view-manage [role="radiogroup"]{grid-template-columns:1fr;}
1383
1406
  #view-manage .visOpt{border-left:0;padding-left:0;border-top:1px solid var(--rule-soft);}
1384
1407
  #view-manage .visOpt:first-child{border-top:0;}
1408
+ /* the picker stacks: the preview above; each band's label above its choices; the
1409
+ cells become the list's filter pills (the chosen one inverted to ink), so the
1410
+ four bands stay one screen */
1411
+ #view-manage .pick{grid-template-columns:1fr;gap:22px 0;}
1412
+ #view-manage .pickPreview{justify-items:start;}
1413
+ #view-manage .pickWords{text-align:left;max-width:none;}
1414
+ #view-manage .pickBand{grid-template-columns:1fr;gap:0;}
1415
+ #view-manage .pickLabel{padding:14px 0 6px;}
1416
+ #view-manage .pickCells[role="radiogroup"]{display:flex;flex-wrap:wrap;gap:8px;padding:0 0 14px;}
1417
+ #view-manage .pickCell{min-height:36px;padding:0 14px;border:1px solid var(--rule);border-radius:var(--radius-pill);gap:0;font-size:14px;}
1418
+ #view-manage .pickCell::before{display:none;}
1419
+ #view-manage .pickCell.on{background:var(--ink);color:var(--bg);border-color:var(--ink);}
1420
+ #view-manage .pickSwatches[role="radiogroup"]{padding:6px 0 14px;border-left:0;}
1421
+ #view-manage #mPick .actions{justify-content:flex-start;}
1385
1422
  }
1386
1423
  @media (prefers-reduced-motion:reduce){
1387
1424
  *,*::before,*::after{animation:none !important;transition:none !important;}