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,233 @@
1
+ // Browser-side autofill for the §13 upload form: when the admin picks the signed .app zip, read the
2
+ // app's version straight out of the archive so they don't retype what the binary already declares.
3
+ //
4
+ // The two values map exactly onto the bundle's Info.plist (and onto Sparkle, see core/appcast.ts):
5
+ // CFBundleVersion → build_number → sparkle:version (the monotonic compare key)
6
+ // CFBundleShortVersionString → short_version → sparkle:shortVersionString (the human display string)
7
+ // LSMinimumSystemVersion → min_os (optional)
8
+ //
9
+ // The fields stay editable: the register path (large/CI uploads) never reaches this code, and a §9
10
+ // rollback deliberately uses a build_number that differs from the binary — so this is a convenience that
11
+ // prefills, never the source of truth.
12
+ //
13
+ // locateInfoPlist + parseInfoPlist are PURE (Uint8Array in, plain data out) and unit-tested with real
14
+ // fixtures. The script serialises them via toString() so the browser runs the tested code; the thin
15
+ // async glue (file read + DEFLATE inflate + field fill) is hand-written and verified in a headless harness.
16
+
17
+ export interface ZipEntry {
18
+ /** Compression method: 0 = stored, 8 = raw DEFLATE. */
19
+ method: number;
20
+ /** Byte offset of the entry's (possibly compressed) data within the zip. */
21
+ start: number;
22
+ /** Compressed byte length. */
23
+ length: number;
24
+ }
25
+
26
+ export interface InfoPlist {
27
+ shortVersion: string | null;
28
+ buildNumber: string | null;
29
+ minOs: string | null;
30
+ }
31
+
32
+ /**
33
+ * Locate the top-level app's `Info.plist` inside a zip (random-access over the central directory, so a
34
+ * ~90 MB archive isn't walked linearly). Returns where its bytes live + how they're compressed, or null
35
+ * when the zip has no such entry. Pure: a plain function over the whole-file byte array.
36
+ */
37
+ export function locateInfoPlist(zip: Uint8Array): ZipEntry | null {
38
+ // Read through a DataView so byte access is a definite number (zip is little-endian throughout).
39
+ const dv = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
40
+ const u16 = (p: number): number => dv.getUint16(p, true);
41
+ const u32 = (p: number): number => dv.getUint32(p, true);
42
+
43
+ // End Of Central Directory: scan backwards for its signature (a trailing comment may follow it).
44
+ let eocd = -1;
45
+ const minPos = Math.max(0, zip.length - 22 - 0xffff);
46
+ for (let p = zip.length - 22; p >= minPos; p--) {
47
+ if (u32(p) === 0x06054b50) {
48
+ eocd = p;
49
+ break;
50
+ }
51
+ }
52
+ if (eocd < 0) return null;
53
+
54
+ const cdOffset = u32(eocd + 16);
55
+ const cdEnd = cdOffset + u32(eocd + 12);
56
+ const isInfoPlist = (name: string): boolean =>
57
+ /(^|\/)[^/]+\.app\/Contents\/Info\.plist$/.test(name);
58
+
59
+ const matches: { name: string; method: number; comp: number; lho: number }[] = [];
60
+ let p = cdOffset;
61
+ while (p + 46 <= cdEnd && u32(p) === 0x02014b50) {
62
+ const method = u16(p + 10);
63
+ const comp = u32(p + 20);
64
+ const nameLen = u16(p + 28);
65
+ const extraLen = u16(p + 30);
66
+ const commentLen = u16(p + 32);
67
+ const lho = u32(p + 42);
68
+ const name = new TextDecoder().decode(zip.subarray(p + 46, p + 46 + nameLen));
69
+ if (isInfoPlist(name)) matches.push({ name, method, comp, lho });
70
+ p += 46 + nameLen + extraLen + commentLen;
71
+ }
72
+
73
+ // Prefer the outermost app (a notarized bundle can nest helper .apps with their own Info.plist).
74
+ matches.sort((a, b) => a.name.split("/").length - b.name.split("/").length);
75
+ const m = matches[0];
76
+ if (!m) return null;
77
+
78
+ // The local header repeats name/extra lengths and may differ from the central record — read it here.
79
+ if (u32(m.lho) !== 0x04034b50) return null;
80
+ const start = m.lho + 30 + u16(m.lho + 26) + u16(m.lho + 28);
81
+ return { method: m.method, start, length: m.comp };
82
+ }
83
+
84
+ /**
85
+ * Parse the three version keys from an `Info.plist` — binary (`bplist00`, what Xcode ships) or XML.
86
+ * Pure; returns nulls for anything missing or unparseable rather than throwing. Only the top-level dict
87
+ * and string values are read (versions are always strings); other plist types are ignored.
88
+ */
89
+ export function parseInfoPlist(bytes: Uint8Array): InfoPlist {
90
+ // Declared inside the function: it is serialised by toString() into the browser, where a module-scope
91
+ // constant wouldn't exist (the same self-containment rule as the inner helpers — see decision 0012).
92
+ const WANTED_KEYS = ["CFBundleShortVersionString", "CFBundleVersion", "LSMinimumSystemVersion"];
93
+ const found: Record<string, string> = {};
94
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
95
+ const decode = (start: number, len: number, enc?: string): string =>
96
+ new TextDecoder(enc).decode(bytes.subarray(start, start + len));
97
+
98
+ if (bytes.length >= 8 && decode(0, 8) === "bplist00") {
99
+ const n = bytes.length;
100
+ const readBE = (pos: number, size: number): number => {
101
+ let v = 0;
102
+ for (let i = 0; i < size; i++) v = v * 256 + dv.getUint8(pos + i);
103
+ return v;
104
+ };
105
+ const offsetIntSize = dv.getUint8(n - 26);
106
+ const objectRefSize = dv.getUint8(n - 25);
107
+ const topObject = readBE(n - 16, 8);
108
+ const offsetTableOffset = readBE(n - 8, 8);
109
+ const objOffset = (idx: number): number =>
110
+ readBE(offsetTableOffset + idx * offsetIntSize, offsetIntSize);
111
+
112
+ // A length nibble of 0xF means "an int object holds the real count"; advance past it.
113
+ const sizedHeader = (pos: number, nibble: number): { count: number; pos: number } => {
114
+ if (nibble !== 0x0f) return { count: nibble, pos };
115
+ const intSize = 1 << (dv.getUint8(pos) & 0x0f);
116
+ return { count: readBE(pos + 1, intSize), pos: pos + 1 + intSize };
117
+ };
118
+
119
+ const readString = (idx: number): string | null => {
120
+ const off = objOffset(idx);
121
+ const marker = dv.getUint8(off);
122
+ const h = sizedHeader(off + 1, marker & 0x0f);
123
+ if ((marker & 0xf0) === 0x50) return decode(h.pos, h.count); // ASCII
124
+ if ((marker & 0xf0) === 0x60) return decode(h.pos, h.count * 2, "utf-16be"); // UTF-16BE
125
+ return null;
126
+ };
127
+
128
+ let p = objOffset(topObject);
129
+ const marker = dv.getUint8(p++);
130
+ if ((marker & 0xf0) === 0xd0) {
131
+ const h = sizedHeader(p, marker & 0x0f);
132
+ const count = h.count;
133
+ p = h.pos;
134
+ const keyRefs: number[] = [];
135
+ const valRefs: number[] = [];
136
+ for (let i = 0; i < count; i++) {
137
+ keyRefs.push(readBE(p, objectRefSize));
138
+ p += objectRefSize;
139
+ }
140
+ for (let i = 0; i < count; i++) {
141
+ valRefs.push(readBE(p, objectRefSize));
142
+ p += objectRefSize;
143
+ }
144
+ for (let i = 0; i < count; i++) {
145
+ const kr = keyRefs[i];
146
+ const vr = valRefs[i];
147
+ if (kr === undefined || vr === undefined) continue;
148
+ const key = readString(kr);
149
+ if (key !== null && WANTED_KEYS.indexOf(key) >= 0) {
150
+ const val = readString(vr);
151
+ if (val !== null) found[key] = val;
152
+ }
153
+ }
154
+ }
155
+ } else {
156
+ const text = new TextDecoder().decode(bytes);
157
+ for (const key of WANTED_KEYS) {
158
+ const g = new RegExp(`<key>\\s*${key}\\s*</key>\\s*<string>([^<]*)</string>`).exec(text)?.[1];
159
+ if (g !== undefined) found[key] = g.trim();
160
+ }
161
+ }
162
+
163
+ return {
164
+ shortVersion: found.CFBundleShortVersionString ?? null,
165
+ buildNumber: found.CFBundleVersion ?? null,
166
+ minOs: found.LSMinimumSystemVersion ?? null,
167
+ };
168
+ }
169
+
170
+ // Hand-written async glue: read the picked file, locate + inflate Info.plist, prefill the form, and
171
+ // ALWAYS report the outcome (success or "couldn't read this — type it") so a failed autofill is never
172
+ // silent. Fills only empty or still-auto-filled fields, so a value the admin typed (e.g. a rollback
173
+ // build_number) is never clobbered when they pick the file.
174
+ const GLUE = `
175
+ (function () {
176
+ var form = document.querySelector("[data-archive-autofill]");
177
+ if (!form) return;
178
+ var file = form.querySelector('input[type="file"]');
179
+ var status = form.querySelector("[data-autofill-status]");
180
+ if (!file) return;
181
+
182
+ function say(msg, ok) {
183
+ if (!status) return;
184
+ status.textContent = msg;
185
+ status.className = ok ? "fhint" : "callout warn";
186
+ status.hidden = false;
187
+ }
188
+ form.addEventListener("input", function (e) {
189
+ if (e.target && e.target !== file && e.target.dataset) e.target.dataset.autofilled = "";
190
+ });
191
+ function set(name, value) {
192
+ var el = form.querySelector('[name="' + name + '"]');
193
+ if (!el || !value) return;
194
+ if (el.value && el.dataset.autofilled !== "1") return; // keep what the admin typed
195
+ el.value = value;
196
+ el.dataset.autofilled = "1";
197
+ }
198
+
199
+ file.addEventListener("change", function () {
200
+ if (status) status.hidden = true;
201
+ var f = file.files && file.files[0];
202
+ if (!f) return;
203
+ if (typeof DecompressionStream === "undefined") return; // ancient browser; stay silent
204
+ f.arrayBuffer().then(function (buf) {
205
+ var zip = new Uint8Array(buf);
206
+ var loc = locateInfoPlist(zip);
207
+ if (!loc) throw new Error("not-an-app-zip");
208
+ var slice = zip.subarray(loc.start, loc.start + loc.length);
209
+ if (loc.method === 0) return slice;
210
+ var stream = new Blob([slice]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
211
+ return new Response(stream).arrayBuffer().then(function (b) { return new Uint8Array(b); });
212
+ }).then(function (plist) {
213
+ var info = parseInfoPlist(plist);
214
+ if (!info.shortVersion && !info.buildNumber) throw new Error("no-version-keys");
215
+ set("short_version", info.shortVersion);
216
+ set("build_number", info.buildNumber);
217
+ set("min_os", info.minOs);
218
+ say("Filled version " + (info.shortVersion || "?") + " / build " + (info.buildNumber || "?") +
219
+ " from the archive — edit if you're rolling forward.", true);
220
+ }).catch(function () {
221
+ say("Couldn't read the version from this archive. Autofill needs the signed .app .zip " +
222
+ "(a .dmg or .tar can't be read) — enter version and build below.", false);
223
+ });
224
+ });
225
+ })();
226
+ `;
227
+
228
+ // Injected once by the upload page. `__name` is an identity shim for esbuild's keep-names helper, which
229
+ // wraps named inner functions and would otherwise be undefined in the browser (see decision 0012).
230
+ export const ARCHIVE_AUTOFILL_SCRIPT = `var __name = function (t) { return t; };
231
+ var locateInfoPlist = ${locateInfoPlist.toString()};
232
+ var parseInfoPlist = ${parseInfoPlist.toString()};
233
+ ${GLUE}`;