@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.
- package/.bongos-core.json +29 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +56 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/rate-limit.js +15 -5
- package/modules/provisioning/routes/provisioning.js +71 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +32 -4
- package/public-landing/projects.html +129 -17
- package/src/module-api.js +5 -1
- package/tests/cosmos_token_contrast.mjs +63 -9
- package/tests/provisioning_slug_available.mjs +243 -0
- package/tests/public_landing_projects.mjs +118 -0
|
@@ -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
|
|
717
|
-
|
|
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"> </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
|
|
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
|
-
|
|
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 = ' '; 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
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
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:
|
|
2623
|
-
noaddr:
|
|
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.
|
|
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
|
|
420
|
-
//
|
|
421
|
-
//
|
|
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);
|