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.
Files changed (140) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -0
  4. package/bin/alpha-gate.mjs +75 -0
  5. package/deploy/backup.sh +45 -0
  6. package/deploy/deploy.sh +21 -0
  7. package/deploy/dev.sh +56 -0
  8. package/deploy/lib/statedir.sh +11 -0
  9. package/deploy/teardown.sh +17 -0
  10. package/docs/ONBOARDING.md +16 -0
  11. package/docs/PRINCIPLES.md +195 -0
  12. package/docs/README.md +54 -0
  13. package/docs/UPLOADING.md +14 -0
  14. package/docs/integrate/activation.md +56 -0
  15. package/docs/integrate/sparkle-go.md +106 -0
  16. package/docs/integrate/sparkle-swift.md +63 -0
  17. package/docs/maintain/backup.md +52 -0
  18. package/docs/maintain/migrate-account.md +95 -0
  19. package/docs/maintain/teardown.md +46 -0
  20. package/docs/maintain/troubleshooting.md +102 -0
  21. package/docs/maintain/updating.md +92 -0
  22. package/docs/operate/add-users.md +55 -0
  23. package/docs/operate/channels.md +54 -0
  24. package/docs/operate/email.md +51 -0
  25. package/docs/operate/monitoring.md +56 -0
  26. package/docs/operate/publish.md +90 -0
  27. package/docs/operate/remove-users.md +47 -0
  28. package/docs/setup/cloudflare-account.md +41 -0
  29. package/docs/setup/deploy.md +84 -0
  30. package/docs/setup/install.md +65 -0
  31. package/migrations/0001_clients.sql +12 -0
  32. package/migrations/0002_builds_streams.sql +30 -0
  33. package/migrations/0003_access_log.sql +14 -0
  34. package/migrations/0004_meta.sql +5 -0
  35. package/migrations/0005_admin_audit.sql +14 -0
  36. package/migrations/0006_build_dmg.sql +6 -0
  37. package/migrations/0007_access_requests.sql +12 -0
  38. package/migrations/0008_build_rollback_target.sql +7 -0
  39. package/migrations/0009_hidden.sql +6 -0
  40. package/migrations/0010_purged_at.sql +5 -0
  41. package/package.json +65 -0
  42. package/publish.sh +302 -0
  43. package/release.json +7 -0
  44. package/src/auth/access-jwt.ts +210 -0
  45. package/src/auth/token-gate.ts +27 -0
  46. package/src/core/appcast.ts +107 -0
  47. package/src/core/audit-chain.ts +145 -0
  48. package/src/core/invite-template.ts +127 -0
  49. package/src/core/no-build.ts +160 -0
  50. package/src/core/resolver.ts +60 -0
  51. package/src/core/tokens.ts +51 -0
  52. package/src/core/types.ts +76 -0
  53. package/src/core/validation.ts +60 -0
  54. package/src/core/verdict.ts +195 -0
  55. package/src/core/version.ts +113 -0
  56. package/src/cron.ts +37 -0
  57. package/src/db/access-log.ts +168 -0
  58. package/src/db/access-requests.ts +90 -0
  59. package/src/db/admin-audit.ts +101 -0
  60. package/src/db/builds.ts +169 -0
  61. package/src/db/client.ts +80 -0
  62. package/src/db/clients.ts +103 -0
  63. package/src/db/meta.ts +30 -0
  64. package/src/db/streams.ts +85 -0
  65. package/src/deploy/cli.ts +181 -0
  66. package/src/deploy/commands/deploy.ts +392 -0
  67. package/src/deploy/commands/dev.ts +190 -0
  68. package/src/deploy/commands/teardown.ts +159 -0
  69. package/src/deploy/core/args.ts +205 -0
  70. package/src/deploy/core/colors.ts +49 -0
  71. package/src/deploy/core/config.ts +65 -0
  72. package/src/deploy/core/parse.ts +51 -0
  73. package/src/deploy/core/paths.ts +27 -0
  74. package/src/deploy/core/plan.ts +138 -0
  75. package/src/deploy/core/result.ts +13 -0
  76. package/src/deploy/core/state.ts +64 -0
  77. package/src/deploy/core/table.ts +49 -0
  78. package/src/deploy/core/types.ts +39 -0
  79. package/src/deploy/core/ui.ts +107 -0
  80. package/src/deploy/seams/clock.ts +10 -0
  81. package/src/deploy/seams/files.ts +52 -0
  82. package/src/deploy/seams/io.ts +56 -0
  83. package/src/deploy/seams/wrangler.ts +100 -0
  84. package/src/deploy/ui-preview.ts +112 -0
  85. package/src/deps.ts +41 -0
  86. package/src/dev/admin-entry.ts +104 -0
  87. package/src/env.ts +47 -0
  88. package/src/lib/clock.ts +17 -0
  89. package/src/lib/hosts.ts +27 -0
  90. package/src/lib/text.ts +6 -0
  91. package/src/r2/builds-bucket.ts +50 -0
  92. package/src/r2/keys.ts +27 -0
  93. package/src/routes/admin/admin-context.ts +9 -0
  94. package/src/routes/admin/audit-fields.ts +22 -0
  95. package/src/routes/admin/branding.tsx +161 -0
  96. package/src/routes/admin/builds.tsx +388 -0
  97. package/src/routes/admin/clients.tsx +396 -0
  98. package/src/routes/admin/confirm.tsx +80 -0
  99. package/src/routes/admin/flash.ts +72 -0
  100. package/src/routes/admin/form.ts +43 -0
  101. package/src/routes/admin/index.ts +146 -0
  102. package/src/routes/admin/invite.ts +57 -0
  103. package/src/routes/admin/middleware.ts +52 -0
  104. package/src/routes/admin/negotiate.ts +13 -0
  105. package/src/routes/admin/pending.tsx +73 -0
  106. package/src/routes/admin/read-model.ts +518 -0
  107. package/src/routes/admin/streams.tsx +182 -0
  108. package/src/routes/admin/theme.ts +34 -0
  109. package/src/routes/admin/upload.tsx +320 -0
  110. package/src/routes/admin/views.tsx +293 -0
  111. package/src/routes/app/access.tsx +38 -0
  112. package/src/routes/app/app-context.ts +8 -0
  113. package/src/routes/app/appcast.ts +68 -0
  114. package/src/routes/app/assets.ts +25 -0
  115. package/src/routes/app/download.ts +45 -0
  116. package/src/routes/app/get.tsx +35 -0
  117. package/src/routes/app/index.ts +31 -0
  118. package/src/routes/app/resolve.ts +16 -0
  119. package/src/services/anchor.ts +54 -0
  120. package/src/services/audit.ts +19 -0
  121. package/src/services/branding.ts +51 -0
  122. package/src/services/email-cloudflare.ts +80 -0
  123. package/src/services/email.ts +68 -0
  124. package/src/services/self-update.ts +62 -0
  125. package/src/views/access-page.tsx +39 -0
  126. package/src/views/admin/ci-page.tsx +76 -0
  127. package/src/views/admin/combobox.tsx +191 -0
  128. package/src/views/admin/forms.tsx +37 -0
  129. package/src/views/admin/layout.tsx +496 -0
  130. package/src/views/admin/manage-pages.tsx +223 -0
  131. package/src/views/admin/manage.tsx +1120 -0
  132. package/src/views/admin/plist-extract.ts +233 -0
  133. package/src/views/admin/read-pages.tsx +1098 -0
  134. package/src/views/admin/setup-page.tsx +108 -0
  135. package/src/views/admin/table-enhance.ts +176 -0
  136. package/src/views/admin/ui.tsx +159 -0
  137. package/src/views/get-page.tsx +39 -0
  138. package/src/views/layout.tsx +57 -0
  139. package/src/worker.ts +23 -0
  140. package/tsconfig.json +35 -0
@@ -0,0 +1,1120 @@
1
+ import type { FC } from "hono/jsx";
2
+ import type { Stream } from "../../core/types";
3
+ import { formatBytes } from "../../core/verdict";
4
+ import type {
5
+ BuildDetail,
6
+ SelfUpdateView,
7
+ StreamDetail,
8
+ UserDetail,
9
+ } from "../../routes/admin/read-model";
10
+ import type { EmailStatus } from "../../services/email";
11
+ import { Combobox } from "./combobox";
12
+ import { COPY_SCRIPT, Post } from "./forms";
13
+ import { AdminLayout, type Chrome } from "./layout";
14
+ import { ARCHIVE_AUTOFILL_SCRIPT } from "./plist-extract";
15
+ import { BuildTags, Dot, Lk, Tag, VerdictCell, When } from "./ui";
16
+
17
+ // The per-entity detail pages plus the Upload and Settings forms — quiet-instrument style: identity
18
+ // header, a verdict strip computed by the same resolver the runtime uses, slab-labeled relation
19
+ // sections with entity rows (never walls of buttons), and a quarantined danger zone. Every mutation
20
+ // form carries return_to so the operator lands back here with a flash.
21
+
22
+ const ret = (path: string) => ({ return_to: path });
23
+
24
+ // ————————————————————————————— User —————————————————————————————
25
+
26
+ export const UserManagePage: FC<{
27
+ detail: UserDetail;
28
+ inviteLink: string;
29
+ /** False when the public App host can't be derived (local dev, custom domain) — the link then
30
+ carries THIS admin host and won't work for the tester. */
31
+ linkDerived: boolean;
32
+ now: string;
33
+ chrome: Chrome;
34
+ }> = ({ detail, inviteLink, linkDerived, now, chrome }) => {
35
+ const { client, channels, assignedStreamIds, availableBuilds, currentBuild, verdict } = detail;
36
+ const here = `/admin/users/${client.id}`;
37
+ const assigned = new Set(assignedStreamIds);
38
+ const assignedChannels = channels.filter((s) => assigned.has(s.id));
39
+ const unassigned = channels.filter((s) => !assigned.has(s.id));
40
+ const revoked = client.status === "revoked";
41
+
42
+ return (
43
+ <AdminLayout
44
+ title={client.email}
45
+ chrome={chrome}
46
+ crumb={
47
+ <>
48
+ <a href="/admin/users">Users</a> / {client.email}
49
+ </>
50
+ }
51
+ head={
52
+ <p class="sub">
53
+ {client.label ? <>{client.label} · </> : null}
54
+ invited <When iso={client.createdAt} now={now} />
55
+ {revoked ? (
56
+ <>
57
+ {" "}
58
+ <Tag kind="mut" label="revoked" title="Not served; reactivate to restore" />
59
+ </>
60
+ ) : null}
61
+ {client.hidden ? (
62
+ <>
63
+ {" "}
64
+ <Tag kind="mut" label="hidden" title="Hidden from the Users list only" />
65
+ </>
66
+ ) : null}
67
+ </p>
68
+ }
69
+ >
70
+ <div class="verdict">
71
+ <Dot
72
+ kind={
73
+ verdict.kind === "offered" || verdict.kind === "up-to-date"
74
+ ? "ok"
75
+ : verdict.kind === "revoked"
76
+ ? "off"
77
+ : "warn"
78
+ }
79
+ />
80
+ <span>
81
+ Next check → <VerdictCell verdict={verdict} />
82
+ </span>
83
+ </div>
84
+
85
+ <section aria-label="Facts">
86
+ <div class="slab">
87
+ <h2>Facts</h2>
88
+ <a href={`/admin/activity?email=${encodeURIComponent(client.email)}`}>activity →</a>
89
+ </div>
90
+ <dl class="facts">
91
+ <div>
92
+ <dt>Installed</dt>
93
+ <dd>
94
+ {currentBuild !== null ? (
95
+ <b class="num">#{currentBuild}</b>
96
+ ) : (
97
+ <span class="mut">nothing reported yet</span>
98
+ )}
99
+ </dd>
100
+ </div>
101
+ <div>
102
+ <dt>Last check</dt>
103
+ <dd>
104
+ <When iso={detail.lastCheck} now={now} />
105
+ </dd>
106
+ </div>
107
+ <div>
108
+ <dt>Last install</dt>
109
+ <dd>
110
+ <When iso={detail.lastInstalled} now={now} />
111
+ </dd>
112
+ </div>
113
+ <div>
114
+ <dt>Last update</dt>
115
+ <dd>
116
+ <When iso={detail.lastUpdated} now={now} />
117
+ </dd>
118
+ </div>
119
+ </dl>
120
+ </section>
121
+
122
+ <section aria-label="Channels">
123
+ <div class="slab">
124
+ <h2>Channels</h2>
125
+ <span class="hint">the highest available build across these is what they're offered</span>
126
+ </div>
127
+ {assignedChannels.length === 0 ? (
128
+ <p class="empty">No channels — this user is offered nothing (unless pinned).</p>
129
+ ) : (
130
+ <ul class="rows">
131
+ {assignedChannels.map((s) => (
132
+ <li>
133
+ <span class="chs">
134
+ <a href={`/admin/streams/${s.id}`}>{s.name}</a>
135
+ </span>
136
+ <form method="post" action={`/admin/clients/${client.id}/streams/unassign`}>
137
+ <input type="hidden" name="streamId" value={s.id} />
138
+ <input type="hidden" name="return_to" value={here} />
139
+ <button type="submit">Remove</button>
140
+ </form>
141
+ </li>
142
+ ))}
143
+ </ul>
144
+ )}
145
+ {unassigned.length > 0 ? (
146
+ <form method="post" action={`/admin/clients/${client.id}/streams/assign`} class="actions">
147
+ <input type="hidden" name="return_to" value={here} />
148
+ <label class="field">
149
+ <span class="sr-only">Channel to assign</span>
150
+ <select name="streamId">
151
+ {unassigned.map((s) => (
152
+ <option value={s.id}>{s.name}</option>
153
+ ))}
154
+ </select>
155
+ </label>
156
+ <button type="submit">Assign channel</button>
157
+ </form>
158
+ ) : null}
159
+ </section>
160
+
161
+ <section aria-label="Pin">
162
+ <div class="slab">
163
+ <h2>Pin</h2>
164
+ <span class="hint">
165
+ a pin overrides channels entirely — remove it to resume normal flow
166
+ </span>
167
+ </div>
168
+ {detail.pinnedBuild !== null ? (
169
+ <ul class="rows">
170
+ <li>
171
+ <span>
172
+ pinned to <Lk build={detail.pinnedBuild} />
173
+ </span>
174
+ <Post action={`/admin/clients/${client.id}/unpin`} label="Unpin" hidden={ret(here)} />
175
+ </li>
176
+ </ul>
177
+ ) : client.pinnedBuildId !== null ? (
178
+ <div class="callout warn">
179
+ Pinned to a build that no longer resolves — unpin to restore channel flow.
180
+ <Post action={`/admin/clients/${client.id}/unpin`} label="Unpin" hidden={ret(here)} />
181
+ </div>
182
+ ) : availableBuilds.length > 0 ? (
183
+ <form method="post" action={`/admin/clients/${client.id}/pin`} class="actions">
184
+ <input type="hidden" name="return_to" value={here} />
185
+ <Combobox
186
+ name="buildId"
187
+ label="Build to pin"
188
+ placeholder="Type a build number or version…"
189
+ required
190
+ options={availableBuilds.map((b) => ({
191
+ value: String(b.id),
192
+ label: `#${b.buildNumber} · v${b.shortVersion}`,
193
+ }))}
194
+ />
195
+ <button type="submit">Pin to build</button>
196
+ </form>
197
+ ) : (
198
+ <p class="empty">No available builds to pin to.</p>
199
+ )}
200
+ </section>
201
+
202
+ <section aria-label="Invite link">
203
+ <div class="slab">
204
+ <h2>Invite link</h2>
205
+ <span class="hint">viewing this never changes the token</span>
206
+ </div>
207
+ {revoked ? (
208
+ <p class="callout warn">
209
+ This user is revoked — the link below is dead until you reactivate them.
210
+ </p>
211
+ ) : null}
212
+ {!linkDerived ? (
213
+ <p class="callout warn">
214
+ The public host can't be derived from this admin host (local dev or a custom domain), so
215
+ the link below carries the <strong>admin</strong> host — testers can't open it. On a
216
+ deployed workers.dev instance it carries the public host.
217
+ </p>
218
+ ) : null}
219
+ <code class="token" id="invite-link">
220
+ {inviteLink}
221
+ </code>
222
+ <div class="actions">
223
+ <button type="button" class="btn" data-copy="#invite-link">
224
+ Copy link
225
+ </button>
226
+ </div>
227
+ <p class="fhint">
228
+ The durable private link for this user — they revisit it to re-download or re-activate. If
229
+ it leaked, use <strong>Reissue</strong> below to replace it.
230
+ </p>
231
+ </section>
232
+
233
+ <section class="dangerzone" aria-label="Access">
234
+ <div class="slab">
235
+ <h2>Access</h2>
236
+ </div>
237
+ <p>
238
+ {revoked
239
+ ? "Reactivate restores access on the existing link. Reissue also mints a fresh link."
240
+ : "Reissue replaces the link and the installed app's token. Revoke cuts access — reversible via Reactivate."}
241
+ </p>
242
+ <div class="actions">
243
+ {revoked ? (
244
+ <Post
245
+ action={`/admin/clients/${client.id}/reactivate`}
246
+ label="Reactivate"
247
+ hidden={ret(here)}
248
+ />
249
+ ) : null}
250
+ <Post
251
+ action={`/admin/clients/${client.id}/reissue`}
252
+ label={revoked ? "Reactivate with a fresh link…" : "Reissue link…"}
253
+ hidden={ret(here)}
254
+ />
255
+ {!revoked ? (
256
+ <Post
257
+ action={`/admin/clients/${client.id}/revoke`}
258
+ label="Revoke access…"
259
+ hidden={ret(here)}
260
+ />
261
+ ) : null}
262
+ <Post
263
+ action={`/admin/clients/${client.id}/hidden`}
264
+ label={client.hidden ? "Unhide from list" : "Hide from list"}
265
+ hidden={{ hidden: client.hidden ? "false" : "true", ...ret(here) }}
266
+ />
267
+ </div>
268
+ </section>
269
+ <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
270
+ </AdminLayout>
271
+ );
272
+ };
273
+
274
+ // ————————————————————————————— Build —————————————————————————————
275
+
276
+ export const BuildManagePage: FC<{ detail: BuildDetail; now: string; chrome: Chrome }> = ({
277
+ detail,
278
+ now,
279
+ chrome,
280
+ }) => {
281
+ const { build, channels, linkedStreamIds, audience } = detail;
282
+ const here = `/admin/builds/${build.id}`;
283
+ const linked = new Set(linkedStreamIds);
284
+ const linkedChannels = channels.filter((s) => linked.has(s.id));
285
+ const unlinkedChannels = channels.filter((s) => !linked.has(s.id));
286
+ const fileName = build.objectKey.split("/").at(-1) ?? build.objectKey;
287
+ const reach = audience.offeredTo + audience.currentFor;
288
+
289
+ return (
290
+ <AdminLayout
291
+ title={`#${build.buildNumber} · v${build.shortVersion}`}
292
+ chrome={chrome}
293
+ crumb={
294
+ <>
295
+ <a href="/admin/builds">Builds</a> / #{build.buildNumber}
296
+ </>
297
+ }
298
+ head={
299
+ <p class="sub">
300
+ published <When iso={build.createdAt} now={now} /> <BuildTags build={build} />
301
+ </p>
302
+ }
303
+ >
304
+ <div class="verdict">
305
+ <Dot kind={build.status === "available" ? (reach > 0 ? "ok" : "off") : "off"} />
306
+ <span>
307
+ {build.status === "withdrawn" ? (
308
+ <>Withdrawn — offered to no one. Restore it to serve it again.</>
309
+ ) : reach === 0 ? (
310
+ <>Offered to no one right now — link a channel with assigned users.</>
311
+ ) : (
312
+ <>
313
+ {audience.offeredTo > 0 ? (
314
+ <>
315
+ offered to <b class="num">{audience.offeredTo}</b>{" "}
316
+ {audience.offeredTo === 1 ? "user" : "users"} on their next check
317
+ </>
318
+ ) : null}
319
+ {audience.offeredTo > 0 && audience.currentFor > 0 ? " · " : null}
320
+ {audience.currentFor > 0 ? (
321
+ <>
322
+ current for <b class="num">{audience.currentFor}</b>{" "}
323
+ {audience.currentFor === 1 ? "user" : "users"}
324
+ </>
325
+ ) : null}
326
+ </>
327
+ )}
328
+ </span>
329
+ </div>
330
+
331
+ <section aria-label="Artifact">
332
+ <div class="slab">
333
+ <h2>Artifact</h2>
334
+ <a href={`/admin/activity?build=${build.buildNumber}`}>
335
+ {detail.downloads} downloads · {detail.updates} updates →
336
+ </a>
337
+ </div>
338
+ <dl class="facts">
339
+ <div>
340
+ <dt>File</dt>
341
+ <dd>
342
+ <code>{fileName}</code> ·{" "}
343
+ <span title={`${build.length} bytes (the enclosure length)`}>
344
+ {formatBytes(build.length)}
345
+ </span>
346
+ </dd>
347
+ </div>
348
+ <div>
349
+ <dt>Minimum macOS</dt>
350
+ <dd>{build.minOs ?? <span class="mut">any</span>}</dd>
351
+ </div>
352
+ <div>
353
+ <dt>EdDSA signature</dt>
354
+ <dd>
355
+ <code>{build.edSignature}</code>
356
+ </dd>
357
+ </div>
358
+ <div>
359
+ <dt>Archive key</dt>
360
+ <dd>
361
+ <code>{build.objectKey}</code>
362
+ </dd>
363
+ </div>
364
+ {build.dmgObjectKey ? (
365
+ <div>
366
+ <dt>First-install DMG</dt>
367
+ <dd>
368
+ <code>{build.dmgObjectKey}</code>
369
+ {build.dmgLength !== null ? (
370
+ <>
371
+ {" "}
372
+ · <span title={`${build.dmgLength} bytes`}>{formatBytes(build.dmgLength)}</span>
373
+ </>
374
+ ) : null}
375
+ </dd>
376
+ </div>
377
+ ) : null}
378
+ </dl>
379
+ </section>
380
+
381
+ <section aria-label="Channels">
382
+ <div class="slab">
383
+ <h2>Channels</h2>
384
+ <span class="hint">
385
+ users in these channels are offered this build when it's their highest
386
+ </span>
387
+ </div>
388
+ {linkedChannels.length === 0 ? (
389
+ <p class="empty">In no channel — offered to no one until you link one.</p>
390
+ ) : (
391
+ <ul class="rows">
392
+ {linkedChannels.map((s) => (
393
+ <li>
394
+ <span class="chs">
395
+ <a href={`/admin/streams/${s.id}`}>{s.name}</a>
396
+ </span>
397
+ <form method="post" action={`/admin/builds/${build.id}/streams/unlink`}>
398
+ <input type="hidden" name="streamId" value={s.id} />
399
+ <input type="hidden" name="return_to" value={here} />
400
+ <button type="submit">Unlink</button>
401
+ </form>
402
+ </li>
403
+ ))}
404
+ </ul>
405
+ )}
406
+ {unlinkedChannels.length > 0 ? (
407
+ <form method="post" action={`/admin/builds/${build.id}/streams/link`} class="actions">
408
+ <input type="hidden" name="return_to" value={here} />
409
+ <label class="field">
410
+ <span class="sr-only">Channel to link</span>
411
+ <select name="streamId">
412
+ {unlinkedChannels.map((s) => (
413
+ <option value={s.id}>{s.name}</option>
414
+ ))}
415
+ </select>
416
+ </label>
417
+ <button type="submit">Link channel</button>
418
+ </form>
419
+ ) : null}
420
+ </section>
421
+
422
+ <section aria-label="State">
423
+ <div class="slab">
424
+ <h2>State</h2>
425
+ </div>
426
+ <div class="actions">
427
+ {build.status === "withdrawn" ? (
428
+ <Post action={`/admin/builds/${build.id}/restore`} label="Restore" hidden={ret(here)} />
429
+ ) : null}
430
+ <Post
431
+ action={`/admin/builds/${build.id}/critical`}
432
+ label={build.critical ? "Clear critical" : "Mark critical"}
433
+ hidden={{ critical: build.critical ? "false" : "true", ...ret(here) }}
434
+ />
435
+ <Post
436
+ action={`/admin/builds/${build.id}/rollback`}
437
+ label={build.rollbackTarget ? "Clear rollback target" : "Designate rollback target"}
438
+ hidden={{ rollback: build.rollbackTarget ? "false" : "true", ...ret(here) }}
439
+ />
440
+ <Post
441
+ action={`/admin/builds/${build.id}/hidden`}
442
+ label={build.hidden ? "Unhide from list" : "Hide from list"}
443
+ hidden={{ hidden: build.hidden ? "false" : "true", ...ret(here) }}
444
+ />
445
+ </div>
446
+ <p class="fhint">
447
+ Critical makes Sparkle treat the update as required. The rollback-target mark is a label
448
+ for the build you'd republish in an incident — it changes nothing by itself.
449
+ </p>
450
+ </section>
451
+
452
+ {build.status === "available" ? (
453
+ <section class="dangerzone" aria-label="Withdraw">
454
+ <div class="slab">
455
+ <h2>Withdraw</h2>
456
+ </div>
457
+ <p>
458
+ Stops offering this build to anyone. Users already on it keep it — and if nothing higher
459
+ serves them, they're stranded (you'll be asked to confirm in that case).
460
+ </p>
461
+ <div class="actions">
462
+ <Post
463
+ action={`/admin/builds/${build.id}/withdraw`}
464
+ label="Withdraw build…"
465
+ hidden={ret(here)}
466
+ />
467
+ </div>
468
+ </section>
469
+ ) : build.purgedAt === null ? (
470
+ <section class="dangerzone" aria-label="Purge archive">
471
+ <div class="slab">
472
+ <h2>Purge archive</h2>
473
+ </div>
474
+ <p>
475
+ Delete this withdrawn build's {formatBytes(build.length)} of archive bytes from R2 to
476
+ reclaim free-tier space. The record stays (counts and audit intact), but it can't be
477
+ restored afterwards — re-publish with a higher number if you need it back.
478
+ </p>
479
+ <div class="actions">
480
+ <Post
481
+ action={`/admin/builds/${build.id}/purge-archive`}
482
+ label="Purge archive…"
483
+ hidden={ret(here)}
484
+ />
485
+ </div>
486
+ </section>
487
+ ) : null}
488
+ </AdminLayout>
489
+ );
490
+ };
491
+
492
+ // ————————————————————————————— Channel —————————————————————————————
493
+
494
+ export const StreamManagePage: FC<{ detail: StreamDetail; now: string; chrome: Chrome }> = ({
495
+ detail,
496
+ now,
497
+ chrome,
498
+ }) => {
499
+ const {
500
+ stream,
501
+ linkedBuilds,
502
+ unlinkedBuilds,
503
+ topBuild,
504
+ assignedUsers,
505
+ unassignedUsers,
506
+ serving,
507
+ } = detail;
508
+ const here = `/admin/streams/${stream.id}`;
509
+ return (
510
+ <AdminLayout
511
+ title={stream.name}
512
+ chrome={chrome}
513
+ crumb={
514
+ <>
515
+ <a href="/admin/streams">Channels</a> / {stream.name}
516
+ </>
517
+ }
518
+ >
519
+ <div class="verdict">
520
+ <Dot kind={topBuild === null ? "off" : serving.faulted > 0 ? "warn" : "ok"} />
521
+ <span>
522
+ {topBuild === null ? (
523
+ <>
524
+ Serving <strong>nothing</strong> — no available build is linked. Assigned users get an
525
+ empty feed.
526
+ </>
527
+ ) : (
528
+ <>
529
+ Serving <Lk build={topBuild} href={`/admin/builds/${topBuild.id}`} /> →{" "}
530
+ <b>{serving.users}</b> {serving.users === 1 ? "user" : "users"}
531
+ {serving.faulted > 0 ? <span class="mut"> · {serving.faulted} faulted</span> : null}
532
+ {serving.willUpdate > 0 ? (
533
+ <span class="mut"> · {serving.willUpdate} will update</span>
534
+ ) : null}
535
+ </>
536
+ )}
537
+ </span>
538
+ </div>
539
+
540
+ <section aria-label="Builds">
541
+ <div class="slab">
542
+ <h2>Builds in this channel</h2>
543
+ <span class="hint">the highest available one is what the channel serves</span>
544
+ </div>
545
+ {linkedBuilds.length === 0 ? (
546
+ <p class="empty">None linked yet.</p>
547
+ ) : (
548
+ <ul class="rows">
549
+ {linkedBuilds.map((b) => (
550
+ <li>
551
+ <span>
552
+ <Lk build={b} href={`/admin/builds/${b.id}`} /> <BuildTags build={b} />
553
+ </span>
554
+ <span class="t">
555
+ <When iso={b.createdAt} now={now} />
556
+ </span>
557
+ <form method="post" action={`/admin/builds/${b.id}/streams/unlink`}>
558
+ <input type="hidden" name="streamId" value={stream.id} />
559
+ <input type="hidden" name="return_to" value={here} />
560
+ <button type="submit">Unlink</button>
561
+ </form>
562
+ </li>
563
+ ))}
564
+ </ul>
565
+ )}
566
+ {unlinkedBuilds.length > 0 ? (
567
+ <form method="post" action={`/admin/streams/${stream.id}/link`} class="actions">
568
+ <input type="hidden" name="return_to" value={here} />
569
+ <Combobox
570
+ name="buildId"
571
+ label="Builds to link"
572
+ placeholder="Type a build number or version…"
573
+ multiple
574
+ required
575
+ options={unlinkedBuilds.map((b) => ({
576
+ value: String(b.id),
577
+ label: `#${b.buildNumber} · v${b.shortVersion}`,
578
+ }))}
579
+ />
580
+ <button type="submit">Link builds</button>
581
+ </form>
582
+ ) : null}
583
+ </section>
584
+
585
+ <section aria-label="Users">
586
+ <div class="slab">
587
+ <h2>Users in this channel</h2>
588
+ </div>
589
+ {assignedUsers.length === 0 ? (
590
+ <p class="empty">No users assigned.</p>
591
+ ) : (
592
+ <ul class="rows">
593
+ {assignedUsers.map((u) => (
594
+ <li>
595
+ <span>
596
+ <a class="who" href={`/admin/users/${u.id}`}>
597
+ {u.email}
598
+ </a>{" "}
599
+ {u.status === "revoked" ? <Tag kind="mut" label="revoked" /> : null}
600
+ </span>
601
+ <span class="vd">
602
+ <VerdictCell verdict={u.verdict} />
603
+ </span>
604
+ <form method="post" action={`/admin/clients/${u.id}/streams/unassign`}>
605
+ <input type="hidden" name="streamId" value={stream.id} />
606
+ <input type="hidden" name="return_to" value={here} />
607
+ <button type="submit">Unassign</button>
608
+ </form>
609
+ </li>
610
+ ))}
611
+ </ul>
612
+ )}
613
+ {unassignedUsers.length > 0 ? (
614
+ <form method="post" action={`/admin/streams/${stream.id}/assign`} class="actions">
615
+ <input type="hidden" name="return_to" value={here} />
616
+ <Combobox
617
+ name="clientId"
618
+ label="Users to assign"
619
+ placeholder="Type an email…"
620
+ multiple
621
+ required
622
+ options={unassignedUsers.map((u) => ({ value: String(u.id), label: u.email }))}
623
+ />
624
+ <button type="submit">Assign users</button>
625
+ </form>
626
+ ) : null}
627
+ </section>
628
+
629
+ <section class="dangerzone" aria-label="Delete">
630
+ <div class="slab">
631
+ <h2>Delete channel</h2>
632
+ </div>
633
+ <p>
634
+ Unassigns its {assignedUsers.length} {assignedUsers.length === 1 ? "user" : "users"} and
635
+ unlinks its {linkedBuilds.length} {linkedBuilds.length === 1 ? "build" : "builds"} (the
636
+ users and builds themselves are kept). You'll be asked to confirm.
637
+ </p>
638
+ <div class="actions">
639
+ <Post
640
+ action={`/admin/streams/${stream.id}/delete`}
641
+ label="Delete channel…"
642
+ hidden={ret("/admin/streams")}
643
+ />
644
+ </div>
645
+ </section>
646
+ </AdminLayout>
647
+ );
648
+ };
649
+
650
+ // ————————————————————————————— Upload —————————————————————————————
651
+
652
+ export interface RecentBuild {
653
+ buildNumber: number;
654
+ shortVersion: string;
655
+ }
656
+
657
+ export const UploadPage: FC<{
658
+ channels: Stream[];
659
+ recentBuilds: RecentBuild[];
660
+ chrome: Chrome;
661
+ }> = ({ channels, recentBuilds, chrome }) => {
662
+ const topBuild = recentBuilds[0]?.buildNumber ?? null;
663
+ return (
664
+ <AdminLayout
665
+ title="Upload build"
666
+ chrome={chrome}
667
+ head={
668
+ <p class="sub">an already-signed, notarized archive — the Worker never signs anything</p>
669
+ }
670
+ >
671
+ <form
672
+ method="post"
673
+ action="/admin/builds/upload"
674
+ enctype="multipart/form-data"
675
+ data-archive-autofill
676
+ >
677
+ {/* Two modes, toggled with no JS (CSS :has, see layout). Both publish via the same endpoint;
678
+ rollback adds §9 guidance AND a server-side floor check (must exceed the highest build). */}
679
+ <div class="modes">
680
+ <label>
681
+ <input type="radio" name="mode" value="normal" id="mode-normal" checked /> New release
682
+ </label>
683
+ <label>
684
+ <input type="radio" name="mode" value="rollback" id="mode-rollback" /> Roll back
685
+ </label>
686
+ </div>
687
+
688
+ <div class="rollback-only">
689
+ <p class="callout warn">
690
+ A rollback is a <strong>roll-forward</strong>: Sparkle can't downgrade, so rebuild the
691
+ previous good code with a <strong>higher build number</strong> (keep its old version
692
+ string), publish it here, then withdraw the bad build.
693
+ {topBuild !== null ? (
694
+ <>
695
+ {" "}
696
+ The current highest is <b class="num">#{topBuild}</b> — your build number must
697
+ exceed it (enforced).
698
+ </>
699
+ ) : null}
700
+ </p>
701
+ {recentBuilds.length > 0 ? (
702
+ <p class="fhint">
703
+ Recent builds:{" "}
704
+ {recentBuilds.map((b, i) => (
705
+ <>
706
+ {i > 0 ? ", " : ""}
707
+ <code>
708
+ #{b.buildNumber} · v{b.shortVersion}
709
+ </code>
710
+ </>
711
+ ))}
712
+ . Withdraw the bad one from its build page after publishing.
713
+ </p>
714
+ ) : null}
715
+ </div>
716
+
717
+ <fieldset>
718
+ <legend>1 · Archive</legend>
719
+ <label class="field">
720
+ <span>Signed archive</span>
721
+ <input type="file" name="archive" required />
722
+ </label>
723
+ <p class="fhint">
724
+ Pick the signed <code>.app</code> <code>.zip</code> and the version + build fields fill
725
+ themselves from its Info.plist (editable). A <code>.dmg</code>/<code>.tar</code> can't
726
+ be read in the browser — type them in. Archives over ~90 MB use the CI register path
727
+ (see docs/operate/publish.md).
728
+ </p>
729
+ <p data-autofill-status hidden />
730
+ </fieldset>
731
+
732
+ <fieldset>
733
+ <legend>2 · Identity</legend>
734
+ <div class="frow">
735
+ <label class="field">
736
+ <span>Version</span>
737
+ <input name="short_version" placeholder="e.g. 1.4.0" required />
738
+ </label>
739
+ <label class="field">
740
+ <span>Build number</span>
741
+ <input name="build_number" class="mono" placeholder="e.g. 1500" required />
742
+ </label>
743
+ <label class="field">
744
+ <span>
745
+ Minimum macOS <i>· optional</i>
746
+ </span>
747
+ <input name="min_os" placeholder="e.g. 13.0" />
748
+ </label>
749
+ </div>
750
+ <label class="field" style="margin-top:14px">
751
+ <span>Sparkle EdDSA signature</span>
752
+ <input
753
+ name="ed_signature"
754
+ class="mono"
755
+ placeholder="the sign_update output for this exact file"
756
+ required
757
+ style="max-width:40rem"
758
+ />
759
+ </label>
760
+ <p class="fhint">
761
+ From Sparkle's <code>sign_update</code>, run against this exact file on your Mac. The
762
+ build number must increase on every publish.
763
+ </p>
764
+ </fieldset>
765
+
766
+ <fieldset>
767
+ <legend>3 · Destination</legend>
768
+ <div class="frow">
769
+ <label class="field">
770
+ <span>
771
+ Channel <i>· optional</i>
772
+ </span>
773
+ <select name="stream_id">
774
+ <option value="">— none yet —</option>
775
+ {channels.map((s) => (
776
+ <option value={s.id}>{s.name}</option>
777
+ ))}
778
+ </select>
779
+ </label>
780
+ <label class="field">
781
+ <span>Urgency</span>
782
+ <label>
783
+ <input type="checkbox" name="critical" value="true" /> mark critical (required
784
+ update)
785
+ </label>
786
+ </label>
787
+ </div>
788
+ <p class="fhint">
789
+ With no channel the build is published but offered to no one until you link it to a
790
+ channel that users are assigned to.
791
+ </p>
792
+ </fieldset>
793
+
794
+ <div class="actions">
795
+ <button type="submit" class="btn-primary">
796
+ Publish build
797
+ </button>
798
+ </div>
799
+ </form>
800
+ <script dangerouslySetInnerHTML={{ __html: ARCHIVE_AUTOFILL_SCRIPT }} />
801
+ </AdminLayout>
802
+ );
803
+ };
804
+
805
+ // ————————————————————————————— Settings —————————————————————————————
806
+
807
+ export interface SettingsInfo {
808
+ instance: string;
809
+ toolVersion: string;
810
+ email: EmailStatus;
811
+ accessTeam: string | null;
812
+ accessAud: string | null;
813
+ selfUpdate: SelfUpdateView;
814
+ /** The public App Worker origin (derived), shown so the operator can find their tester-facing host. */
815
+ appOrigin: string | null;
816
+ }
817
+
818
+ // The Email row of the instance panel: a status the admin can trust, because it's the same check that
819
+ // decides whether mail actually sends (services/email emailStatus). "incomplete" is the dangerous case —
820
+ // the provider says cloudflare but delivery silently falls back to copy-paste — so it's a warning.
821
+ const EmailStatusCell: FC<{ email: EmailStatus }> = ({ email }) =>
822
+ email.mode === "active" ? (
823
+ <>
824
+ <Tag kind="acc" label="sending" /> via Cloudflare · <code>{email.from}</code>
825
+ </>
826
+ ) : email.mode === "incomplete" ? (
827
+ <Tag kind="warn" label="misconfigured — falling back to copy-paste" />
828
+ ) : (
829
+ <span class="mut">copy-paste links (no email sent)</span>
830
+ );
831
+
832
+ // Shown below the instance panel whenever email isn't actively sending: what's missing (if anything)
833
+ // and the exact deploy command to turn it on. Hidden once email is "active" — nothing to do.
834
+ const EmailSetupPanel: FC<{ instance: string; email: EmailStatus }> = ({ instance, email }) => {
835
+ if (email.mode === "active") return null;
836
+ const flags = `--instance ${instance} --email-provider cloudflare --email-from alpha@<your-sending-domain>`;
837
+ const cmd = `./deploy/deploy.sh ${flags} # from a clone\nnpx alpha-gate deploy ${flags} # from npm`;
838
+ return (
839
+ <section aria-label="Email delivery">
840
+ <div class="slab">
841
+ <h2>Set up email delivery</h2>
842
+ </div>
843
+ {email.mode === "incomplete" ? (
844
+ <p class="callout warn">
845
+ Provider is set to Cloudflare but {email.missing.join(" and ")}{" "}
846
+ {email.missing.length > 1 ? "are" : "is"} missing — invites silently fall back to
847
+ copy-paste links until this is fixed.
848
+ </p>
849
+ ) : (
850
+ <p class="fhint">
851
+ Right now invites are <strong>copy-paste links</strong>: when you add a user (or invite a
852
+ request), the back office shows the link and message for you to send manually — no email
853
+ leaves the Worker. To send invites automatically:
854
+ </p>
855
+ )}
856
+ <ol class="fhint" style="padding-left:1.2rem">
857
+ <li>
858
+ Cloudflare Email Service needs the <strong>Workers Paid plan</strong> and an{" "}
859
+ <strong>onboarded sending domain</strong> (Cloudflare dashboard → Email → Email Routing →
860
+ add and verify the DNS records).
861
+ </li>
862
+ <li>
863
+ Re-run deploy with email turned on — this adds the <code>EMAIL</code> send_email binding
864
+ to this admin Worker and sets the From address:
865
+ <pre>
866
+ <code>{cmd}</code>
867
+ </pre>
868
+ </li>
869
+ <li>
870
+ Reload this page: the status above should read <Tag kind="acc" label="sending" />. Add a
871
+ user with your own address to receive a test invite.
872
+ </li>
873
+ </ol>
874
+ </section>
875
+ );
876
+ };
877
+
878
+ export const SettingsPage: FC<{
879
+ settings: Record<string, string>;
880
+ info: SettingsInfo;
881
+ now: string;
882
+ chrome: Chrome;
883
+ }> = ({ settings, info, now, chrome }) => (
884
+ <AdminLayout title="Settings" chrome={chrome}>
885
+ <section aria-label="This instance">
886
+ <div class="slab">
887
+ <h2>This instance</h2>
888
+ <a href="/admin/ci">publishing from CI →</a>
889
+ </div>
890
+ <dl class="facts">
891
+ <div>
892
+ <dt>Instance</dt>
893
+ <dd>
894
+ <code>{info.instance}</code>
895
+ </dd>
896
+ </div>
897
+ <div>
898
+ <dt>Tool version</dt>
899
+ <dd>{info.toolVersion}</dd>
900
+ </div>
901
+ <div>
902
+ <dt>Public host</dt>
903
+ <dd>
904
+ {info.appOrigin ? (
905
+ <code>{info.appOrigin}</code>
906
+ ) : (
907
+ <span class="mut">not derivable from this host</span>
908
+ )}
909
+ </dd>
910
+ </div>
911
+ <div>
912
+ <dt>Email</dt>
913
+ <dd>
914
+ <EmailStatusCell email={info.email} />
915
+ </dd>
916
+ </div>
917
+ <div>
918
+ <dt>Access team</dt>
919
+ <dd>{info.accessTeam ?? <span class="mut">—</span>}</dd>
920
+ </div>
921
+ <div>
922
+ <dt>Access AUD</dt>
923
+ <dd class="mut">{info.accessAud ?? "—"}</dd>
924
+ </div>
925
+ <div>
926
+ <dt>Self-update</dt>
927
+ <dd>
928
+ {info.selfUpdate.available ? (
929
+ <>
930
+ <Tag
931
+ kind="warn"
932
+ label={`${info.selfUpdate.latest} available${info.selfUpdate.breaking ? " (breaking)" : ""}`}
933
+ />{" "}
934
+ — update and re-deploy
935
+ {info.selfUpdate.notesUrl ? (
936
+ <>
937
+ {" "}
938
+ · <a href={info.selfUpdate.notesUrl}>notes</a>
939
+ </>
940
+ ) : null}
941
+ </>
942
+ ) : info.selfUpdate.checkedAt === null ? (
943
+ <span class="mut">
944
+ not checked yet — the daily cron reports here (fires within 24h of deploy)
945
+ </span>
946
+ ) : (
947
+ <>
948
+ <span class="mut">up to date</span>{" "}
949
+ <span class="mut">
950
+ · checked <When iso={info.selfUpdate.checkedAt} now={now} />
951
+ </span>
952
+ </>
953
+ )}
954
+ </dd>
955
+ </div>
956
+ </dl>
957
+ </section>
958
+
959
+ <EmailSetupPanel instance={info.instance} email={info.email} />
960
+
961
+ {info.email.mode === "active" ? (
962
+ <section aria-label="Test email">
963
+ <div class="slab">
964
+ <h2>Test email delivery</h2>
965
+ </div>
966
+ <form method="post" action="/admin/settings/test-email" class="frow">
967
+ <label class="field">
968
+ <span>
969
+ Recipient <i>· defaults to you</i>
970
+ </span>
971
+ <input type="email" name="to" placeholder="you@example.com" />
972
+ </label>
973
+ <button type="submit">Send test email</button>
974
+ </form>
975
+ <p class="fhint">
976
+ Sends one email now and shows the exact result — the fastest way to debug delivery without
977
+ creating a user. If it fails, run <code>wrangler tail</code> on the admin Worker for the
978
+ full provider error.
979
+ </p>
980
+ </section>
981
+ ) : null}
982
+
983
+ <form method="post" action="/admin/branding" enctype="multipart/form-data">
984
+ <fieldset>
985
+ <legend>Download-page branding</legend>
986
+ <div class="frow">
987
+ <label class="field">
988
+ <span>App name</span>
989
+ <input name="app_name" value={settings.app_name ?? ""} placeholder="e.g. Acme" />
990
+ </label>
991
+ <label class="field">
992
+ <span>
993
+ Blurb <i>· optional</i>
994
+ </span>
995
+ <input
996
+ name="blurb"
997
+ value={settings.blurb ?? ""}
998
+ placeholder="one line under the name"
999
+ />
1000
+ </label>
1001
+ <label class="field">
1002
+ <span>Accent colour</span>
1003
+ <input name="accent" class="mono" value={settings.accent ?? ""} placeholder="#0A84FF" />
1004
+ </label>
1005
+ </div>
1006
+ <div class="frow">
1007
+ <label class="field">
1008
+ <span>
1009
+ Icon <i>· PNG/JPEG/WebP</i>
1010
+ </span>
1011
+ <input type="file" name="icon" accept="image/png,image/jpeg,image/webp" />
1012
+ </label>
1013
+ <label class="field">
1014
+ <span>
1015
+ Header image <i>· PNG/JPEG/WebP</i>
1016
+ </span>
1017
+ <input type="file" name="header" accept="image/png,image/jpeg,image/webp" />
1018
+ </label>
1019
+ </div>
1020
+ </fieldset>
1021
+
1022
+ <fieldset>
1023
+ <legend>App activation</legend>
1024
+ <div class="frow">
1025
+ <label class="field">
1026
+ <span>Activate URL scheme</span>
1027
+ <input
1028
+ name="activate_scheme"
1029
+ class="mono"
1030
+ value={settings.activate_scheme ?? ""}
1031
+ placeholder="myapp"
1032
+ />
1033
+ </label>
1034
+ <label class="field">
1035
+ <span>Sparkle public key (SUPublicEDKey)</span>
1036
+ <input
1037
+ name="sparkle_public_key"
1038
+ class="mono"
1039
+ value={settings.sparkle_public_key ?? ""}
1040
+ placeholder="from generate_keys — not secret"
1041
+ style="min-width:24rem"
1042
+ />
1043
+ </label>
1044
+ </div>
1045
+ <p class="fhint">
1046
+ The Activate button on the download page links to{" "}
1047
+ <code>&lt;scheme&gt;://activate?token=…</code> — it must match the URL scheme your macOS
1048
+ app registers in its Info.plist. The public key feeds the ready-to-paste snippet on{" "}
1049
+ <a href="/admin/setup">Setup</a>.
1050
+ </p>
1051
+ </fieldset>
1052
+
1053
+ <fieldset>
1054
+ <legend>Invite email template</legend>
1055
+ <label class="field">
1056
+ <span>
1057
+ Subject <i>· supports {"{app_name}"}</i>
1058
+ </span>
1059
+ <input
1060
+ name="invite_subject"
1061
+ value={settings.invite_subject ?? ""}
1062
+ placeholder="Your {app_name} alpha invite"
1063
+ style="max-width:40rem"
1064
+ />
1065
+ </label>
1066
+ <label class="field" style="margin-top:14px">
1067
+ <span>
1068
+ Body{" "}
1069
+ <i>
1070
+ · supports {"{app_name}"} {"{get_url}"} {"{token}"}
1071
+ </i>
1072
+ </span>
1073
+ <textarea
1074
+ name="invite_body"
1075
+ placeholder="Hi — here's your private download link: {get_url}"
1076
+ >
1077
+ {settings.invite_body ?? ""}
1078
+ </textarea>
1079
+ </label>
1080
+ <p class="fhint">
1081
+ Feeds both real email and the copy-paste message shown after you add a user.
1082
+ </p>
1083
+ </fieldset>
1084
+
1085
+ <fieldset>
1086
+ <legend>Access notice (revoked or unknown tokens)</legend>
1087
+ <label class="field">
1088
+ <span>
1089
+ Title <i>· optional</i>
1090
+ </span>
1091
+ <input
1092
+ name="notice_title"
1093
+ value={settings.notice_title ?? ""}
1094
+ placeholder="Reactivate your access"
1095
+ style="max-width:40rem"
1096
+ />
1097
+ </label>
1098
+ <label class="field" style="margin-top:14px">
1099
+ <span>
1100
+ Message <i>· supports {"{app_name}"}</i>
1101
+ </span>
1102
+ <textarea name="notice_message" placeholder="Your access needs to be renewed.">
1103
+ {settings.notice_message ?? ""}
1104
+ </textarea>
1105
+ </label>
1106
+ <p class="fhint">
1107
+ Shown by Sparkle when a token is revoked or unknown — an informational notice (no install)
1108
+ linking to your access page. A valid user with no build sees nothing. Leave blank for the
1109
+ default.
1110
+ </p>
1111
+ </fieldset>
1112
+
1113
+ <div class="actions">
1114
+ <button type="submit" class="btn-primary">
1115
+ Save settings
1116
+ </button>
1117
+ </div>
1118
+ </form>
1119
+ </AdminLayout>
1120
+ );