alpha-gate 0.1.0
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/alpha-gate.mjs +75 -0
- package/deploy/backup.sh +45 -0
- package/deploy/deploy.sh +21 -0
- package/deploy/dev.sh +56 -0
- package/deploy/lib/statedir.sh +11 -0
- package/deploy/teardown.sh +17 -0
- package/docs/ONBOARDING.md +16 -0
- package/docs/PRINCIPLES.md +195 -0
- package/docs/README.md +54 -0
- package/docs/UPLOADING.md +14 -0
- package/docs/integrate/activation.md +56 -0
- package/docs/integrate/sparkle-go.md +106 -0
- package/docs/integrate/sparkle-swift.md +63 -0
- package/docs/maintain/backup.md +52 -0
- package/docs/maintain/migrate-account.md +95 -0
- package/docs/maintain/teardown.md +46 -0
- package/docs/maintain/troubleshooting.md +102 -0
- package/docs/maintain/updating.md +92 -0
- package/docs/operate/add-users.md +55 -0
- package/docs/operate/channels.md +54 -0
- package/docs/operate/email.md +51 -0
- package/docs/operate/monitoring.md +56 -0
- package/docs/operate/publish.md +90 -0
- package/docs/operate/remove-users.md +47 -0
- package/docs/setup/cloudflare-account.md +41 -0
- package/docs/setup/deploy.md +84 -0
- package/docs/setup/install.md +65 -0
- package/migrations/0001_clients.sql +12 -0
- package/migrations/0002_builds_streams.sql +30 -0
- package/migrations/0003_access_log.sql +14 -0
- package/migrations/0004_meta.sql +5 -0
- package/migrations/0005_admin_audit.sql +14 -0
- package/migrations/0006_build_dmg.sql +6 -0
- package/migrations/0007_access_requests.sql +12 -0
- package/migrations/0008_build_rollback_target.sql +7 -0
- package/migrations/0009_hidden.sql +6 -0
- package/migrations/0010_purged_at.sql +5 -0
- package/package.json +65 -0
- package/publish.sh +302 -0
- package/release.json +7 -0
- package/src/auth/access-jwt.ts +210 -0
- package/src/auth/token-gate.ts +27 -0
- package/src/core/appcast.ts +107 -0
- package/src/core/audit-chain.ts +145 -0
- package/src/core/invite-template.ts +127 -0
- package/src/core/no-build.ts +160 -0
- package/src/core/resolver.ts +60 -0
- package/src/core/tokens.ts +51 -0
- package/src/core/types.ts +76 -0
- package/src/core/validation.ts +60 -0
- package/src/core/verdict.ts +195 -0
- package/src/core/version.ts +113 -0
- package/src/cron.ts +37 -0
- package/src/db/access-log.ts +168 -0
- package/src/db/access-requests.ts +90 -0
- package/src/db/admin-audit.ts +101 -0
- package/src/db/builds.ts +169 -0
- package/src/db/client.ts +80 -0
- package/src/db/clients.ts +103 -0
- package/src/db/meta.ts +30 -0
- package/src/db/streams.ts +85 -0
- package/src/deploy/cli.ts +181 -0
- package/src/deploy/commands/deploy.ts +392 -0
- package/src/deploy/commands/dev.ts +190 -0
- package/src/deploy/commands/teardown.ts +159 -0
- package/src/deploy/core/args.ts +205 -0
- package/src/deploy/core/colors.ts +49 -0
- package/src/deploy/core/config.ts +65 -0
- package/src/deploy/core/parse.ts +51 -0
- package/src/deploy/core/paths.ts +27 -0
- package/src/deploy/core/plan.ts +138 -0
- package/src/deploy/core/result.ts +13 -0
- package/src/deploy/core/state.ts +64 -0
- package/src/deploy/core/table.ts +49 -0
- package/src/deploy/core/types.ts +39 -0
- package/src/deploy/core/ui.ts +107 -0
- package/src/deploy/seams/clock.ts +10 -0
- package/src/deploy/seams/files.ts +52 -0
- package/src/deploy/seams/io.ts +56 -0
- package/src/deploy/seams/wrangler.ts +100 -0
- package/src/deploy/ui-preview.ts +112 -0
- package/src/deps.ts +41 -0
- package/src/dev/admin-entry.ts +104 -0
- package/src/env.ts +47 -0
- package/src/lib/clock.ts +17 -0
- package/src/lib/hosts.ts +27 -0
- package/src/lib/text.ts +6 -0
- package/src/r2/builds-bucket.ts +50 -0
- package/src/r2/keys.ts +27 -0
- package/src/routes/admin/admin-context.ts +9 -0
- package/src/routes/admin/audit-fields.ts +22 -0
- package/src/routes/admin/branding.tsx +161 -0
- package/src/routes/admin/builds.tsx +388 -0
- package/src/routes/admin/clients.tsx +396 -0
- package/src/routes/admin/confirm.tsx +80 -0
- package/src/routes/admin/flash.ts +72 -0
- package/src/routes/admin/form.ts +43 -0
- package/src/routes/admin/index.ts +146 -0
- package/src/routes/admin/invite.ts +57 -0
- package/src/routes/admin/middleware.ts +52 -0
- package/src/routes/admin/negotiate.ts +13 -0
- package/src/routes/admin/pending.tsx +73 -0
- package/src/routes/admin/read-model.ts +518 -0
- package/src/routes/admin/streams.tsx +182 -0
- package/src/routes/admin/theme.ts +34 -0
- package/src/routes/admin/upload.tsx +320 -0
- package/src/routes/admin/views.tsx +293 -0
- package/src/routes/app/access.tsx +38 -0
- package/src/routes/app/app-context.ts +8 -0
- package/src/routes/app/appcast.ts +68 -0
- package/src/routes/app/assets.ts +25 -0
- package/src/routes/app/download.ts +45 -0
- package/src/routes/app/get.tsx +35 -0
- package/src/routes/app/index.ts +31 -0
- package/src/routes/app/resolve.ts +16 -0
- package/src/services/anchor.ts +54 -0
- package/src/services/audit.ts +19 -0
- package/src/services/branding.ts +51 -0
- package/src/services/email-cloudflare.ts +80 -0
- package/src/services/email.ts +68 -0
- package/src/services/self-update.ts +62 -0
- package/src/views/access-page.tsx +39 -0
- package/src/views/admin/ci-page.tsx +76 -0
- package/src/views/admin/combobox.tsx +191 -0
- package/src/views/admin/forms.tsx +37 -0
- package/src/views/admin/layout.tsx +496 -0
- package/src/views/admin/manage-pages.tsx +223 -0
- package/src/views/admin/manage.tsx +1120 -0
- package/src/views/admin/plist-extract.ts +233 -0
- package/src/views/admin/read-pages.tsx +1098 -0
- package/src/views/admin/setup-page.tsx +108 -0
- package/src/views/admin/table-enhance.ts +176 -0
- package/src/views/admin/ui.tsx +159 -0
- package/src/views/get-page.tsx +39 -0
- package/src/views/layout.tsx +57 -0
- package/src/worker.ts +23 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { FC } from "hono/jsx";
|
|
2
|
+
import { AdminLayout, type Chrome } from "./layout";
|
|
3
|
+
|
|
4
|
+
// §6/§7/§14 — the admin "App setup" page: a personalized, copy-paste guide for wiring a macOS app to
|
|
5
|
+
// THIS instance (its activate scheme + saved Sparkle public key + its per-user feed URL). Pure: the
|
|
6
|
+
// route loads the values from `meta` and derives the App Worker origin. The public key is non-secret
|
|
7
|
+
// (it ships in the app); the private key never reaches the Worker.
|
|
8
|
+
|
|
9
|
+
export interface SetupInfo {
|
|
10
|
+
appName: string;
|
|
11
|
+
activateScheme: string;
|
|
12
|
+
publicKey: string | null;
|
|
13
|
+
/** The public App Worker origin, or a placeholder when it can't be derived (custom domain). */
|
|
14
|
+
appOrigin: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SetupPage: FC<{ info: SetupInfo; chrome?: Chrome }> = ({ info, chrome }) => {
|
|
18
|
+
const key = info.publicKey ?? "PASTE_SUPublicEDKey_FROM_generate_keys";
|
|
19
|
+
const plist = `<key>SUPublicEDKey</key>
|
|
20
|
+
<string>${key}</string>
|
|
21
|
+
|
|
22
|
+
<key>CFBundleURLTypes</key>
|
|
23
|
+
<array><dict>
|
|
24
|
+
<key>CFBundleURLSchemes</key>
|
|
25
|
+
<array><string>${info.activateScheme}</string></array>
|
|
26
|
+
</dict></array>`;
|
|
27
|
+
const feed = `${info.appOrigin}/appcast?token=<TOKEN>&installed=<CFBundleVersion>`;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<AdminLayout title="App setup" chrome={chrome}>
|
|
31
|
+
<p class="sub" style="margin-top:6px">
|
|
32
|
+
How to wire <strong>{info.appName}</strong> to this instance. To publish builds see{" "}
|
|
33
|
+
<a href="/admin/ci">CI publishing</a>; full runbook in <code>docs/operate/publish.md</code>.
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
{info.publicKey === null ? (
|
|
37
|
+
<p class="callout warn">
|
|
38
|
+
No Sparkle public key saved yet — run <code>generate_keys</code> and paste{" "}
|
|
39
|
+
<code>SUPublicEDKey</code> on the <a href="/admin/settings">Settings</a> page; it then
|
|
40
|
+
fills in below.
|
|
41
|
+
</p>
|
|
42
|
+
) : null}
|
|
43
|
+
|
|
44
|
+
<section>
|
|
45
|
+
<div class="slab">
|
|
46
|
+
<h2>1 · Sparkle EdDSA key (once)</h2>
|
|
47
|
+
</div>
|
|
48
|
+
<pre>
|
|
49
|
+
<code>./bin/generate_keys</code>
|
|
50
|
+
</pre>
|
|
51
|
+
<p class="muted">
|
|
52
|
+
Prints the public key (save it in Settings). The private key stays in your Keychain —
|
|
53
|
+
<code>sign_update</code> uses it at publish time; the Worker never holds it.
|
|
54
|
+
</p>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<section>
|
|
58
|
+
<div class="slab">
|
|
59
|
+
<h2>2 · Info.plist</h2>
|
|
60
|
+
</div>
|
|
61
|
+
<pre>
|
|
62
|
+
<code>{plist}</code>
|
|
63
|
+
</pre>
|
|
64
|
+
<p class="muted">
|
|
65
|
+
Leave <code>SUFeedURL</code> unset and <code>SURequireSignedFeed</code> off — the feed is
|
|
66
|
+
per-user (step 3). The scheme above must match the Activate URL scheme in Settings.
|
|
67
|
+
</p>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<section>
|
|
71
|
+
<div class="slab">
|
|
72
|
+
<h2>3 · Per-user feed (runtime)</h2>
|
|
73
|
+
</div>
|
|
74
|
+
<p class="muted">
|
|
75
|
+
Your SPUUpdaterDelegate builds the feed from the token stored on activation:
|
|
76
|
+
</p>
|
|
77
|
+
<pre>
|
|
78
|
+
<code>{feed}</code>
|
|
79
|
+
</pre>
|
|
80
|
+
<p class="muted">
|
|
81
|
+
The token reaches the app via <code>{`${info.activateScheme}://activate?token=…`}</code>{" "}
|
|
82
|
+
or by pasting the key from the user's <code>/get</code> page.
|
|
83
|
+
</p>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<section>
|
|
87
|
+
<div class="slab">
|
|
88
|
+
<h2>4 · Publish</h2>
|
|
89
|
+
</div>
|
|
90
|
+
<p class="muted">
|
|
91
|
+
Build → sign → notarize → staple on macOS, then publish with one command — it reads the
|
|
92
|
+
version from the app and signs with <code>sign_update</code> (the Worker never signs):
|
|
93
|
+
</p>
|
|
94
|
+
<pre>
|
|
95
|
+
<code>{`./publish.sh MyApp.dmg --channel <name> # from a clone
|
|
96
|
+
npx alpha-gate publish MyApp.dmg --channel <name> # from npm`}</code>
|
|
97
|
+
</pre>
|
|
98
|
+
<p class="muted">
|
|
99
|
+
It takes a <code>.dmg</code> or a signed <code>.app</code> <code>.zip</code>, picks the
|
|
100
|
+
instance automatically when only one is deployed, and pre-checks the build number. Or use
|
|
101
|
+
the browser <a href="/admin/upload">Upload</a> page; headless CI is on{" "}
|
|
102
|
+
<a href="/admin/ci">CI publishing</a>. Each build's <code>build_number</code> must
|
|
103
|
+
increase.
|
|
104
|
+
</p>
|
|
105
|
+
</section>
|
|
106
|
+
</AdminLayout>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Progressive-enhancement for the admin list tables: click-to-sort columns and instant client-side
|
|
2
|
+
// filtering, with NO framework. The server always renders the complete table, so without JavaScript the
|
|
3
|
+
// page is a fully readable static table (graceful degradation) — the script below only reorders/hides
|
|
4
|
+
// rows already present. Tables opt in declaratively:
|
|
5
|
+
//
|
|
6
|
+
// <table data-enhance>
|
|
7
|
+
// <thead><tr>
|
|
8
|
+
// <th data-key="email" data-sort="text">Email</th> ← sortable; data-sort = "text" | "num"
|
|
9
|
+
// <th data-key="build" data-sort="num">Build</th>
|
|
10
|
+
// <th>Actions</th> ← no data-sort → not sortable
|
|
11
|
+
// </tr></thead>
|
|
12
|
+
// <tbody>
|
|
13
|
+
// <tr><td>a@x</td><td>1500</td><td>…</td></tr>
|
|
14
|
+
// </tbody>
|
|
15
|
+
// </table>
|
|
16
|
+
//
|
|
17
|
+
// A cell's sort/filter value is `data-value` when present, else its trimmed text — so badges and "—"
|
|
18
|
+
// placeholders can carry a canonical value (e.g. <td data-value="yes">). Filter controls live anywhere
|
|
19
|
+
// on the page and target a column by its header key:
|
|
20
|
+
//
|
|
21
|
+
// <select data-filter-col="status">…</select> exact match (default)
|
|
22
|
+
// <input data-filter-col="email" data-filter-match="contains">
|
|
23
|
+
// <input type="checkbox" data-filter-col="nobuild" data-filter-value="nobuild">
|
|
24
|
+
//
|
|
25
|
+
// compareCells / cellPasses are PURE and unit-tested (no DOM); the script serialises them via toString()
|
|
26
|
+
// so the exact tested logic ships to the browser. Keep them self-contained (no external references).
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Comparator for one column's two cell values, direction included. Blank/"—" cells always sort LAST in
|
|
30
|
+
* both directions (so empty rows never crowd the top of a descending sort). "num" compares numerically;
|
|
31
|
+
* "text" is a case-insensitive, numeric-aware locale compare. Returns the usual negative/zero/positive.
|
|
32
|
+
*/
|
|
33
|
+
export function compareCells(
|
|
34
|
+
a: string,
|
|
35
|
+
b: string,
|
|
36
|
+
type: "text" | "num",
|
|
37
|
+
dir: "asc" | "desc",
|
|
38
|
+
): number {
|
|
39
|
+
// No inner named functions here: those get wrapped in esbuild's `__name` keep-names helper, which is
|
|
40
|
+
// absent in the browser and would throw when this is serialised via toString() (see TABLE_ENHANCE_SCRIPT).
|
|
41
|
+
const ta = a.trim();
|
|
42
|
+
const tb = b.trim();
|
|
43
|
+
const ea = ta === "" || ta === "—";
|
|
44
|
+
const eb = tb === "" || tb === "—";
|
|
45
|
+
if (ea && eb) return 0;
|
|
46
|
+
if (ea) return 1;
|
|
47
|
+
if (eb) return -1;
|
|
48
|
+
let base: number;
|
|
49
|
+
if (type === "num") {
|
|
50
|
+
const na = Number.parseFloat(a);
|
|
51
|
+
const nb = Number.parseFloat(b);
|
|
52
|
+
base = (Number.isNaN(na) ? 0 : na) - (Number.isNaN(nb) ? 0 : nb);
|
|
53
|
+
} else {
|
|
54
|
+
base = a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
|
55
|
+
}
|
|
56
|
+
return dir === "asc" ? base : -base;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a cell value passes one filter control. An empty filter value is "no constraint" (passes).
|
|
61
|
+
* "contains" is a case-insensitive substring; "exact" a case-insensitive equality. Both trim first.
|
|
62
|
+
*/
|
|
63
|
+
export function cellPasses(
|
|
64
|
+
cellValue: string,
|
|
65
|
+
filterValue: string,
|
|
66
|
+
match: "exact" | "contains",
|
|
67
|
+
): boolean {
|
|
68
|
+
if (filterValue === "") return true;
|
|
69
|
+
const c = cellValue.trim().toLowerCase();
|
|
70
|
+
const v = filterValue.trim().toLowerCase();
|
|
71
|
+
return match === "contains" ? c.includes(v) : c === v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The browser glue. Reads the declarative markup, wires sort + filter, and calls the pure functions
|
|
75
|
+
// above (injected verbatim). No-ops cleanly on pages with no [data-enhance] table.
|
|
76
|
+
const GLUE = `
|
|
77
|
+
(function () {
|
|
78
|
+
function val(tr, i) {
|
|
79
|
+
var td = tr.cells[i];
|
|
80
|
+
if (!td) return "";
|
|
81
|
+
return td.getAttribute("data-value") != null ? td.getAttribute("data-value") : td.textContent.trim();
|
|
82
|
+
}
|
|
83
|
+
document.querySelectorAll("table[data-enhance]").forEach(function (table) {
|
|
84
|
+
var head = table.tHead && table.tHead.rows[0];
|
|
85
|
+
var body = table.tBodies[0];
|
|
86
|
+
if (!head || !body) return;
|
|
87
|
+
var heads = Array.prototype.slice.call(head.cells);
|
|
88
|
+
var keyIndex = {};
|
|
89
|
+
heads.forEach(function (th, i) { var k = th.getAttribute("data-key"); if (k) keyIndex[k] = i; });
|
|
90
|
+
function rows() { return Array.prototype.slice.call(body.rows); }
|
|
91
|
+
|
|
92
|
+
heads.forEach(function (th, i) {
|
|
93
|
+
var type = th.getAttribute("data-sort");
|
|
94
|
+
if (type == null) return;
|
|
95
|
+
th.classList.add("th-sort");
|
|
96
|
+
// No role override: a <th> is an implicit columnheader, which is what makes aria-sort meaningful
|
|
97
|
+
// and keeps the cells associated with it. tabindex + keydown make it keyboard-operable.
|
|
98
|
+
th.setAttribute("tabindex", "0");
|
|
99
|
+
function sort() {
|
|
100
|
+
var dir = th.getAttribute("aria-sort") === "ascending" ? "desc" : "asc";
|
|
101
|
+
heads.forEach(function (h) { h.removeAttribute("aria-sort"); });
|
|
102
|
+
th.setAttribute("aria-sort", dir === "asc" ? "ascending" : "descending");
|
|
103
|
+
rows()
|
|
104
|
+
.sort(function (a, b) { return compareCells(val(a, i), val(b, i), type || "text", dir); })
|
|
105
|
+
.forEach(function (tr) { body.appendChild(tr); });
|
|
106
|
+
}
|
|
107
|
+
th.addEventListener("click", sort);
|
|
108
|
+
th.addEventListener("keydown", function (e) {
|
|
109
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); sort(); }
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
var controls = Array.prototype.slice
|
|
114
|
+
.call(document.querySelectorAll("[data-filter-col]"))
|
|
115
|
+
.filter(function (c) { return keyIndex[c.getAttribute("data-filter-col")] != null; });
|
|
116
|
+
if (!controls.length) return;
|
|
117
|
+
// Scope the status/empty nodes to THIS table's container, not document-wide, so two enhanced tables
|
|
118
|
+
// with filters on one page don't write each other's count.
|
|
119
|
+
var scope = table.parentElement || document;
|
|
120
|
+
var status = scope.querySelector("[data-table-status]");
|
|
121
|
+
var empty = scope.querySelector("[data-table-empty]");
|
|
122
|
+
function apply() {
|
|
123
|
+
var specs = controls.map(function (c) {
|
|
124
|
+
var v = c.type === "checkbox" ? (c.checked ? (c.getAttribute("data-filter-value") || "on") : "") : c.value;
|
|
125
|
+
return { i: keyIndex[c.getAttribute("data-filter-col")], value: v, match: c.getAttribute("data-filter-match") || "exact" };
|
|
126
|
+
});
|
|
127
|
+
var all = rows(), shown = 0;
|
|
128
|
+
all.forEach(function (tr) {
|
|
129
|
+
var ok = specs.every(function (s) { return cellPasses(val(tr, s.i), s.value, s.match); });
|
|
130
|
+
tr.hidden = !ok;
|
|
131
|
+
if (ok) shown++;
|
|
132
|
+
});
|
|
133
|
+
if (status) status.textContent = shown === all.length ? "" : "Showing " + shown + " of " + all.length;
|
|
134
|
+
if (empty) empty.hidden = shown !== 0;
|
|
135
|
+
}
|
|
136
|
+
controls.forEach(function (c) { c.addEventListener("input", apply); c.addEventListener("change", apply); });
|
|
137
|
+
apply();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Bulk-selection enhancement: a [data-check-all] header checkbox toggles every row checkbox in the
|
|
141
|
+
// same table (respecting filtered-out rows), and [data-selected-count] mirrors the live count.
|
|
142
|
+
// Without JS the header checkbox does nothing and the count stays blank — the bulk form still works.
|
|
143
|
+
document.querySelectorAll("[data-check-all]").forEach(function (all) {
|
|
144
|
+
var table = all.closest("table");
|
|
145
|
+
if (!table) return;
|
|
146
|
+
function boxes() {
|
|
147
|
+
return Array.prototype.slice
|
|
148
|
+
.call(table.querySelectorAll('tbody input[type="checkbox"]'))
|
|
149
|
+
.filter(function (b) { var tr = b.closest("tr"); return !(tr && tr.hidden); });
|
|
150
|
+
}
|
|
151
|
+
function counts() {
|
|
152
|
+
var n = boxes().filter(function (b) { return b.checked; }).length;
|
|
153
|
+
document.querySelectorAll("[data-selected-count]").forEach(function (el) {
|
|
154
|
+
el.textContent = n > 0 ? " " + n + " " + (n === 1 ? "build" : "builds") : " builds";
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
all.addEventListener("change", function () {
|
|
158
|
+
boxes().forEach(function (b) { b.checked = all.checked; });
|
|
159
|
+
counts();
|
|
160
|
+
});
|
|
161
|
+
table.addEventListener("change", function (e) {
|
|
162
|
+
if (e.target !== all) counts();
|
|
163
|
+
});
|
|
164
|
+
counts();
|
|
165
|
+
});
|
|
166
|
+
})();
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
// The full inline script: the pure functions (tested) plus the DOM glue. Injected once by AdminLayout.
|
|
170
|
+
// `__name` is an identity shim for esbuild's keep-names helper: when the bundler wraps a named function
|
|
171
|
+
// with __name(fn, "name"), the serialised source below would reference a helper the browser doesn't have.
|
|
172
|
+
// Shimming it to return its first argument makes the toString()-injected functions self-contained.
|
|
173
|
+
export const TABLE_ENHANCE_SCRIPT = `var __name = function (t) { return t; };
|
|
174
|
+
var compareCells = ${compareCells.toString()};
|
|
175
|
+
var cellPasses = ${cellPasses.toString()};
|
|
176
|
+
${GLUE}`;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { FC } from "hono/jsx";
|
|
2
|
+
import type { Build } from "../../core/types";
|
|
3
|
+
import { formatWhen, type Verdict } from "../../core/verdict";
|
|
4
|
+
|
|
5
|
+
// Quiet-instrument primitives shared by every admin page: the canonical version lockup, state tags,
|
|
6
|
+
// state dots, timestamps, and the verdict rendered in operator words. Pure JSX over props (§23).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one canonical way a build is written anywhere in the back office: `#1500 · v1.2.1` — mono,
|
|
10
|
+
* number carrying the weight. Never a raw DB row id. `href` makes it the link to the build page.
|
|
11
|
+
*/
|
|
12
|
+
export const Lk: FC<{
|
|
13
|
+
build: Pick<Build, "buildNumber" | "shortVersion">;
|
|
14
|
+
href?: string | undefined;
|
|
15
|
+
/** Muted variant — "nothing will change" (up-to-date verdicts, secondary mentions). */
|
|
16
|
+
dim?: boolean | undefined;
|
|
17
|
+
/** Omit the version half (tight cells, second mentions). */
|
|
18
|
+
short?: boolean | undefined;
|
|
19
|
+
}> = ({ build, href, dim, short }) => {
|
|
20
|
+
const body = (
|
|
21
|
+
<>
|
|
22
|
+
<b>#{build.buildNumber}</b>
|
|
23
|
+
{short ? null : <i> · v{build.shortVersion}</i>}
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
const cls = dim ? "lk dim" : "lk";
|
|
27
|
+
return href ? (
|
|
28
|
+
<a class={cls} href={href}>
|
|
29
|
+
{body}
|
|
30
|
+
</a>
|
|
31
|
+
) : (
|
|
32
|
+
<span class={cls}>{body}</span>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Exception-only state tag. Healthy states render nothing — silence is the ok state. `crit` is the
|
|
38
|
+
* single filled tag in the system.
|
|
39
|
+
*/
|
|
40
|
+
export const Tag: FC<{
|
|
41
|
+
kind: "warn" | "mut" | "acc" | "crit";
|
|
42
|
+
label: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
}> = ({ kind, label, title }) => (
|
|
45
|
+
<span class={`tag ${kind}`} title={title}>
|
|
46
|
+
{label}
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Build state tags for one build — exceptions only (an available, ordinary build shows nothing). */
|
|
51
|
+
export const BuildTags: FC<{ build: Build }> = ({ build }) => (
|
|
52
|
+
<>
|
|
53
|
+
{build.status === "withdrawn" ? (
|
|
54
|
+
<Tag kind="mut" label="withdrawn" title="Not offered to anyone; restore to serve it again" />
|
|
55
|
+
) : null}
|
|
56
|
+
{build.critical ? (
|
|
57
|
+
<Tag kind="crit" label="critical" title="Sparkle treats this update as required" />
|
|
58
|
+
) : null}
|
|
59
|
+
{build.rollbackTarget ? (
|
|
60
|
+
<Tag kind="acc" label="rollback target" title="Old code republished above the bad build" />
|
|
61
|
+
) : null}
|
|
62
|
+
{build.hidden ? <Tag kind="mut" label="hidden" title="Hidden from the list only" /> : null}
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** 8px state dot: ok (serving) · warn (faults) · off (serving nothing) · req (requests). */
|
|
67
|
+
export const Dot: FC<{ kind: "ok" | "warn" | "off" | "req"; title?: string }> = ({
|
|
68
|
+
kind,
|
|
69
|
+
title,
|
|
70
|
+
}) => <span class={`dot ${kind}`} title={title} aria-hidden="true" />;
|
|
71
|
+
|
|
72
|
+
/** The one timestamp form: `Jul 09 08:30` with the full ISO in the title (and datetime). */
|
|
73
|
+
export const When: FC<{ iso: string | null; now: string }> = ({ iso, now }) =>
|
|
74
|
+
iso === null ? (
|
|
75
|
+
<span class="mut">—</span>
|
|
76
|
+
) : (
|
|
77
|
+
<time class="t" datetime={iso} title={iso}>
|
|
78
|
+
{formatWhen(iso, now)}
|
|
79
|
+
</time>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The verdict in operator words — the Users list's "Next check" cell and every detail page's strip.
|
|
84
|
+
* Faults carry the cause; remedies live in the surrounding page (attention list, detail actions).
|
|
85
|
+
*/
|
|
86
|
+
export const VerdictCell: FC<{ verdict: Verdict }> = ({ verdict }) => {
|
|
87
|
+
switch (verdict.kind) {
|
|
88
|
+
case "offered":
|
|
89
|
+
return (
|
|
90
|
+
<span class="vd">
|
|
91
|
+
gets <Lk build={verdict.build} />
|
|
92
|
+
{verdict.via === "pin" ? (
|
|
93
|
+
<Tag kind="mut" label="pinned" title="The pin decides — channels are ignored" />
|
|
94
|
+
) : null}
|
|
95
|
+
{verdict.build.critical ? (
|
|
96
|
+
<Tag kind="crit" label="critical" title="Sparkle treats this update as required" />
|
|
97
|
+
) : null}
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
case "up-to-date":
|
|
101
|
+
return (
|
|
102
|
+
<span class="vd mut">
|
|
103
|
+
up to date · <Lk build={verdict.build} dim short />
|
|
104
|
+
{verdict.via === "pin" ? (
|
|
105
|
+
<Tag kind="mut" label="pinned" title="Held here by the pin" />
|
|
106
|
+
) : null}
|
|
107
|
+
</span>
|
|
108
|
+
);
|
|
109
|
+
case "revoked":
|
|
110
|
+
return <span class="vd mut">— not served while revoked</span>;
|
|
111
|
+
case "no-channel":
|
|
112
|
+
return (
|
|
113
|
+
<span class="vd">
|
|
114
|
+
<Tag kind="warn" label="no channel" title="Resolves to nothing — assign a channel" />{" "}
|
|
115
|
+
<span class="mut">assign one to serve them</span>
|
|
116
|
+
</span>
|
|
117
|
+
);
|
|
118
|
+
case "empty-channel":
|
|
119
|
+
return (
|
|
120
|
+
<span class="vd">
|
|
121
|
+
<Tag kind="warn" label="no build" title="Their channels carry no available build" />{" "}
|
|
122
|
+
<span class="mut">their channels serve nothing</span>
|
|
123
|
+
</span>
|
|
124
|
+
);
|
|
125
|
+
case "pin-unavailable":
|
|
126
|
+
return (
|
|
127
|
+
<span class="vd">
|
|
128
|
+
<Tag kind="warn" label="pin serves nothing" title="The pinned build was withdrawn" />{" "}
|
|
129
|
+
<span class="mut">pinned build is withdrawn — unpin or re-pin</span>
|
|
130
|
+
</span>
|
|
131
|
+
);
|
|
132
|
+
case "pin-below-installed":
|
|
133
|
+
return (
|
|
134
|
+
<span class="vd">
|
|
135
|
+
<Tag
|
|
136
|
+
kind="warn"
|
|
137
|
+
label="pin below installed"
|
|
138
|
+
title="Sparkle can't downgrade — the pin serves nothing"
|
|
139
|
+
/>{" "}
|
|
140
|
+
<span class="mut">
|
|
141
|
+
pinned <Lk build={verdict.pinned} dim short /> under installed #{verdict.installed}
|
|
142
|
+
</span>
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
case "stranded":
|
|
146
|
+
return (
|
|
147
|
+
<span class="vd">
|
|
148
|
+
<Tag
|
|
149
|
+
kind="warn"
|
|
150
|
+
label="stranded"
|
|
151
|
+
title="Installed above everything their channels offer"
|
|
152
|
+
/>{" "}
|
|
153
|
+
<span class="mut">
|
|
154
|
+
nothing above <b class="num">#{verdict.installed}</b> — roll forward
|
|
155
|
+
</span>
|
|
156
|
+
</span>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FC } from "hono/jsx";
|
|
2
|
+
import type { Branding } from "../core/invite-template";
|
|
3
|
+
import { Layout } from "./layout";
|
|
4
|
+
|
|
5
|
+
// §6 — the one token-gated page each user receives. Carries every action in order: download,
|
|
6
|
+
// activate (deep link), the token as a paste fallback, and short instructions. The route computes the
|
|
7
|
+
// URLs (download with via=install; the myapp:// activate link) and the branding model; this is view.
|
|
8
|
+
|
|
9
|
+
export const GetPage: FC<{
|
|
10
|
+
branding: Branding;
|
|
11
|
+
token: string;
|
|
12
|
+
downloadUrl: string;
|
|
13
|
+
activateUrl: string;
|
|
14
|
+
}> = ({ branding, token, downloadUrl, activateUrl }) => (
|
|
15
|
+
<Layout title={branding.appName} accent={branding.accent}>
|
|
16
|
+
{branding.headerUrl ? <img class="header" src={branding.headerUrl} alt="" /> : null}
|
|
17
|
+
{branding.iconUrl ? <img class="icon" src={branding.iconUrl} alt="" /> : null}
|
|
18
|
+
<h1>{branding.appName}</h1>
|
|
19
|
+
{branding.blurb ? <p class="blurb">{branding.blurb}</p> : null}
|
|
20
|
+
|
|
21
|
+
<a class="btn primary" href={downloadUrl}>
|
|
22
|
+
Download
|
|
23
|
+
</a>
|
|
24
|
+
<a class="btn" href={activateUrl}>
|
|
25
|
+
Activate
|
|
26
|
+
</a>
|
|
27
|
+
|
|
28
|
+
<p class="hint">Access key — paste it in the app if Activate doesn’t open it:</p>
|
|
29
|
+
<code class="token">{token}</code>
|
|
30
|
+
|
|
31
|
+
<ol class="steps">
|
|
32
|
+
<li>Download and install the app.</li>
|
|
33
|
+
<li>Launch it.</li>
|
|
34
|
+
<li>
|
|
35
|
+
Click <strong>Activate</strong> (or paste the key) to connect updates.
|
|
36
|
+
</li>
|
|
37
|
+
</ol>
|
|
38
|
+
</Layout>
|
|
39
|
+
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Child, FC } from "hono/jsx";
|
|
2
|
+
|
|
3
|
+
// Shared HTML shell for the public pages. Pure: props in, markup out. The accent color is the one
|
|
4
|
+
// branding knob that reaches the chrome (§6); everything else is page content. renderPage prepends
|
|
5
|
+
// the doctype so route handlers don't hand-concatenate strings.
|
|
6
|
+
|
|
7
|
+
const baseStyles = (accent: string) => `
|
|
8
|
+
:root { --accent: ${accent}; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
12
|
+
background: #f5f5f7; color: #1d1d1f; padding: 2rem; }
|
|
13
|
+
.card { width: 100%; max-width: 28rem; background: #fff; border-radius: 16px;
|
|
14
|
+
padding: 2.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); text-align: center; }
|
|
15
|
+
.header { display: block; width: 100%; border-radius: 12px; margin-bottom: 1rem; }
|
|
16
|
+
.icon { width: 72px; height: 72px; border-radius: 16px; }
|
|
17
|
+
h1 { font-size: 1.5rem; margin: 1rem 0 .25rem; }
|
|
18
|
+
.blurb { color: #6e6e73; margin: 0 0 1.5rem; }
|
|
19
|
+
.btn { display: block; width: 100%; padding: .75rem 1rem; margin: .5rem 0; border-radius: 10px;
|
|
20
|
+
border: 1px solid #d2d2d7; background: #fff; color: #1d1d1f; font-size: 1rem; font-weight: 500;
|
|
21
|
+
text-decoration: none; cursor: pointer; }
|
|
22
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
23
|
+
.input { display: block; width: 100%; padding: .75rem 1rem; margin: .5rem 0; border-radius: 10px;
|
|
24
|
+
border: 1px solid #d2d2d7; font-size: 1rem; }
|
|
25
|
+
.hint { color: #6e6e73; font-size: .85rem; margin: 1.5rem 0 .25rem; }
|
|
26
|
+
.token { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
27
|
+
background: #f5f5f7; border-radius: 8px; padding: .6rem; word-break: break-all; user-select: all; }
|
|
28
|
+
.steps { text-align: left; color: #424245; font-size: .9rem; margin-top: 1.5rem; padding-left: 1.25rem; }
|
|
29
|
+
.muted { color: #6e6e73; }
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
export const Layout: FC<{ title: string; accent: string; children?: Child }> = ({
|
|
33
|
+
title,
|
|
34
|
+
accent,
|
|
35
|
+
children,
|
|
36
|
+
}) => (
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8" />
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
41
|
+
<title>{title}</title>
|
|
42
|
+
{/* CSS is raw, not escaped */}
|
|
43
|
+
<style dangerouslySetInnerHTML={{ __html: baseStyles(accent) }} />
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<main class="card">{children}</main>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Renders a full page element to an HTML string with the doctype prepended. Our pages are synchronous
|
|
53
|
+
* hono/jsx components, so the element stringifies directly (a hono JSX.Element is a string subtype).
|
|
54
|
+
*/
|
|
55
|
+
export function renderPage(element: string | Promise<string>): string {
|
|
56
|
+
return `<!DOCTYPE html>\n${String(element)}`;
|
|
57
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { runScheduled } from "./cron";
|
|
2
|
+
import { type Env, readEnv } from "./env";
|
|
3
|
+
import { createAdminApp } from "./routes/admin";
|
|
4
|
+
import { createAppApp } from "./routes/app";
|
|
5
|
+
|
|
6
|
+
// §17 — the single entrypoint, deployed twice and switched by env.ROLE. The app/admin Hono apps are
|
|
7
|
+
// built once at module scope; fetch dispatches to one by role, scheduled() runs the cron (admin only).
|
|
8
|
+
// This is the only file that knows about both surfaces; it holds no logic of its own.
|
|
9
|
+
|
|
10
|
+
const appWorker = createAppApp();
|
|
11
|
+
const adminWorker = createAdminApp();
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise<Response> {
|
|
15
|
+
readEnv(env); // fail fast on a misconfigured ROLE
|
|
16
|
+
const worker = env.ROLE === "admin" ? adminWorker : appWorker;
|
|
17
|
+
return worker.fetch(request, env, ctx);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): void {
|
|
21
|
+
ctx.waitUntil(runScheduled(env).catch((error) => console.error("scheduled run failed", error)));
|
|
22
|
+
},
|
|
23
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"lib": ["ESNext"],
|
|
8
|
+
"types": ["@cloudflare/workers-types"],
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"jsxImportSource": "hono/jsx",
|
|
11
|
+
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"exactOptionalPropertyTypes": true,
|
|
15
|
+
"noImplicitOverride": true,
|
|
16
|
+
"noFallthroughCasesInSwitch": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
|
|
20
|
+
"isolatedModules": true,
|
|
21
|
+
"verbatimModuleSyntax": true,
|
|
22
|
+
"skipLibCheck": true,
|
|
23
|
+
"noEmit": true,
|
|
24
|
+
"forceConsistentCasingInFileNames": true
|
|
25
|
+
},
|
|
26
|
+
"include": ["src", "test"],
|
|
27
|
+
"//": "src/deploy + test/deploy are the Node CLI — typechecked separately via tsconfig.deploy.json (Node types, not workers-types).",
|
|
28
|
+
"exclude": [
|
|
29
|
+
"node_modules",
|
|
30
|
+
"test/vitest.config.ts",
|
|
31
|
+
"test/vitest.deploy.config.ts",
|
|
32
|
+
"src/deploy",
|
|
33
|
+
"test/deploy"
|
|
34
|
+
]
|
|
35
|
+
}
|