@bongos/core 1.19.142 → 1.19.144

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.
@@ -80,7 +80,7 @@ h1[tabindex="-1"]:focus{outline:none;}
80
80
  .wizField small{font-weight:500;color:var(--ink-faint);}
81
81
  #view-new input[type=text]{
82
82
  width:100%;font-family:var(--font-body);font-size:17px;font-weight:600;color:var(--ink);
83
- background:var(--bg-card);border:2px solid var(--line);border-radius:14px;padding:13px 16px;outline:none;
83
+ background:var(--bg-card);border:2px solid var(--ctl-line);border-radius:14px;padding:13px 16px;outline:none;
84
84
  transition:border-color .15s ease, box-shadow .15s ease;
85
85
  }
86
86
  /* no fade toward the card — a 70% mix washed placeholders to 2.89:1 (task 1002749) */
@@ -255,6 +255,10 @@ h1[tabindex="-1"]:focus{outline:none;}
255
255
  .addrConfirm.optedOut{border-color:var(--line);background:var(--bg);}
256
256
  .addrConfirm.optedOut .addrBig{color:var(--ink-soft);}
257
257
  .addrConfirm.optedOut>p{color:var(--ink-faint);}
258
+ /* the handle is not free (task 1002898 N10) — a refusal must not be painted into the
259
+ green confirmation box that means "yours" */
260
+ .addrConfirm.unavailable{border-color:color-mix(in srgb,var(--err) 45%,var(--bg-card));background:var(--bg);}
261
+ .addrConfirm.unavailable .addrBig{color:var(--err);}
258
262
  .addrBig{font-family:var(--font-display);font-weight:700;font-size:19px;letter-spacing:-0.3px;line-height:1.4;}
259
263
  .addrBig code{font-family:var(--font-mono);font-size:15px;background:var(--bg-deep);border-radius:8px;padding:2px 9px;color:var(--accent-deep);word-break:break-all;}
260
264
  .addrConfirm>p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.5;margin-top:6px;}
@@ -713,8 +717,14 @@ h1[tabindex="-1"]:focus{outline:none;}
713
717
  </section>
714
718
 
715
719
  <!-- step 4 · address — a CONFIRMATION, not a form (task 1002704: every project
716
- gets its slug subdomain assigned up front; opt-out lives in the disclosure).
717
- The domainless default was a proven dead-end (QA walk, task 1002702). -->
720
+ gets its slug subdomain assigned up front). The domainless default was a
721
+ proven dead-end (QA walk, task 1002702).
722
+ Three choices, all legible without opening anything (task 1002898 N8):
723
+ the assigned address is stated, and BOTH alternatives sit under it — the
724
+ bring-your-own domain behind its disclosure, the no-address opt-out in the
725
+ open. The opt-out used to live INSIDE the "domain I own" fold, i.e. the
726
+ choice for NO address was hidden under a fold labelled the opposite
727
+ choice; the owner read the step as "weird" and never found it. -->
718
728
  <section class="panel" id="panel-address" aria-labelledby="addr-h">
719
729
  <h1 id="addr-h">Its address in the sky</h1>
720
730
  <p class="lede">The handle names your project on the platform — and its address comes with it. Sign-in, sharing, everything just works from the moment it stands up.</p>
@@ -733,11 +743,11 @@ h1[tabindex="-1"]:focus{outline:none;}
733
743
  <input type="text" id="f-domain" placeholder="mercury.example.com" autocomplete="off" maxlength="253" spellcheck="false">
734
744
  <div class="hint" id="h-domain">&nbsp;</div>
735
745
  </label>
736
- <label class="addrOpt"><input type="checkbox" id="f-noaddr">
737
- <span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
738
- </label>
739
746
  </div>
740
747
  </details>
748
+ <label class="addrOpt"><input type="checkbox" id="f-noaddr">
749
+ <span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
750
+ </label>
741
751
  </div>
742
752
  <div class="nav">
743
753
  <button type="button" class="btn ghost" data-back="3">← Back</button>
@@ -2113,30 +2123,101 @@ h1[tabindex="-1"]:focus{outline:none;}
2113
2123
  if (state.noAddress) return '';
2114
2124
  return assignedDomain() || '';
2115
2125
  }
2126
+ /* What the create POST may CARRY, as opposed to what the panel displays: only a
2127
+ bring-your-own domain. The assigned <slug>.<apex> default is the server's to
2128
+ apply (task 1002704) and re-asserting it here is what task 1002898 N2 closes —
2129
+ apexBase() falls back to guessing from location.hostname, and a guess that
2130
+ disagrees with the server's apex (a subdomain hub like get.<apex>) is refused
2131
+ as an under-apex squat. Omit it and the server assigns the right one. */
2132
+ function byoDomain() {
2133
+ return state.domain && looksLikeDomain(state.domain) ? state.domain : '';
2134
+ }
2135
+
2136
+ /* ── slug availability (task 1002898 N10) ──
2137
+ The panel promised '<slug>.<apex> — yours from the moment it stands up' on FORMAT
2138
+ validation alone, so a collision surfaced only as a 409 at the final Create click.
2139
+ GET /provisioning/slug-available answers the same question the create route will,
2140
+ and the promise is now made only once the server has said the handle is free.
2141
+ This NARROWS the race, it does not close it — the 409 at create is still the only
2142
+ authority — so an unanswered check degrades to naming the address WITHOUT the
2143
+ ownership promise, never to guessing in either direction. */
2144
+ var slugAvail = { slug: '', state: 'unknown' }; /* unknown | checking | free | taken | yours | reserved */
2145
+ var slugCheckT = 0;
2146
+ var slugCheckGen = 0;
2147
+ /* Only trust a verdict that belongs to the handle currently in the field. */
2148
+ function slugAvailState() {
2149
+ return slugAvail.slug === state.slug ? slugAvail.state : 'unknown';
2150
+ }
2151
+ /* Debounced, and generation-guarded because a cleared timer cannot cancel a chain
2152
+ already mid-fetch — each chain carries the generation it was born under, so an
2153
+ orphaned answer goes quiet instead of overwriting a newer one (the manifest-poll
2154
+ idiom below). The caller re-renders; this only sets state. */
2155
+ function checkSlugAvailable() {
2156
+ clearTimeout(slugCheckT);
2157
+ slugCheckGen += 1;
2158
+ var slug = state.slug;
2159
+ if (!validSlug(slug)) { slugAvail = { slug: slug, state: 'unknown' }; return; }
2160
+ slugAvail = { slug: slug, state: 'checking' };
2161
+ var myGen = slugCheckGen;
2162
+ slugCheckT = setTimeout(function () {
2163
+ fetch(API + '/provisioning/slug-available?slug=' + encodeURIComponent(slug), { credentials: 'same-origin' })
2164
+ .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
2165
+ .then(function (d) {
2166
+ if (myGen !== slugCheckGen) return;
2167
+ slugAvail = { slug: slug, state: d.available ? 'free' : (d.reason || 'taken') };
2168
+ renderAddrPanel();
2169
+ })
2170
+ .catch(function () {
2171
+ if (myGen !== slugCheckGen) return;
2172
+ /* no answer is NOT a free answer — back to the format-only wording */
2173
+ slugAvail = { slug: slug, state: 'unknown' };
2174
+ renderAddrPanel();
2175
+ });
2176
+ }, 400);
2177
+ }
2116
2178
  function renderAddrPanel() {
2117
2179
  var box = $('addrConfirm');
2118
2180
  if (!box) return;
2119
2181
  var assigned = assignedDomain();
2120
2182
  var custom = state.domain && looksLikeDomain(state.domain) ? state.domain : '';
2121
- box.classList.toggle('optedOut', state.noAddress && !custom);
2122
2183
  var big = $('addrAssigned');
2123
2184
  var note = $('addrAssignedNote');
2185
+ /* the verdict only bears on the ASSIGNED address — a chosen domain or the
2186
+ opt-out settles the step without it */
2187
+ var avail = !custom && !state.noAddress && assigned ? slugAvailState() : 'unknown';
2188
+ box.classList.toggle('optedOut', state.noAddress && !custom);
2189
+ box.classList.toggle('unavailable', avail === 'taken' || avail === 'yours' || avail === 'reserved');
2124
2190
  if (custom) {
2125
2191
  big.innerHTML = '<code>' + esc(custom) + '</code> — your own domain, wired up for you.';
2126
2192
  note.textContent = 'DNS pointing happens after the standup; the card on your projects page walks you through it.';
2127
2193
  } else if (state.noAddress) {
2128
2194
  big.textContent = 'Starting without an address.';
2129
2195
  note.textContent = 'Sign-in on your project can’t be set up until it has one — add it any time from your projects page.';
2130
- } else if (assigned) {
2196
+ } else if (assigned && avail === 'free') {
2131
2197
  big.innerHTML = '<code>' + esc(assigned) + '</code> — yours from the moment it stands up.';
2132
2198
  note.textContent = 'Sign-in, sharing, everything just works. You can point a domain you own at it any time.';
2199
+ } else if (assigned && avail === 'taken') {
2200
+ big.innerHTML = '<code>' + esc(assigned) + '</code> — already taken.';
2201
+ note.textContent = 'Another project on the platform holds that handle. Pick a different one.';
2202
+ } else if (assigned && avail === 'yours') {
2203
+ big.innerHTML = '<code>' + esc(assigned) + '</code> — you already have that one.';
2204
+ note.textContent = 'That handle belongs to one of your own projects. Pick a different one to start something new.';
2205
+ } else if (assigned && avail === 'reserved') {
2206
+ big.innerHTML = '<code>' + esc(assigned) + '</code> — reserved by the platform.';
2207
+ note.textContent = 'That address is the platform’s own. Pick a different handle, or bring a domain you own.';
2208
+ } else if (assigned && avail === 'checking') {
2209
+ big.innerHTML = '<code>' + esc(assigned) + '</code> — checking that it’s free…';
2210
+ note.textContent = 'One moment.';
2211
+ } else if (assigned) {
2212
+ /* the check didn’t answer — name the address, promise nothing about it */
2213
+ big.innerHTML = '<code>' + esc(assigned) + '</code> — the address it would take.';
2214
+ note.textContent = 'We couldn’t check whether that handle is free just now — you’ll know for certain when you create it.';
2133
2215
  } else if (!validSlug(state.slug)) {
2134
2216
  big.textContent = 'Pick a handle above and the address comes with it.';
2135
2217
  note.textContent = '';
2136
2218
  } else {
2137
2219
  /* dotless host (local dev): nothing sane to auto-assign — the domain
2138
- field IS the step here, so unfold it (a folded disclosure made the
2139
- opt-out checkbox unreachable-by-default; owner walk, 2026-08-12) */
2220
+ field IS the step here, so unfold it */
2140
2221
  big.textContent = 'No address is assigned on this host.';
2141
2222
  note.textContent = 'Add a domain you own below, or start without one.';
2142
2223
  $('addrCustom').open = true;
@@ -2202,7 +2283,10 @@ h1[tabindex="-1"]:focus{outline:none;}
2202
2283
  (a pre-auth probe 401s for strangers and, worse, could seed the
2203
2284
  draft's owner with myLogin()'s placeholder) */
2204
2285
  if (step === 3) { syncHome(); if (authKnown) ensureRepoAccess(); }
2205
- if (step === 4) renderAddrPanel();
2286
+ /* re-check on every entry, not just on typing: a resumed draft arrives here with
2287
+ a handle nobody has asked the server about, and a handle free ten minutes ago
2288
+ may not be now (task 1002898 N10) */
2289
+ if (step === 4) { checkSlugAvailable(); renderAddrPanel(); }
2206
2290
  if (step === 5) renderReview();
2207
2291
  }
2208
2292
 
@@ -2427,6 +2511,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2427
2511
  if (!state.slug) { h.innerHTML = '&nbsp;'; h.className = 'hint'; this.classList.remove('bad'); }
2428
2512
  else if (validSlug(state.slug)) { h.textContent = 'Nice — that works.'; h.className = 'hint ok'; this.classList.remove('bad'); }
2429
2513
  else { h.textContent = '2–40 lowercase letters, numbers, or dashes; no leading/trailing/double dash.'; h.className = 'hint err'; this.classList.add('bad'); }
2514
+ checkSlugAvailable();
2430
2515
  renderAddrPanel();
2431
2516
  saveDraft();
2432
2517
  });
@@ -2481,6 +2566,23 @@ h1[tabindex="-1"]:focus{outline:none;}
2481
2566
  return false;
2482
2567
  }
2483
2568
  if (state.domain && !looksLikeDomain(state.domain)) return false;
2569
+ /* A handle the server has already said is not free would only 409 at Create —
2570
+ stop here instead (task 1002898 N10). ONLY a definite negative blocks:
2571
+ 'unknown' and 'checking' must never gate the step, because the check is
2572
+ advisory and the 409 remains the authority. A chosen domain or the opt-out
2573
+ settles the address another way, so the block does not apply there. */
2574
+ if (!state.domain && !state.noAddress) {
2575
+ var av = slugAvailState();
2576
+ if (av === 'taken' || av === 'yours' || av === 'reserved') {
2577
+ $('h-slug').textContent = av === 'taken'
2578
+ ? 'That handle is already taken — pick a different one.'
2579
+ : av === 'yours'
2580
+ ? 'You already have a project with that handle — pick a different one.'
2581
+ : 'That handle is reserved by the platform — pick a different one.';
2582
+ $('h-slug').className = 'hint err'; $('f-slug').classList.add('bad');
2583
+ return false;
2584
+ }
2585
+ }
2484
2586
  /* no auto-assigned address (dotless host): going domainless must be an
2485
2587
  EXPLICIT choice — a domain, or the opt-out box. Never silent. */
2486
2588
  if (!resolvedDomain() && !state.noAddress) {
@@ -2593,10 +2695,15 @@ h1[tabindex="-1"]:focus{outline:none;}
2593
2695
  slug: state.slug,
2594
2696
  type: state.projType || undefined,
2595
2697
  hosting_shape: 'standalone',
2596
- domain: resolvedDomain() || undefined,
2597
- /* The server now defaults an omitted domain to the assigned address
2598
- (task 1002704), so the opt-out must travel explicitly — an absent
2599
- domain no longer means "none". */
2698
+ /* ONLY a bring-your-own domain travels (task 1002898 N2). The server owns
2699
+ the assigned <slug>.<apex> default (task 1002704) and applies it to an
2700
+ omitted domain, so re-sending our own derived copy could only disagree
2701
+ with it — apexBase() may be guessing from location.hostname, and a guess
2702
+ that lands under the apex but isn't the server's assignment is refused
2703
+ as a squat. Omitting it is how the default is now requested. */
2704
+ domain: byoDomain() || undefined,
2705
+ /* An absent domain means "assign me the default", so the opt-out must
2706
+ travel explicitly — it is the only thing that means "none". */
2600
2707
  no_address: state.noAddress ? true : undefined,
2601
2708
  target_ref: state.owner + '/' + state.repo,
2602
2709
  /* LOAD-BEARING for the control-plane runner, not just for the checklist we
@@ -2618,9 +2725,14 @@ h1[tabindex="-1"]:focus{outline:none;}
2618
2725
  });
2619
2726
  }).then(function (data) {
2620
2727
  try {
2728
+ /* The address the instance ACTUALLY holds, read off the create response —
2729
+ not our preview of it (task 1002898 N2). The server owns the assigned
2730
+ default, so its answer is the only one the done panel may promise; a
2731
+ client-derived copy could name an address that was never assigned. */
2732
+ var madeDomain = (data.instance && data.instance.domain) || '';
2621
2733
  sessionStorage.setItem(CREATED_KEY, JSON.stringify({
2622
- id: data.instance && data.instance.id, slug: state.slug, domain: resolvedDomain(),
2623
- noaddr: !!state.noAddress,
2734
+ id: data.instance && data.instance.id, slug: state.slug, domain: madeDomain,
2735
+ noaddr: !madeDomain,
2624
2736
  mode: state.mode, name: state.name, at: Date.now()
2625
2737
  }));
2626
2738
  sessionStorage.removeItem(DRAFT_KEY);
package/src/module-api.js CHANGED
@@ -711,8 +711,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
711
711
  // landed since 1.19.140 with no explicit bump. run 31892484952. (task 1002620)
712
712
  // 1.19.142 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
713
713
  // landed since 1.19.141 with no explicit bump. run 31893298770. (task 1002620)
714
+ // 1.19.143 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
715
+ // landed since 1.19.142 with no explicit bump. run 31893905320. (task 1002620)
716
+ // 1.19.144 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
717
+ // landed since 1.19.143 with no explicit bump. run 31894547582. (task 1002620)
714
718
  // ---------------------------------------------------------------------------
715
- const CORE_VERSION = '1.19.142'; // CI auto-patch carrier (ADR 0161); see changelog above
719
+ const CORE_VERSION = '1.19.144'; // CI auto-patch carrier (ADR 0161); see changelog above
716
720
 
717
721
  // A namespaced logger so a module's log lines are attributable + consistent.
718
722
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -19,6 +19,17 @@ const CSS = path.join(
19
19
  );
20
20
  const src = fs.readFileSync(CSS, 'utf8');
21
21
 
22
+ // The two sheets that actually ship, comment-stripped. Several static guards
23
+ // below read these instead of resolved colors, so they are declared here beside
24
+ // `src` rather than beside their first consumer — as source-order constants they
25
+ // were only reachable because node:test defers a test body past module
26
+ // evaluation, which is a thin thing to rest on.
27
+ const cssSrc = src.replace(/\/\*[\s\S]*?\*\//g, '');
28
+ const pageSrc = fs
29
+ .readFileSync(path.join(path.dirname(CSS), '..', 'projects.html'), 'utf8')
30
+ .replace(/<!--[\s\S]*?-->/g, '')
31
+ .replace(/\/\*[\s\S]*?\*\//g, '');
32
+
22
33
  // ── parse ────────────────────────────────────────────────────────────────────
23
34
  const declarations = (body) => {
24
35
  const out = {};
@@ -207,6 +218,15 @@ const PAIRS = [
207
218
  // must still measure (19px/700 headline = large text, 3.0)
208
219
  ['optedOut .addrBig (large) on --bg', 'var(--ink-soft)', 'var(--bg)', 3.0],
209
220
  ['addrOpt warning on --bg', 'var(--warn)', 'var(--bg)', 4.5],
221
+ // ?view=mine (task 1002823) — both surfaces traded a subtree opacity for muted
222
+ // tokens, so the tokens themselves now carry the de-emphasis and must measure.
223
+ // The two pill rows are the pair that proves the split: one mute cannot serve
224
+ // both grounds, because on the pressed --accent fill no ink token clears 4.5
225
+ // (--ink is the best at 3.70 light / 4.05 dark) and only the button's #fff does.
226
+ ['.filters .n count, resting pill', 'var(--ink-faint)', CARD, 4.5],
227
+ ['.filters .n count, pressed pill', '#ffffff', 'var(--accent)', 4.5],
228
+ ['.proj.gone name (muted)', 'var(--ink-soft)', CARD, 4.5],
229
+ ['.proj.gone meta (dim removed)', 'var(--ink-faint)', CARD, 4.5],
210
230
  // lifecycle strip: 12px/700
211
231
  ['.strip .slbl idle', 'var(--ink-faint)', CARD, 4.5],
212
232
  ['.strip .slbl now/done', 'color-mix(in srgb,var(--sea) 40%,var(--ink))', CARD, 4.5],
@@ -227,6 +247,23 @@ const PAIRS = [
227
247
  ['typeCard heading · business', 'color-mix(in srgb,var(--terracotta) 40%,var(--ink))', 'color-mix(in srgb,var(--terracotta) 9%,var(--bg-card))', 4.5],
228
248
  ['typeCard heading · non-profit', 'color-mix(in srgb,var(--gold-soft) 40%,var(--ink))', 'color-mix(in srgb,var(--gold-soft) 9%,var(--bg-card))', 4.5],
229
249
  ['typeCard heading · not-sure', 'color-mix(in srgb,var(--rule-soft) 40%,var(--ink))', 'color-mix(in srgb,var(--rule-soft) 9%,var(--bg-card))', 4.5],
250
+ // --ctl-line (task 1002824): the ONLY rows in this file that measure a border
251
+ // token as a FOREGROUND. WCAG 1.4.11 asks 3:1 of the boundary that IDENTIFIES
252
+ // a control, and a text field has nothing else to identify it — its --bg-card
253
+ // fill is 1.02:1 on the card it sits in, so the border IS the boundary. Both
254
+ // adjacent colors are measured: the --card outside and the fill inside.
255
+ //
256
+ // --line stays exactly where it is and is deliberately NOT held to this bar.
257
+ // It is a CARD hairline (1.16:1 light / 1.09:1 dark) drawing .review,
258
+ // .humanSteps>li, .kv, .ghlist, the unselected .tpill and the .choice cards.
259
+ // Two washed-down variants of it are likewise EXEMPT because they are
260
+ // DECORATIVE, not identifying: .humanSteps>li.slocked (55% line mix, ~1.06:1 —
261
+ // task 1002751) says "locked" through its number chip and muted text, and
262
+ // .proj.gone (the same mix — task 1002823) says "torn down" through its
263
+ // ghosted orb, its chip and a line of copy. Neither border carries state on
264
+ // its own, so raising them would buy nothing and restyle every card.
265
+ ['--ctl-line boundary on the card', 'var(--ctl-line)', 'var(--card)', 3.0],
266
+ ['--ctl-line boundary vs input fill', 'var(--ctl-line)', CARD, 3.0],
230
267
  ['?view=new input ::placeholder', 'var(--ink-faint)', CARD, 4.5],
231
268
  ['?view=new .ghlist .hint on deep', 'var(--ink-faint)', 'var(--bg-deep)', 4.5],
232
269
  ['--chrome-accent wordmark', 'var(--chrome-accent)', 'var(--page-bg)', 4.5],
@@ -351,6 +388,25 @@ test('no text link is left painting --accent-deep', () => {
351
388
  assert.deepEqual(strays, [], `\n ${strays.join('\n ')}\n`);
352
389
  });
353
390
 
391
+ test('no text-entry control draws its boundary with --line', () => {
392
+ // The companion to the --ctl-line PAIRS rows: the token only helps the rules
393
+ // that take it, and these live in two sheets (cosmos.css owns .attach and
394
+ // .mineBar .search, projects.html owns the wizard's own #view-new fields), so
395
+ // fixing one sheet leaves the other invisible. Text-entry selectors only —
396
+ // --line is still correct for every card, list and pill it draws, which is the
397
+ // whole reason this became a second token instead of a change to --line.
398
+ const strays = [];
399
+ for (const [where, text] of [['cosmos.css', cssSrc], ['projects.html', pageSrc]]) {
400
+ for (const m of text.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
401
+ if (!/\b(?:input|textarea|select)\b|\.search\b/.test(m[1])) continue;
402
+ if (/border(?:-color|-\w+)?\s*:[^;]*var\(\s*--line\s*\)/.test(m[2])) {
403
+ strays.push(`${where}: ${m[1].trim().slice(0, 70)}`);
404
+ }
405
+ }
406
+ }
407
+ assert.deepEqual(strays, [], `\n ${strays.join('\n ')}\n`);
408
+ });
409
+
354
410
  // The gate's LOCKED sky palette — deliberately absent from COLOR_VARS, so a
355
411
  // branding pack can never re-theme these. Dark GROUNDS are allowed (dark mode IS
356
412
  // the gate's space, by design); text painted with one shows Cloud Bongos orange
@@ -402,11 +458,6 @@ test('dark blocks never re-point a token at the locked space palette', () => {
402
458
  // Known limits, accepted: @keyframes bodies, animation-fill-mode resting
403
459
  // values, and JS-set inline opacity are out of static reach — never make an
404
460
  // animation or script the resting presenter of text or a state mark.
405
- const pageSrc = fs
406
- .readFileSync(path.join(path.dirname(CSS), '..', 'projects.html'), 'utf8')
407
- .replace(/<!--[\s\S]*?-->/g, '')
408
- .replace(/\/\*[\s\S]*?\*\//g, '');
409
- const cssSrc = src.replace(/\/\*[\s\S]*?\*\//g, '');
410
461
  const dimsBelowFull = (body) => {
411
462
  const m = body.match(/opacity\s*:\s*([\d.]+%?)/);
412
463
  if (!m) return false;
@@ -416,9 +467,12 @@ const dimsBelowFull = (body) => {
416
467
 
417
468
  test('text is never dimmed by subtree opacity', () => {
418
469
  // Two nets. (1) Anywhere in either sheet: a rule that declares BOTH a color
419
- // and a sub-1 opacity is dimmed text by construction. (2) The create flow's
420
- // known text containers may not carry opacity at all — this also catches
421
- // ancestor dims (.createSplit) that net 1 can't see.
470
+ // and a sub-1 opacity is dimmed text by construction. (2) The known text
471
+ // containers may not carry opacity at all — this also catches ancestor dims
472
+ // (.createSplit, .proj.gone) and blocks that declare NO color of their own,
473
+ // which is exactly how .filters .n and .proj.gone survived net 1 into task
474
+ // 1002823. Graphics are deliberately absent from the list: .orb.ghost and
475
+ // .worldOrb.dim carry no text and their opacity IS the state cue.
422
476
  const offenders = [];
423
477
  for (const [where, text] of [['cosmos.css', cssSrc], ['projects.html', pageSrc]]) {
424
478
  for (const m of text.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
@@ -427,7 +481,7 @@ test('text is never dimmed by subtree opacity', () => {
427
481
  }
428
482
  }
429
483
  }
430
- for (const sel of ['\\.humanSteps', '\\.addrConfirm', '\\.prog\\b', '\\.choice', '\\.review', '\\.createSplit', '\\.chip \\.rec']) {
484
+ for (const sel of ['\\.humanSteps', '\\.addrConfirm', '\\.prog\\b', '\\.choice', '\\.review', '\\.createSplit', '\\.chip \\.rec', '\\.filters', '\\.proj\\b']) {
431
485
  for (const text of [cssSrc, pageSrc]) {
432
486
  for (const m of text.matchAll(new RegExp(`${sel}[^{}]*\\{([^}]*)\\}`, 'g'))) {
433
487
  if (dimsBelowFull(m[1])) offenders.push(`text container dimmed: ${m[0].slice(0, 90)}`);
@@ -0,0 +1,243 @@
1
+ // tests/provisioning_slug_available.mjs
2
+ //
3
+ // GET /provisioning/slug-available (task 1002898 N10). The create wizard declared
4
+ // '<slug>.<apex> — yours from the moment it stands up' on FORMAT validation alone;
5
+ // no availability route existed anywhere, so a collision surfaced only as a 409 at
6
+ // the final Create click. This route answers the same question POST
7
+ // /provisioning/instances will, and the panel now waits for it before promising.
8
+ //
9
+ // The contract under test is AGREEMENT with the create route — the route is only
10
+ // worth having if its three refusals match the three ways create actually answers:
11
+ // taken → another builder holds the slug → create 409s slug_taken
12
+ // yours → the CALLER holds it → create is an idempotent re-request
13
+ // reserved → row free, <slug>.<apex> reserved → create 400s domain_reserved
14
+ // plus the same trim+lowercase normalization and the same isValidSlug bar, and the
15
+ // same byte-exact `slug text UNIQUE` lookup (no lower(slug), which would over-report).
16
+ //
17
+ // Also pinned: it requires a session (NOT public — the answer is a yes/no about every
18
+ // project slug on the instance) and it is rate-limited (same reason).
19
+ //
20
+ // DB-free: data-access fns are stubbed on the cached module object (the
21
+ // provisioning_default_domain.mjs harness). No network.
22
+ //
23
+ // Run: node tests/provisioning_slug_available.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const provisioning = require('../modules/provisioning/provisioning.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ // ── harness ─────────────────────────────────────────────────────────────────
39
+ let slugHolder = null; // what getInstanceBySlug returns
40
+ let lookedUp = []; // the exact string the route handed the lookup
41
+ provisioning.getInstanceBySlug = async (_db, s) => { lookedUp.push(s); return slugHolder; };
42
+
43
+ const api = require('../src/module-api.js');
44
+ let authOk = true; // flipped to prove the route is gated, not public
45
+ api.requireBuilder = (req, res, next) => {
46
+ if (!authOk) return res.status(401).json({ error: { code: 'unauthenticated' } });
47
+ req.builder = { id: 7, github_login: 'owner' };
48
+ next();
49
+ };
50
+ api.requirePermission = () => (_req, _res, next) => next();
51
+ let brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
52
+ api.branding = () => ({ domains: brandingDomains, identity: { productName: 'Cloud Bongos' } });
53
+
54
+ const express = require('express');
55
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
56
+
57
+ // Each app gets its OWN router — and therefore its own fresh rate-limit bucket, so
58
+ // the limiter test cannot be starved (or starve) the correctness tests.
59
+ function mount() {
60
+ const app = express();
61
+ app.use(express.json());
62
+ app.use((_req, res, next) => {
63
+ res.fail = (code, opts, details) => {
64
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
65
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
66
+ };
67
+ next();
68
+ });
69
+ app.use('/api/bongos', provisioningRoutes());
70
+ return app;
71
+ }
72
+
73
+ const server = mount().listen(0);
74
+ await new Promise((r) => server.once('listening', r));
75
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
76
+
77
+ function get(path, origin = base) {
78
+ return new Promise((resolve, reject) => {
79
+ const req = http.request(`${origin}${path}`, { method: 'GET' }, (res) => {
80
+ let d = '';
81
+ res.on('data', (c) => { d += c; });
82
+ res.on('end', () => {
83
+ let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ }
84
+ resolve({ status: res.statusCode, body: j, raw: d, headers: res.headers });
85
+ });
86
+ });
87
+ req.on('error', reject);
88
+ req.end();
89
+ });
90
+ }
91
+
92
+ const heldBy = (ownerId) => ({ id: 41, owner_builder_id: ownerId, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' });
93
+
94
+ function reset() {
95
+ slugHolder = null;
96
+ lookedUp = [];
97
+ authOk = true;
98
+ brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
99
+ }
100
+
101
+ // ── the answer, and its agreement with the create route ─────────────────────
102
+ console.log('GET /provisioning/slug-available — the verdict:');
103
+
104
+ await ta('a free handle → available, with the address create would assign', async () => {
105
+ reset();
106
+ const res = await get('/provisioning/slug-available?slug=spike');
107
+ assert.equal(res.status, 200, res.raw);
108
+ assert.deepEqual(res.body, { slug: 'spike', available: true, reason: null, address: 'spike.cloudbongos.com' });
109
+ });
110
+
111
+ await ta("held by ANOTHER builder → taken (the create route's 409 slug_taken)", async () => {
112
+ reset();
113
+ slugHolder = heldBy(99);
114
+ const res = await get('/provisioning/slug-available?slug=spike');
115
+ assert.equal(res.status, 200, res.raw);
116
+ assert.equal(res.body.available, false);
117
+ assert.equal(res.body.reason, 'taken');
118
+ });
119
+
120
+ await ta("held by the CALLER → 'yours', never 'taken' — create re-requests it, it is not a new project", async () => {
121
+ reset();
122
+ slugHolder = heldBy(7);
123
+ const res = await get('/provisioning/slug-available?slug=spike');
124
+ assert.equal(res.status, 200, res.raw);
125
+ assert.equal(res.body.available, false);
126
+ assert.equal(res.body.reason, 'yours', 'the caller must not be told a stranger holds it');
127
+ });
128
+
129
+ await ta('the holder row NEVER travels — only the verdict', async () => {
130
+ reset();
131
+ slugHolder = heldBy(99);
132
+ const res = await get('/provisioning/slug-available?slug=spike');
133
+ assert.deepEqual(Object.keys(res.body).sort(), ['address', 'available', 'reason', 'slug']);
134
+ assert.doesNotMatch(res.raw, /owner_builder_id|status|"id"/, 'no instance shape may leak');
135
+ });
136
+
137
+ await ta("a free row whose <slug>.<apex> is platform-reserved → 'reserved' (create 400s domain_reserved)", async () => {
138
+ reset();
139
+ const res = await get('/provisioning/slug-available?slug=www');
140
+ assert.equal(res.status, 200, res.raw);
141
+ assert.equal(res.body.available, false);
142
+ assert.equal(res.body.reason, 'reserved');
143
+ // agrees with the helper the create route itself calls
144
+ assert.equal(provisioning.isReservedHostname('www.cloudbongos.com', 'cloudbongos.com'), true);
145
+ });
146
+
147
+ await ta('a taken row wins over reserved — the row is the uniqueness rule', async () => {
148
+ reset();
149
+ slugHolder = heldBy(99);
150
+ const res = await get('/provisioning/slug-available?slug=www');
151
+ assert.equal(res.body.reason, 'taken');
152
+ });
153
+
154
+ // ── normalization: the same sequence the create route runs ──────────────────
155
+ console.log('\nnormalization — must match the create route byte for byte:');
156
+
157
+ await ta('trim + lowercase before the lookup, exactly as create does', async () => {
158
+ reset();
159
+ const res = await get('/provisioning/slug-available?slug=%20%20SPIKE%20%20');
160
+ assert.equal(res.status, 200, res.raw);
161
+ assert.equal(res.body.slug, 'spike', 'the normalized handle is echoed back');
162
+ assert.deepEqual(lookedUp, ['spike'], 'the UNIQUE index is byte-exact — the lookup must be too');
163
+ });
164
+
165
+ await ta('an invalid handle → 400 bad_slug, the same bar and reason string as create', async () => {
166
+ for (const bad of ['a', '-bad', 'bad-', 'do--uble', 'Nope!', '', 'x'.repeat(41)]) {
167
+ reset();
168
+ const res = await get(`/provisioning/slug-available?slug=${encodeURIComponent(bad)}`);
169
+ assert.equal(res.status, 400, `${JSON.stringify(bad)} → ${res.raw}`);
170
+ assert.equal(res.body.error.code, 'bad_slug');
171
+ assert.match(res.body.error.reason, /lowercase, 2–40 chars/);
172
+ }
173
+ });
174
+
175
+ await ta('a missing slug param → 400 bad_slug, never a lookup', async () => {
176
+ reset();
177
+ const res = await get('/provisioning/slug-available');
178
+ assert.equal(res.status, 400, res.raw);
179
+ assert.equal(res.body.error.code, 'bad_slug');
180
+ assert.deepEqual(lookedUp, [], 'nothing may reach the database');
181
+ });
182
+
183
+ await ta('a blank apex (self-host) still answers availability, with no address to promise', async () => {
184
+ reset();
185
+ brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: '' };
186
+ const res = await get('/provisioning/slug-available?slug=www');
187
+ assert.equal(res.status, 200, res.raw);
188
+ assert.equal(res.body.address, null, 'nothing to assign → nothing to name');
189
+ assert.equal(res.body.available, true, 'with no apex there is no reserved address to collide with');
190
+ });
191
+
192
+ // ── the gate + the throttle ─────────────────────────────────────────────────
193
+ console.log('\nthe gate and the throttle — it is an enumeration oracle:');
194
+
195
+ await ta('requires a session — this route is NOT public', async () => {
196
+ reset();
197
+ authOk = false;
198
+ const res = await get('/provisioning/slug-available?slug=spike');
199
+ assert.equal(res.status, 401, res.raw);
200
+ assert.deepEqual(lookedUp, [], 'an unauthenticated caller learns nothing');
201
+ });
202
+
203
+ await ta('the limiter keys on the BUILDER, not the IP — one office address cannot starve itself', async () => {
204
+ reset();
205
+ const RL = require('../modules/provisioning/rate-limit.js');
206
+ // default key is still the IP (the public tls-check/status limiters depend on it)
207
+ assert.equal(RL.clientIp({ ip: '203.0.113.7' }), '203.0.113.7');
208
+ const seen = [];
209
+ const limiter = RL.makeAskRateLimiter({ max: 1, key: (req) => `b:${req.builder.id}` });
210
+ const runFor = (id) => new Promise((resolve) => {
211
+ const res = { set() {}, status(c) { this._c = c; return this; }, json(b) { resolve({ status: this._c, body: b }); } };
212
+ limiter({ ip: '203.0.113.7', builder: { id } }, res, () => { seen.push(id); resolve({ status: 200 }); });
213
+ });
214
+ assert.equal((await runFor(7)).status, 200, 'builder 7 gets its first call');
215
+ assert.equal((await runFor(7)).status, 429, 'and is throttled on its second');
216
+ assert.equal((await runFor(8)).status, 200,
217
+ 'a DIFFERENT builder on the same IP still gets through — the bucket is per-builder');
218
+ assert.deepEqual(seen, [7, 8]);
219
+ });
220
+
221
+ await ta('rate-limited — a crawler is shed with 429 + Retry-After', async () => {
222
+ reset();
223
+ const rlServer = mount().listen(0);
224
+ await new Promise((r) => rlServer.once('listening', r));
225
+ const rlBase = `http://127.0.0.1:${rlServer.address().port}/api/bongos`;
226
+ try {
227
+ let limited = null;
228
+ // the limiter's cap is 30/min/IP; walk past it and the next answer must be 429
229
+ for (let i = 0; i < 40 && !limited; i++) {
230
+ const res = await get(`/provisioning/slug-available?slug=spike${i}`, rlBase);
231
+ if (res.status === 429) limited = res;
232
+ }
233
+ assert.ok(limited, 'the route must stop answering under a flood');
234
+ assert.equal(limited.body.error, 'rate_limited');
235
+ assert.ok(Number(limited.headers['retry-after']) > 0, 'Retry-After must tell the caller when');
236
+ } finally {
237
+ rlServer.close();
238
+ }
239
+ });
240
+
241
+ server.close();
242
+ console.log(`\n${passed} passed, ${failed} failed`);
243
+ process.exit(failed ? 1 : 0);