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,160 @@
|
|
|
1
|
+
import { resolve } from "./resolver";
|
|
2
|
+
import type { Build, BuildStreamLink, Client, ResolverResult, UserStreamLink } from "./types";
|
|
3
|
+
|
|
4
|
+
// §11 — servability and the no-build state, sharing the §8 resolver so the confirmation preview and
|
|
5
|
+
// runtime resolution can never disagree (a bug here would silently strand users). Pure over plain
|
|
6
|
+
// data; the installed-build numbers come from the access log (the impure source is the caller's).
|
|
7
|
+
|
|
8
|
+
/** The relational slice the §11 computations operate on. */
|
|
9
|
+
export interface World {
|
|
10
|
+
clients: readonly Client[];
|
|
11
|
+
builds: readonly Build[];
|
|
12
|
+
buildStreams: readonly BuildStreamLink[];
|
|
13
|
+
userStreams: readonly UserStreamLink[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A proposed admin mutation whose blast radius §11 wants computed before it is applied. */
|
|
17
|
+
export type AdminAction =
|
|
18
|
+
| { type: "withdraw-build"; buildId: number }
|
|
19
|
+
| { type: "restore-build"; buildId: number }
|
|
20
|
+
| { type: "remove-build-from-stream"; buildId: number; streamId: number }
|
|
21
|
+
| { type: "unassign-user-stream"; clientId: number; streamId: number }
|
|
22
|
+
| { type: "pin-client"; clientId: number; buildId: number }
|
|
23
|
+
| { type: "unpin-client"; clientId: number }
|
|
24
|
+
| { type: "delete-stream"; streamId: number };
|
|
25
|
+
|
|
26
|
+
export type NoBuildState = "servable" | "empty" | "stranded";
|
|
27
|
+
|
|
28
|
+
/** The /appcast notion of servable: the resolver yields a target. Sparkle enforces no-downgrade itself. */
|
|
29
|
+
export function isServableResult(result: ResolverResult): boolean {
|
|
30
|
+
return result.kind === "target";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveForClient(world: World, client: Client): ResolverResult {
|
|
34
|
+
const clientStreamIds = world.userStreams
|
|
35
|
+
.filter((link) => link.clientId === client.id)
|
|
36
|
+
.map((link) => link.streamId);
|
|
37
|
+
return resolve({
|
|
38
|
+
client,
|
|
39
|
+
builds: world.builds,
|
|
40
|
+
buildStreams: world.buildStreams,
|
|
41
|
+
clientStreamIds,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The §13 admin-surface notion, which accounts for no-downgrade using the client's last-reported
|
|
47
|
+
* installed build (or null if unknown):
|
|
48
|
+
* - `servable` — the resolver yields a target the client can actually be on (≥ installed).
|
|
49
|
+
* - `stranded` — the client is on a withdrawn build and the resolver can't move them higher (§11).
|
|
50
|
+
* - `empty` — no usable build otherwise (no streams/builds, or pinned to a withdrawn build).
|
|
51
|
+
*/
|
|
52
|
+
export function noBuildState(
|
|
53
|
+
world: World,
|
|
54
|
+
client: Client,
|
|
55
|
+
installedBuildNumber: number | null,
|
|
56
|
+
): NoBuildState {
|
|
57
|
+
const result = resolveForClient(world, client);
|
|
58
|
+
const target = result.kind === "target" ? result.build : null;
|
|
59
|
+
|
|
60
|
+
const servable =
|
|
61
|
+
target !== null &&
|
|
62
|
+
(installedBuildNumber === null || target.buildNumber >= installedBuildNumber);
|
|
63
|
+
if (servable) return "servable";
|
|
64
|
+
|
|
65
|
+
// §11 files "pinned to a now-withdrawn build" under EMPTY — the pin is the cause, so classify as
|
|
66
|
+
// empty regardless of the installed build. Must precede the stranded (stream no-downgrade) check.
|
|
67
|
+
if (client.pinnedBuildId !== null) {
|
|
68
|
+
const pinned = world.builds.find((build) => build.id === client.pinnedBuildId);
|
|
69
|
+
if (pinned === undefined || pinned.status !== "available") return "empty";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const onWithdrawnBuild =
|
|
73
|
+
installedBuildNumber !== null &&
|
|
74
|
+
world.builds.some(
|
|
75
|
+
(build) => build.buildNumber === installedBuildNumber && build.status === "withdrawn",
|
|
76
|
+
);
|
|
77
|
+
return onWithdrawnBuild ? "stranded" : "empty";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The emails of clients who are servable now but would fall into the no-build state if the batch of
|
|
82
|
+
* `actions` were applied together. §11: such actions are confirmed, not blocked — the §13 #3 bulk
|
|
83
|
+
* withdraw shows the union of everyone the whole selection would strand, so the operator confirms
|
|
84
|
+
* once for the lot (single actions pass a one-element batch).
|
|
85
|
+
*/
|
|
86
|
+
export function computeAffectedUsersForActions(
|
|
87
|
+
world: World,
|
|
88
|
+
actions: readonly AdminAction[],
|
|
89
|
+
installed: ReadonlyMap<number, number> = new Map(),
|
|
90
|
+
): string[] {
|
|
91
|
+
const after = applyActions(world, actions);
|
|
92
|
+
const affected: string[] = [];
|
|
93
|
+
|
|
94
|
+
for (const client of world.clients) {
|
|
95
|
+
const installedBuildNumber = installed.get(client.id) ?? null;
|
|
96
|
+
if (noBuildState(world, client, installedBuildNumber) !== "servable") continue;
|
|
97
|
+
|
|
98
|
+
const clientAfter = after.clients.find((candidate) => candidate.id === client.id) ?? client;
|
|
99
|
+
if (noBuildState(after, clientAfter, installedBuildNumber) !== "servable") {
|
|
100
|
+
affected.push(client.email);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return affected;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Fold a sequence of actions into the world (the bulk operations apply several at once). */
|
|
107
|
+
function applyActions(world: World, actions: readonly AdminAction[]): World {
|
|
108
|
+
return actions.reduce(applyAction, world);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Pure transform: a new World with `action` applied. */
|
|
112
|
+
function applyAction(world: World, action: AdminAction): World {
|
|
113
|
+
switch (action.type) {
|
|
114
|
+
case "withdraw-build":
|
|
115
|
+
return { ...world, builds: withBuildStatus(world.builds, action.buildId, "withdrawn") };
|
|
116
|
+
case "restore-build":
|
|
117
|
+
return { ...world, builds: withBuildStatus(world.builds, action.buildId, "available") };
|
|
118
|
+
case "remove-build-from-stream":
|
|
119
|
+
return {
|
|
120
|
+
...world,
|
|
121
|
+
buildStreams: world.buildStreams.filter(
|
|
122
|
+
(link) => !(link.buildId === action.buildId && link.streamId === action.streamId),
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
case "unassign-user-stream":
|
|
126
|
+
return {
|
|
127
|
+
...world,
|
|
128
|
+
userStreams: world.userStreams.filter(
|
|
129
|
+
(link) => !(link.clientId === action.clientId && link.streamId === action.streamId),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
case "pin-client":
|
|
133
|
+
return { ...world, clients: withPin(world.clients, action.clientId, action.buildId) };
|
|
134
|
+
case "unpin-client":
|
|
135
|
+
return { ...world, clients: withPin(world.clients, action.clientId, null) };
|
|
136
|
+
case "delete-stream":
|
|
137
|
+
// The channel and all its assignments/links vanish: builds leave it, users are unassigned.
|
|
138
|
+
return {
|
|
139
|
+
...world,
|
|
140
|
+
buildStreams: world.buildStreams.filter((link) => link.streamId !== action.streamId),
|
|
141
|
+
userStreams: world.userStreams.filter((link) => link.streamId !== action.streamId),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function withBuildStatus(
|
|
147
|
+
builds: readonly Build[],
|
|
148
|
+
buildId: number,
|
|
149
|
+
status: Build["status"],
|
|
150
|
+
): Build[] {
|
|
151
|
+
return builds.map((build) => (build.id === buildId ? { ...build, status } : build));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function withPin(
|
|
155
|
+
clients: readonly Client[],
|
|
156
|
+
clientId: number,
|
|
157
|
+
pinnedBuildId: number | null,
|
|
158
|
+
): Client[] {
|
|
159
|
+
return clients.map((client) => (client.id === clientId ? { ...client, pinnedBuildId } : client));
|
|
160
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Build, BuildStreamLink, Client, ResolverResult } from "./types";
|
|
2
|
+
|
|
3
|
+
// §8 — the core resolver. Pure: given a client (or null for an unknown token), the builds, the
|
|
4
|
+
// build→stream links, and the client's stream assignments, decide what to serve. The single source
|
|
5
|
+
// of truth behind /appcast, the /download target, and the §11 no-build preview, so the confirmation
|
|
6
|
+
// preview and runtime resolution can never disagree.
|
|
7
|
+
|
|
8
|
+
export interface ResolveInput {
|
|
9
|
+
/** The client for the token, or null when the token matched no row (§3/§15). */
|
|
10
|
+
client: Client | null;
|
|
11
|
+
/** Candidate builds (the resolver filters by status and stream membership). */
|
|
12
|
+
builds: readonly Build[];
|
|
13
|
+
/** build_streams rows for the candidate builds. */
|
|
14
|
+
buildStreams: readonly BuildStreamLink[];
|
|
15
|
+
/** The stream ids this client is assigned to (their user_streams rows). */
|
|
16
|
+
clientStreamIds: readonly number[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type { ResolverResult } from "./types";
|
|
20
|
+
|
|
21
|
+
export function resolve(input: ResolveInput): ResolverResult {
|
|
22
|
+
const { client, builds, buildStreams, clientStreamIds } = input;
|
|
23
|
+
|
|
24
|
+
// 1. Unknown or revoked → an informational notice, never a target (§8.1, §15).
|
|
25
|
+
if (client === null) return { kind: "informational", reason: "unknown" };
|
|
26
|
+
if (client.status === "revoked") return { kind: "informational", reason: "revoked" };
|
|
27
|
+
|
|
28
|
+
// 2. A pin overrides stream resolution entirely (§8.2). If the pinned build is unavailable
|
|
29
|
+
// (withdrawn or gone) the client is no-build — it does NOT fall back to streams (§11).
|
|
30
|
+
if (client.pinnedBuildId !== null) {
|
|
31
|
+
const pinned = builds.find((build) => build.id === client.pinnedBuildId);
|
|
32
|
+
if (pinned !== undefined && pinned.status === "available") {
|
|
33
|
+
return { kind: "target", build: pinned };
|
|
34
|
+
}
|
|
35
|
+
return { kind: "none" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 3. Otherwise: the highest available build_number across the client's streams (§8.3).
|
|
39
|
+
const target = highestAvailableInStreams(builds, buildStreams, clientStreamIds);
|
|
40
|
+
return target === null ? { kind: "none" } : { kind: "target", build: target };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function highestAvailableInStreams(
|
|
44
|
+
builds: readonly Build[],
|
|
45
|
+
buildStreams: readonly BuildStreamLink[],
|
|
46
|
+
clientStreamIds: readonly number[],
|
|
47
|
+
): Build | null {
|
|
48
|
+
const clientStreams = new Set(clientStreamIds);
|
|
49
|
+
const buildIdsInClientStreams = new Set(
|
|
50
|
+
buildStreams.filter((link) => clientStreams.has(link.streamId)).map((link) => link.buildId),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
let best: Build | null = null;
|
|
54
|
+
for (const build of builds) {
|
|
55
|
+
if (build.status !== "available") continue;
|
|
56
|
+
if (!buildIdsInClientStreams.has(build.id)) continue;
|
|
57
|
+
if (best === null || build.buildNumber > best.buildNumber) best = build;
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Per-user access tokens (decision 0002). The sole credential: it travels in URLs and is hand-pasted
|
|
2
|
+
// into the macOS app on first launch, so it uses an unambiguous, case-insensitive Crockford base32
|
|
3
|
+
// alphabet and ≥160 bits of entropy. This module is the single source of truth for token shape.
|
|
4
|
+
|
|
5
|
+
const CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // 32 symbols — no I, L, O, U
|
|
6
|
+
const TOKEN_BYTES = 20; // 160 bits of entropy
|
|
7
|
+
const TOKEN_LENGTH = 32; // 160 bits / 5 bits-per-symbol, exact (no padding)
|
|
8
|
+
|
|
9
|
+
/** A fresh, well-formed, already-normalized token. */
|
|
10
|
+
export function generateToken(): string {
|
|
11
|
+
const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
|
|
12
|
+
return encodeCrockford(bytes);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Canonical form for lookup: forgiving of case, whitespace/hyphens, and the Crockford confusables
|
|
17
|
+
* (O→0, I/L→1) so a human paste still matches. Idempotent.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeToken(raw: string): string {
|
|
20
|
+
return raw
|
|
21
|
+
.replace(/[\s-]+/g, "")
|
|
22
|
+
.toUpperCase()
|
|
23
|
+
.replace(/O/g, "0")
|
|
24
|
+
.replace(/[IL]/g, "1");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Defensive guard run before any DB lookup: exactly 32 chars, all in the Crockford alphabet. */
|
|
28
|
+
export function isWellFormedToken(raw: string): boolean {
|
|
29
|
+
const normalized = normalizeToken(raw);
|
|
30
|
+
if (normalized.length !== TOKEN_LENGTH) return false;
|
|
31
|
+
for (const ch of normalized) {
|
|
32
|
+
if (!CROCKFORD_ALPHABET.includes(ch)) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encodeCrockford(bytes: Uint8Array): string {
|
|
38
|
+
let value = 0;
|
|
39
|
+
let bits = 0;
|
|
40
|
+
let out = "";
|
|
41
|
+
for (const byte of bytes) {
|
|
42
|
+
value = (value << 8) | byte;
|
|
43
|
+
bits += 8;
|
|
44
|
+
while (bits >= 5) {
|
|
45
|
+
bits -= 5;
|
|
46
|
+
out += CROCKFORD_ALPHABET.charAt((value >>> bits) & 31);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// 160 bits is a multiple of 5, so there is never a trailing remainder for our fixed size.
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Shared domain vocabulary — plain data the pure core operates on. The db/ layer maps snake_case D1
|
|
2
|
+
// rows onto these camelCase shapes; the core never sees a binding.
|
|
3
|
+
|
|
4
|
+
export type ClientStatus = "active" | "revoked";
|
|
5
|
+
export type BuildStatus = "available" | "withdrawn";
|
|
6
|
+
export type AccessEvent = "check" | "download" | "update";
|
|
7
|
+
|
|
8
|
+
export interface Client {
|
|
9
|
+
id: number;
|
|
10
|
+
email: string;
|
|
11
|
+
token: string;
|
|
12
|
+
status: ClientStatus;
|
|
13
|
+
/** Overrides stream resolution when set and the build is available (§8). */
|
|
14
|
+
pinnedBuildId: number | null;
|
|
15
|
+
label: string | null;
|
|
16
|
+
/** Admin-list visibility only (declutter); does NOT affect resolution/serving. */
|
|
17
|
+
hidden: boolean;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Build {
|
|
23
|
+
id: number;
|
|
24
|
+
/** Human version, e.g. "1.4.0". */
|
|
25
|
+
shortVersion: string;
|
|
26
|
+
/** Machine CFBundleVersion — monotonic, the value Sparkle compares (§8). */
|
|
27
|
+
buildNumber: number;
|
|
28
|
+
/** R2 key of the EdDSA-signed .app zip (the Sparkle enclosure). */
|
|
29
|
+
objectKey: string;
|
|
30
|
+
edSignature: string;
|
|
31
|
+
/** Byte length of the signed zip (the enclosure length). */
|
|
32
|
+
length: number;
|
|
33
|
+
minOs: string | null;
|
|
34
|
+
critical: boolean;
|
|
35
|
+
/** §9/§13 operator marker: a designated rollback (roll-forward) target. Label only — see §9. */
|
|
36
|
+
rollbackTarget: boolean;
|
|
37
|
+
status: BuildStatus;
|
|
38
|
+
/** Admin-list visibility only (declutter); does NOT affect resolution/serving. */
|
|
39
|
+
hidden: boolean;
|
|
40
|
+
/** Optional first-install DMG (decision 0003); no EdDSA — notarization seals it. */
|
|
41
|
+
dmgObjectKey: string | null;
|
|
42
|
+
dmgLength: number | null;
|
|
43
|
+
/** When the R2 archive bytes were deleted to reclaim space (the row is kept). Null = present. */
|
|
44
|
+
purgedAt: string | null;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Stream {
|
|
49
|
+
id: number;
|
|
50
|
+
name: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A build_streams row: which stream a build belongs to. */
|
|
54
|
+
export interface BuildStreamLink {
|
|
55
|
+
buildId: number;
|
|
56
|
+
streamId: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A user_streams row: which stream a client is assigned to. */
|
|
60
|
+
export interface UserStreamLink {
|
|
61
|
+
clientId: number;
|
|
62
|
+
streamId: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Outcome of resolving what a client should be served (§8). The single source of truth behind the
|
|
67
|
+
* appcast, the /download target, and the §11 no-build preview.
|
|
68
|
+
*
|
|
69
|
+
* `none` means "no build to serve" — the empty-vs-stranded distinction (§11) is a separate
|
|
70
|
+
* classification (core/no-build) because it needs the client's last-reported installed build, which
|
|
71
|
+
* §8 resolution never uses.
|
|
72
|
+
*/
|
|
73
|
+
export type ResolverResult =
|
|
74
|
+
| { kind: "target"; build: Build }
|
|
75
|
+
| { kind: "informational"; reason: "revoked" | "unknown" }
|
|
76
|
+
| { kind: "none" };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type AdminAction, computeAffectedUsersForActions, type World } from "./no-build";
|
|
2
|
+
|
|
3
|
+
// §11 — pre-mutation validation. Actions that would strand users are confirmed, not blocked: the
|
|
4
|
+
// caller computes the affected set up front, shows it, and proceeds only on confirm. This module also
|
|
5
|
+
// guards malformed params defensively, since the action is built from untrusted form/HTTP input.
|
|
6
|
+
|
|
7
|
+
export type ValidationResult =
|
|
8
|
+
| { ok: false; error: string }
|
|
9
|
+
| { ok: true; needsConfirm: boolean; affectedEmails: string[] };
|
|
10
|
+
|
|
11
|
+
export function validateAction(
|
|
12
|
+
world: World,
|
|
13
|
+
action: AdminAction,
|
|
14
|
+
installed?: ReadonlyMap<number, number>,
|
|
15
|
+
): ValidationResult {
|
|
16
|
+
return validateActions(world, [action], installed);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validate a batch applied together (§13 #3 bulk withdraw): one combined confirm for the selection. */
|
|
20
|
+
export function validateActions(
|
|
21
|
+
world: World,
|
|
22
|
+
actions: readonly AdminAction[],
|
|
23
|
+
installed?: ReadonlyMap<number, number>,
|
|
24
|
+
): ValidationResult {
|
|
25
|
+
for (const action of actions) {
|
|
26
|
+
const guardError = guardAction(action);
|
|
27
|
+
if (guardError !== null) return { ok: false, error: guardError };
|
|
28
|
+
}
|
|
29
|
+
const affectedEmails = computeAffectedUsersForActions(world, actions, installed);
|
|
30
|
+
return { ok: true, needsConfirm: affectedEmails.length > 0, affectedEmails };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns an error message if the action's id params are not positive integers, else null. */
|
|
34
|
+
function guardAction(action: AdminAction): string | null {
|
|
35
|
+
switch (action.type) {
|
|
36
|
+
case "withdraw-build":
|
|
37
|
+
case "restore-build":
|
|
38
|
+
return isPositiveInt(action.buildId) ? null : "buildId must be a positive integer";
|
|
39
|
+
case "remove-build-from-stream":
|
|
40
|
+
return isPositiveInt(action.buildId) && isPositiveInt(action.streamId)
|
|
41
|
+
? null
|
|
42
|
+
: "buildId and streamId must be positive integers";
|
|
43
|
+
case "unassign-user-stream":
|
|
44
|
+
return isPositiveInt(action.clientId) && isPositiveInt(action.streamId)
|
|
45
|
+
? null
|
|
46
|
+
: "clientId and streamId must be positive integers";
|
|
47
|
+
case "pin-client":
|
|
48
|
+
return isPositiveInt(action.clientId) && isPositiveInt(action.buildId)
|
|
49
|
+
? null
|
|
50
|
+
: "clientId and buildId must be positive integers";
|
|
51
|
+
case "unpin-client":
|
|
52
|
+
return isPositiveInt(action.clientId) ? null : "clientId must be a positive integer";
|
|
53
|
+
case "delete-stream":
|
|
54
|
+
return isPositiveInt(action.streamId) ? null : "streamId must be a positive integer";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPositiveInt(value: unknown): value is number {
|
|
59
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { World } from "./no-build";
|
|
2
|
+
import type { Build, Client } from "./types";
|
|
3
|
+
|
|
4
|
+
// The display-grade answer to "what does this user's NEXT update check actually do?" — the §8
|
|
5
|
+
// resolver's outcome refined with the user's last-reported installed build and the no-downgrade rule,
|
|
6
|
+
// classified by CAUSE so every page can say not just "no build" but why, and what fixes it. Pure over
|
|
7
|
+
// plain data (§23): the same World the runtime resolver uses, no bindings, no clock.
|
|
8
|
+
|
|
9
|
+
export type Verdict =
|
|
10
|
+
/** The next check offers this build (installed is below it, or nothing installed yet). */
|
|
11
|
+
| { kind: "offered"; build: Build; via: "pin" | "channel" }
|
|
12
|
+
/** The resolver targets exactly what is installed — Sparkle reports "up to date". */
|
|
13
|
+
| { kind: "up-to-date"; build: Build; via: "pin" | "channel" }
|
|
14
|
+
/** Revoked: every check returns the reactivation notice, never a build. */
|
|
15
|
+
| { kind: "revoked" }
|
|
16
|
+
/** Active but assigned to no channel (and unpinned) — resolves to nothing, always. */
|
|
17
|
+
| { kind: "no-channel" }
|
|
18
|
+
/** Channels are assigned but none carries an available build. */
|
|
19
|
+
| { kind: "empty-channel" }
|
|
20
|
+
/** Pinned to a build that is withdrawn or gone — the pin serves nothing. */
|
|
21
|
+
| { kind: "pin-unavailable"; pinnedBuildId: number }
|
|
22
|
+
/** Pinned below the installed build — Sparkle won't downgrade, so the pin serves nothing. */
|
|
23
|
+
| { kind: "pin-below-installed"; pinned: Build; installed: number }
|
|
24
|
+
/** Installed sits above everything the channels offer — stranded under no-downgrade (§11). */
|
|
25
|
+
| { kind: "stranded"; installed: number; top: Build };
|
|
26
|
+
|
|
27
|
+
/** True when the verdict means the user's feed is empty/faulted (the amber states). */
|
|
28
|
+
export function isFault(verdict: Verdict): boolean {
|
|
29
|
+
return verdict.kind !== "offered" && verdict.kind !== "up-to-date" && verdict.kind !== "revoked";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verdictFor(
|
|
33
|
+
world: World,
|
|
34
|
+
client: Client,
|
|
35
|
+
installedBuildNumber: number | null,
|
|
36
|
+
): Verdict {
|
|
37
|
+
if (client.status === "revoked") return { kind: "revoked" };
|
|
38
|
+
|
|
39
|
+
// A pin overrides channel resolution entirely (§8.2) — and never falls back.
|
|
40
|
+
if (client.pinnedBuildId !== null) {
|
|
41
|
+
const pinned = world.builds.find((build) => build.id === client.pinnedBuildId);
|
|
42
|
+
if (pinned === undefined || pinned.status !== "available") {
|
|
43
|
+
return { kind: "pin-unavailable", pinnedBuildId: client.pinnedBuildId };
|
|
44
|
+
}
|
|
45
|
+
if (installedBuildNumber !== null && pinned.buildNumber < installedBuildNumber) {
|
|
46
|
+
return { kind: "pin-below-installed", pinned, installed: installedBuildNumber };
|
|
47
|
+
}
|
|
48
|
+
if (installedBuildNumber !== null && pinned.buildNumber === installedBuildNumber) {
|
|
49
|
+
return { kind: "up-to-date", build: pinned, via: "pin" };
|
|
50
|
+
}
|
|
51
|
+
return { kind: "offered", build: pinned, via: "pin" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const streamIds = new Set(
|
|
55
|
+
world.userStreams.filter((link) => link.clientId === client.id).map((link) => link.streamId),
|
|
56
|
+
);
|
|
57
|
+
if (streamIds.size === 0) return { kind: "no-channel" };
|
|
58
|
+
|
|
59
|
+
const buildIds = new Set(
|
|
60
|
+
world.buildStreams.filter((link) => streamIds.has(link.streamId)).map((link) => link.buildId),
|
|
61
|
+
);
|
|
62
|
+
let top: Build | null = null;
|
|
63
|
+
for (const build of world.builds) {
|
|
64
|
+
if (build.status !== "available" || !buildIds.has(build.id)) continue;
|
|
65
|
+
if (top === null || build.buildNumber > top.buildNumber) top = build;
|
|
66
|
+
}
|
|
67
|
+
if (top === null) return { kind: "empty-channel" };
|
|
68
|
+
|
|
69
|
+
if (installedBuildNumber === null || top.buildNumber > installedBuildNumber) {
|
|
70
|
+
return { kind: "offered", build: top, via: "channel" };
|
|
71
|
+
}
|
|
72
|
+
if (top.buildNumber === installedBuildNumber) {
|
|
73
|
+
return { kind: "up-to-date", build: top, via: "channel" };
|
|
74
|
+
}
|
|
75
|
+
return { kind: "stranded", installed: installedBuildNumber, top };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One serving-map row: what a channel offers and how its audience actually fares. */
|
|
79
|
+
export interface ChannelServing {
|
|
80
|
+
streamId: number;
|
|
81
|
+
name: string;
|
|
82
|
+
/** Highest available linked build — what the channel offers (§8) — or null (serving nothing). */
|
|
83
|
+
top: Build | null;
|
|
84
|
+
/** Active users assigned to the channel (revoked users are not counted as audience). */
|
|
85
|
+
users: number;
|
|
86
|
+
/** Users whose next check will move them (verdict: offered). */
|
|
87
|
+
willUpdate: number;
|
|
88
|
+
/** Users already at the channel's head (or held at an equal pin). */
|
|
89
|
+
upToDate: number;
|
|
90
|
+
/** Users whose feed is empty while assigned here — stranded, pin faults (the amber count). */
|
|
91
|
+
faulted: number;
|
|
92
|
+
/** Users held elsewhere by a pin (their pin overrides this channel). */
|
|
93
|
+
pinned: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The dashboard serving map, computed from the same World as runtime resolution — one row per
|
|
98
|
+
* channel plus the audience math per user verdict. `installed` maps client id → last-reported build.
|
|
99
|
+
*/
|
|
100
|
+
export function channelServings(
|
|
101
|
+
world: World,
|
|
102
|
+
streams: readonly { id: number; name: string }[],
|
|
103
|
+
installed: ReadonlyMap<number, number>,
|
|
104
|
+
): ChannelServing[] {
|
|
105
|
+
return streams.map((stream) => {
|
|
106
|
+
const buildIds = new Set(
|
|
107
|
+
world.buildStreams.filter((l) => l.streamId === stream.id).map((l) => l.buildId),
|
|
108
|
+
);
|
|
109
|
+
let top: Build | null = null;
|
|
110
|
+
for (const build of world.builds) {
|
|
111
|
+
if (build.status !== "available" || !buildIds.has(build.id)) continue;
|
|
112
|
+
if (top === null || build.buildNumber > top.buildNumber) top = build;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const memberIds = new Set(
|
|
116
|
+
world.userStreams.filter((l) => l.streamId === stream.id).map((l) => l.clientId),
|
|
117
|
+
);
|
|
118
|
+
let users = 0;
|
|
119
|
+
let willUpdate = 0;
|
|
120
|
+
let upToDate = 0;
|
|
121
|
+
let faulted = 0;
|
|
122
|
+
let pinned = 0;
|
|
123
|
+
for (const client of world.clients) {
|
|
124
|
+
if (!memberIds.has(client.id) || client.status !== "active") continue;
|
|
125
|
+
users++;
|
|
126
|
+
const verdict = verdictFor(world, client, installed.get(client.id) ?? null);
|
|
127
|
+
if (client.pinnedBuildId !== null) pinned++;
|
|
128
|
+
if (verdict.kind === "offered") willUpdate++;
|
|
129
|
+
else if (verdict.kind === "up-to-date") upToDate++;
|
|
130
|
+
else faulted++;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
streamId: stream.id,
|
|
134
|
+
name: stream.name,
|
|
135
|
+
top,
|
|
136
|
+
users,
|
|
137
|
+
willUpdate,
|
|
138
|
+
upToDate,
|
|
139
|
+
faulted,
|
|
140
|
+
pinned,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Active users assigned to NO channel and not pinned — routed nowhere; the map's "off the map" row. */
|
|
146
|
+
export function offTheMap(world: World): Client[] {
|
|
147
|
+
const assigned = new Set(world.userStreams.map((link) => link.clientId));
|
|
148
|
+
return world.clients.filter(
|
|
149
|
+
(client) =>
|
|
150
|
+
client.status === "active" && !assigned.has(client.id) && client.pinnedBuildId === null,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const MONTHS = [
|
|
155
|
+
"Jan",
|
|
156
|
+
"Feb",
|
|
157
|
+
"Mar",
|
|
158
|
+
"Apr",
|
|
159
|
+
"May",
|
|
160
|
+
"Jun",
|
|
161
|
+
"Jul",
|
|
162
|
+
"Aug",
|
|
163
|
+
"Sep",
|
|
164
|
+
"Oct",
|
|
165
|
+
"Nov",
|
|
166
|
+
"Dec",
|
|
167
|
+
] as const;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* "Jul 09 08:30" from an ISO-8601 string — the UI's one timestamp form (full ISO stays in the
|
|
171
|
+
* element's title). Pure string slicing, no Date (lib/clock owns time). Adds the year only when it
|
|
172
|
+
* differs from `nowIso`'s. Returns the input unchanged when it doesn't look like an ISO timestamp.
|
|
173
|
+
*/
|
|
174
|
+
export function formatWhen(iso: string, nowIso: string): string {
|
|
175
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(iso);
|
|
176
|
+
if (m === null) return iso;
|
|
177
|
+
const [, year, month, day, hh, mm] = m;
|
|
178
|
+
const monthName = MONTHS[Number(month) - 1] ?? month;
|
|
179
|
+
const sameYear = nowIso.startsWith(`${year}-`);
|
|
180
|
+
return `${monthName} ${day} ${hh}:${mm}${sameYear ? "" : ` ${year}`}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** "84.2 MB" from bytes — exact count belongs in the title attribute. */
|
|
184
|
+
export function formatBytes(bytes: number): string {
|
|
185
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
186
|
+
const units = ["KB", "MB", "GB"] as const;
|
|
187
|
+
let value = bytes;
|
|
188
|
+
let unit = "B";
|
|
189
|
+
for (const next of units) {
|
|
190
|
+
if (value < 1024) break;
|
|
191
|
+
value /= 1024;
|
|
192
|
+
unit = next;
|
|
193
|
+
}
|
|
194
|
+
return `${value >= 100 ? Math.round(value) : Math.round(value * 10) / 10} ${unit}`;
|
|
195
|
+
}
|