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,210 @@
|
|
|
1
|
+
// §4 / decision 0006 — Cloudflare Access JWT verification, the security boundary for every admin
|
|
2
|
+
// mutation. Fail CLOSED on any error. alg pinned to RS256 (algorithm-confusion defense); iss/aud
|
|
3
|
+
// checked; ±60s clock skew; the JWKS fetch is an injectable seam so tests use a throwaway keypair.
|
|
4
|
+
// A human one-time-PIN JWT carries `email`; a service-token JWT carries `common_name` (no email).
|
|
5
|
+
|
|
6
|
+
export interface Jwk {
|
|
7
|
+
kid?: string;
|
|
8
|
+
kty?: string;
|
|
9
|
+
alg?: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AccessIdentity =
|
|
14
|
+
| { kind: "user"; email: string }
|
|
15
|
+
| { kind: "service"; commonName: string };
|
|
16
|
+
|
|
17
|
+
export type AccessResult = AccessIdentity | { kind: "reject"; reason: string };
|
|
18
|
+
|
|
19
|
+
const SKEW_SECONDS = 60;
|
|
20
|
+
const ACCESS_HEADER = "Cf-Access-Jwt-Assertion";
|
|
21
|
+
|
|
22
|
+
const reject = (reason: string): AccessResult => ({ kind: "reject", reason });
|
|
23
|
+
|
|
24
|
+
export interface VerifyOptions {
|
|
25
|
+
jwks: readonly Jwk[];
|
|
26
|
+
now: number; // unix seconds
|
|
27
|
+
aud: string;
|
|
28
|
+
issuer: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The pure-ish verifier: crypto + claims against a supplied JWKS. No network. */
|
|
32
|
+
export async function verifyAccessJwt(token: string, opts: VerifyOptions): Promise<AccessResult> {
|
|
33
|
+
const parts = token.split(".");
|
|
34
|
+
if (parts.length !== 3) return reject("malformed token");
|
|
35
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string];
|
|
36
|
+
|
|
37
|
+
let header: Record<string, unknown>;
|
|
38
|
+
let payload: Record<string, unknown>;
|
|
39
|
+
let signature: Uint8Array;
|
|
40
|
+
try {
|
|
41
|
+
header = JSON.parse(decodeText(encodedHeader)) as Record<string, unknown>;
|
|
42
|
+
payload = JSON.parse(decodeText(encodedPayload)) as Record<string, unknown>;
|
|
43
|
+
signature = decodeBytes(encodedSignature);
|
|
44
|
+
} catch {
|
|
45
|
+
return reject("unparseable token");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (header.alg !== "RS256") return reject("alg not RS256");
|
|
49
|
+
const kid = typeof header.kid === "string" ? header.kid : null;
|
|
50
|
+
const jwk = opts.jwks.find((k) => k.kid === kid) ?? (kid === null ? opts.jwks[0] : undefined);
|
|
51
|
+
if (jwk === undefined) return reject("unknown kid");
|
|
52
|
+
|
|
53
|
+
let valid: boolean;
|
|
54
|
+
try {
|
|
55
|
+
const key = await crypto.subtle.importKey(
|
|
56
|
+
"jwk",
|
|
57
|
+
jwk as JsonWebKey,
|
|
58
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
59
|
+
false,
|
|
60
|
+
["verify"],
|
|
61
|
+
);
|
|
62
|
+
valid = await crypto.subtle.verify(
|
|
63
|
+
"RSASSA-PKCS1-v1_5",
|
|
64
|
+
key,
|
|
65
|
+
signature,
|
|
66
|
+
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`),
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
return reject("verification error");
|
|
70
|
+
}
|
|
71
|
+
if (!valid) return reject("bad signature");
|
|
72
|
+
|
|
73
|
+
if (payload.iss !== opts.issuer) return reject("issuer mismatch");
|
|
74
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
75
|
+
if (!audiences.includes(opts.aud)) return reject("audience mismatch");
|
|
76
|
+
|
|
77
|
+
// exp is REQUIRED and must be numeric: a missing/garbled exp must fail closed (decision 0006), not
|
|
78
|
+
// be treated as never-expiring. nbf/iat are optional but enforced (with skew) when present.
|
|
79
|
+
if (typeof payload.exp !== "number") return reject("missing or invalid exp");
|
|
80
|
+
if (opts.now > payload.exp + SKEW_SECONDS) return reject("expired");
|
|
81
|
+
if (typeof payload.nbf === "number" && opts.now < payload.nbf - SKEW_SECONDS) {
|
|
82
|
+
return reject("not yet valid");
|
|
83
|
+
}
|
|
84
|
+
if (typeof payload.iat === "number" && opts.now < payload.iat - SKEW_SECONDS) {
|
|
85
|
+
return reject("issued in the future");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof payload.email === "string" && payload.email.length > 0) {
|
|
89
|
+
return { kind: "user", email: payload.email };
|
|
90
|
+
}
|
|
91
|
+
if (typeof payload.common_name === "string" && payload.common_name.length > 0) {
|
|
92
|
+
return { kind: "service", commonName: payload.common_name };
|
|
93
|
+
}
|
|
94
|
+
return reject("no principal claim");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface AccessVerifier {
|
|
98
|
+
verify(headers: Headers): Promise<AccessResult>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A JWKS fetcher: returns the team keys, optionally told which `kid` is wanted (cache refetch hint). */
|
|
102
|
+
export type JwksFetcher = (
|
|
103
|
+
teamDomain: string,
|
|
104
|
+
sel?: { kid?: string | null },
|
|
105
|
+
) => Promise<readonly Jwk[]>;
|
|
106
|
+
|
|
107
|
+
export interface AccessConfig {
|
|
108
|
+
teamDomain: string | undefined;
|
|
109
|
+
aud: string | undefined;
|
|
110
|
+
fetchJwks: JwksFetcher;
|
|
111
|
+
now: () => number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The Deps seam. Reads the Access assertion header, fetches the team JWKS (behind fetchJwks — in
|
|
116
|
+
* production a Worker-global cache, see createCachedJwksFetcher), and verifies — failing closed on
|
|
117
|
+
* missing config, a missing header, or a JWKS fetch failure. The token's `kid` is passed to the
|
|
118
|
+
* fetcher so the cache can force a refetch when an unknown key id appears (Cloudflare key rotation).
|
|
119
|
+
*/
|
|
120
|
+
export function createAccessVerifier(config: AccessConfig): AccessVerifier {
|
|
121
|
+
return {
|
|
122
|
+
async verify(headers: Headers): Promise<AccessResult> {
|
|
123
|
+
if (config.teamDomain === undefined || config.aud === undefined) {
|
|
124
|
+
return reject("Access not configured");
|
|
125
|
+
}
|
|
126
|
+
const token = headers.get(ACCESS_HEADER);
|
|
127
|
+
if (token === null || token.length === 0) return reject("missing Access assertion");
|
|
128
|
+
|
|
129
|
+
let jwks: readonly Jwk[];
|
|
130
|
+
try {
|
|
131
|
+
jwks = await config.fetchJwks(config.teamDomain, { kid: extractKid(token) });
|
|
132
|
+
} catch {
|
|
133
|
+
return reject("JWKS fetch failed");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return verifyAccessJwt(token, {
|
|
137
|
+
jwks,
|
|
138
|
+
now: config.now(),
|
|
139
|
+
aud: config.aud,
|
|
140
|
+
issuer: `https://${config.teamDomain}`,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Reads the unverified `kid` from a JWT header (drives the cache refetch); null if absent/garbled. */
|
|
147
|
+
export function extractKid(token: string): string | null {
|
|
148
|
+
const dot = token.indexOf(".");
|
|
149
|
+
if (dot <= 0) return null;
|
|
150
|
+
try {
|
|
151
|
+
const header = JSON.parse(decodeText(token.slice(0, dot))) as Record<string, unknown>;
|
|
152
|
+
return typeof header.kid === "string" ? header.kid : null;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function defaultFetchJwks(teamDomain: string): Promise<readonly Jwk[]> {
|
|
159
|
+
const res = await fetch(`https://${teamDomain}/cdn-cgi/access/certs`);
|
|
160
|
+
if (!res.ok) throw new Error(`JWKS fetch returned ${res.status}`);
|
|
161
|
+
const body = (await res.json()) as { keys?: Jwk[] };
|
|
162
|
+
return body.keys ?? [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface CachedJwksOptions {
|
|
166
|
+
/** The clock seam (unix seconds) — kept injectable so the TTL is deterministic in tests. */
|
|
167
|
+
now: () => number;
|
|
168
|
+
/** The underlying network fetch; defaults to defaultFetchJwks. */
|
|
169
|
+
fetchJwks?: (teamDomain: string) => Promise<readonly Jwk[]>;
|
|
170
|
+
/** Cache lifetime; decision 0006 calls for ~10–15 min. Default 600s. */
|
|
171
|
+
ttlSeconds?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* decision 0006 — a JWKS fetcher with a cache keyed by team domain and a short TTL, plus a forced
|
|
176
|
+
* refetch when the requested `kid` is absent from the cached set (so a Cloudflare key rotation is
|
|
177
|
+
* picked up at once instead of failing for up to the TTL). Create ONCE at module scope so the cache
|
|
178
|
+
* lives in Worker-global scope and survives across requests in the same isolate.
|
|
179
|
+
*/
|
|
180
|
+
export function createCachedJwksFetcher(options: CachedJwksOptions): JwksFetcher {
|
|
181
|
+
const fetchJwks = options.fetchJwks ?? defaultFetchJwks;
|
|
182
|
+
const ttlSeconds = options.ttlSeconds ?? 600;
|
|
183
|
+
const cache = new Map<string, { keys: readonly Jwk[]; fetchedAt: number }>();
|
|
184
|
+
|
|
185
|
+
return async (teamDomain, sel) => {
|
|
186
|
+
const entry = cache.get(teamDomain);
|
|
187
|
+
const fresh = entry !== undefined && options.now() - entry.fetchedAt < ttlSeconds;
|
|
188
|
+
const wantedKid = sel?.kid ?? null;
|
|
189
|
+
const hasKid =
|
|
190
|
+
entry !== undefined && (wantedKid === null || entry.keys.some((k) => k.kid === wantedKid));
|
|
191
|
+
if (entry !== undefined && fresh && hasKid) return entry.keys;
|
|
192
|
+
|
|
193
|
+
const keys = await fetchJwks(teamDomain);
|
|
194
|
+
cache.set(teamDomain, { keys, fetchedAt: options.now() });
|
|
195
|
+
return keys;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function decodeBytes(base64url: string): Uint8Array {
|
|
200
|
+
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
201
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
202
|
+
const binary = atob(padded);
|
|
203
|
+
const bytes = new Uint8Array(binary.length);
|
|
204
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
205
|
+
return bytes;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decodeText(base64url: string): string {
|
|
209
|
+
return new TextDecoder().decode(decodeBytes(base64url));
|
|
210
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isWellFormedToken, normalizeToken } from "../core/tokens";
|
|
2
|
+
import type { Client } from "../core/types";
|
|
3
|
+
import { findByToken } from "../db/clients";
|
|
4
|
+
import type { Deps } from "../deps";
|
|
5
|
+
|
|
6
|
+
// The public-side token gate used by every /get, /appcast, /download request. Validates shape, then
|
|
7
|
+
// looks the client up by the normalized token. Returns the client for active AND revoked rows (the
|
|
8
|
+
// resolver and routes decide what each gets); an unknown/malformed token is indistinguishable from a
|
|
9
|
+
// revoked one to the outside, so token existence is never confirmed (§6/§16).
|
|
10
|
+
|
|
11
|
+
export type GateResult =
|
|
12
|
+
| { kind: "active"; client: Client }
|
|
13
|
+
| { kind: "revoked"; client: Client }
|
|
14
|
+
| { kind: "unknown" };
|
|
15
|
+
|
|
16
|
+
export async function gateToken(
|
|
17
|
+
deps: Deps,
|
|
18
|
+
rawToken: string | null | undefined,
|
|
19
|
+
): Promise<GateResult> {
|
|
20
|
+
if (rawToken === null || rawToken === undefined || !isWellFormedToken(rawToken)) {
|
|
21
|
+
return { kind: "unknown" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const client = await findByToken(deps.db, normalizeToken(rawToken));
|
|
25
|
+
if (client === null) return { kind: "unknown" };
|
|
26
|
+
return client.status === "revoked" ? { kind: "revoked", client } : { kind: "active", client };
|
|
27
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Build } from "./types";
|
|
2
|
+
|
|
3
|
+
// §8/§14/§15 — per-user appcast generation. Pure string building over already-resolved data (the
|
|
4
|
+
// route passes the build + enclosure URL). The out-of-scope Sparkle client depends on this output
|
|
5
|
+
// being exactly right, so every interpolated value is XML-escaped and the shape is golden-tested.
|
|
6
|
+
|
|
7
|
+
const SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle";
|
|
8
|
+
const ENCLOSURE_TYPE = "application/octet-stream";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sentinel version for the §15 informational item — fixed, far above any real build_number, and
|
|
12
|
+
* below INT32 max. Safe permanently because the item carries no enclosure, so Sparkle can never
|
|
13
|
+
* "install" it regardless of the number (decision 0008). Real build_numbers must stay below this.
|
|
14
|
+
*/
|
|
15
|
+
export const INFORMATIONAL_SENTINEL_VERSION = 999_000_000;
|
|
16
|
+
|
|
17
|
+
// Shown in Sparkle's dialog in place of the raw sentinel number: sparkle:shortVersionString is the
|
|
18
|
+
// human-readable version string (the sentinel stays the machine-comparable sparkle:version).
|
|
19
|
+
const INFORMATIONAL_DISPLAY_VERSION = "Access renewal";
|
|
20
|
+
|
|
21
|
+
export interface InformationalNotice {
|
|
22
|
+
/** The /access page the notice links to. */
|
|
23
|
+
accessUrl: string;
|
|
24
|
+
/** Dialog title (already {app_name}-filled by the caller). */
|
|
25
|
+
title: string;
|
|
26
|
+
/** Plain-text body, shown as the dialog's release notes (already {app_name}-filled). */
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function xmlEscape(value: string): string {
|
|
31
|
+
return value
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, """)
|
|
36
|
+
.replace(/'/g, "'");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `<item>` Sparkle installs: machine version, enclosure with the fixed EdDSA signature (§8/§14). */
|
|
40
|
+
export function renderUpdateItem(build: Build, enclosureUrl: string): string {
|
|
41
|
+
const children = [
|
|
42
|
+
`<title>${xmlEscape(build.shortVersion)}</title>`,
|
|
43
|
+
`<sparkle:version>${build.buildNumber}</sparkle:version>`,
|
|
44
|
+
`<sparkle:shortVersionString>${xmlEscape(build.shortVersion)}</sparkle:shortVersionString>`,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
if (build.minOs !== null) {
|
|
48
|
+
children.push(
|
|
49
|
+
`<sparkle:minimumSystemVersion>${xmlEscape(build.minOs)}</sparkle:minimumSystemVersion>`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (build.critical) {
|
|
53
|
+
children.push("<sparkle:criticalUpdate></sparkle:criticalUpdate>");
|
|
54
|
+
}
|
|
55
|
+
children.push(
|
|
56
|
+
`<enclosure url="${xmlEscape(enclosureUrl)}" length="${build.length}" type="${ENCLOSURE_TYPE}" sparkle:edSignature="${xmlEscape(build.edSignature)}" />`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return wrapItem(children);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The §15 informational notice for revoked/unknown tokens (decision 0011): a title, a message, the
|
|
64
|
+
* sentinel `sparkle:version` with a friendly `shortVersionString` (so Sparkle shows "Access renewal",
|
|
65
|
+
* not "999000000"), an explicit empty `<sparkle:informationalUpdate>` (Sparkle 2's documented marker;
|
|
66
|
+
* 1.x falls back to "no enclosure → informational"), a `<link>`, and NO enclosure (never installable).
|
|
67
|
+
*/
|
|
68
|
+
export function renderInformationalItem(notice: InformationalNotice): string {
|
|
69
|
+
return wrapItem([
|
|
70
|
+
`<title>${xmlEscape(notice.title)}</title>`,
|
|
71
|
+
`<description>${descriptionCdata(notice.message)}</description>`,
|
|
72
|
+
`<sparkle:version>${INFORMATIONAL_SENTINEL_VERSION}</sparkle:version>`,
|
|
73
|
+
`<sparkle:shortVersionString>${xmlEscape(INFORMATIONAL_DISPLAY_VERSION)}</sparkle:shortVersionString>`,
|
|
74
|
+
"<sparkle:informationalUpdate></sparkle:informationalUpdate>",
|
|
75
|
+
`<link>${xmlEscape(notice.accessUrl)}</link>`,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The admin message becomes the dialog's HTML release notes. xmlEscape first so it renders as literal
|
|
80
|
+
// text (no markup/script reaches Sparkle's WebView) — escaping `>` also guarantees the content can't
|
|
81
|
+
// contain `]]>`, so it can't break out of the CDATA. Newlines become <br> for a readable paragraph.
|
|
82
|
+
function descriptionCdata(message: string): string {
|
|
83
|
+
return `<![CDATA[<p>${xmlEscape(message).replace(/\n/g, "<br>")}</p>]]>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AppcastOptions {
|
|
87
|
+
title: string;
|
|
88
|
+
items: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Wraps zero or more item fragments in the rss/channel document with the sparkle namespace. */
|
|
92
|
+
export function renderAppcast({ title, items }: AppcastOptions): string {
|
|
93
|
+
const lines = [
|
|
94
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
95
|
+
`<rss version="2.0" xmlns:sparkle="${SPARKLE_NS}">`,
|
|
96
|
+
" <channel>",
|
|
97
|
+
` <title>${xmlEscape(title)}</title>`,
|
|
98
|
+
...items,
|
|
99
|
+
" </channel>",
|
|
100
|
+
"</rss>",
|
|
101
|
+
];
|
|
102
|
+
return `${lines.join("\n")}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function wrapItem(children: readonly string[]): string {
|
|
106
|
+
return [" <item>", ...children.map((child) => ` ${child}`), " </item>"].join("\n");
|
|
107
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// §16 — tamper-evident admin audit hash chain (decision 0005). Pure over plain data (crypto.subtle
|
|
2
|
+
// is used but only over passed-in values). The canonical form is versioned and length-prefixed so it
|
|
3
|
+
// is deterministic and reproducible offline in any language — the basis for the anchor + verification.
|
|
4
|
+
|
|
5
|
+
const CANON_VERSION = "v1";
|
|
6
|
+
|
|
7
|
+
/** The audited content of one mutation (everything except the autoincrement id and the hash). */
|
|
8
|
+
export interface AuditFields {
|
|
9
|
+
actorEmail: string;
|
|
10
|
+
action: string;
|
|
11
|
+
target: string | null;
|
|
12
|
+
detail: string | null;
|
|
13
|
+
ip: string | null;
|
|
14
|
+
rayId: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An entry ready to hash: the fields plus the link to the previous row. */
|
|
19
|
+
export interface AuditEntry extends AuditFields {
|
|
20
|
+
prevHash: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A stored, hashed row. */
|
|
24
|
+
export interface AuditRow extends AuditEntry {
|
|
25
|
+
hash: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type VerifyResult = { ok: true } | { ok: false; brokenIndex: number };
|
|
29
|
+
|
|
30
|
+
// Fixed field order. `prevHash` is part of the canonical input (chain linkage); `id` and `hash` are
|
|
31
|
+
// excluded. Changing the order or set is a breaking change — bump CANON_VERSION if it ever happens.
|
|
32
|
+
const FIELD_ORDER = [
|
|
33
|
+
"actorEmail",
|
|
34
|
+
"action",
|
|
35
|
+
"target",
|
|
36
|
+
"detail",
|
|
37
|
+
"ip",
|
|
38
|
+
"rayId",
|
|
39
|
+
"createdAt",
|
|
40
|
+
"prevHash",
|
|
41
|
+
] as const satisfies readonly (keyof AuditEntry)[];
|
|
42
|
+
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Deterministic canonical form: version tag, then each field as `name:<utf8-byte-length>:<value>`
|
|
47
|
+
* (or `name:null`). Length-prefixing means no value — even one containing newlines or colons — can
|
|
48
|
+
* forge a field boundary.
|
|
49
|
+
*/
|
|
50
|
+
export function canonicalize(entry: AuditEntry): string {
|
|
51
|
+
const parts: string[] = [CANON_VERSION];
|
|
52
|
+
for (const field of FIELD_ORDER) {
|
|
53
|
+
const value = entry[field];
|
|
54
|
+
if (value === null) {
|
|
55
|
+
parts.push(`${field}:null`);
|
|
56
|
+
} else {
|
|
57
|
+
parts.push(`${field}:${encoder.encode(value).length}:${value}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return parts.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function computeHash(entry: AuditEntry): Promise<string> {
|
|
64
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalize(entry)));
|
|
65
|
+
return toHex(digest);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Builds the next row: links it to `prevHash` and computes its hash. */
|
|
69
|
+
export async function linkRow(prevHash: string | null, fields: AuditFields): Promise<AuditRow> {
|
|
70
|
+
const entry: AuditEntry = { ...fields, prevHash };
|
|
71
|
+
const hash = await computeHash(entry);
|
|
72
|
+
return { ...entry, hash };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Recomputes each row's hash from its fields and checks both the recomputed hash and the prev_hash
|
|
77
|
+
* link. Returns the index of the first row that fails (catching edits and mid-chain deletions).
|
|
78
|
+
*/
|
|
79
|
+
export async function verifyChain(rows: readonly AuditRow[]): Promise<VerifyResult> {
|
|
80
|
+
let prev: string | null = null;
|
|
81
|
+
for (const [index, row] of rows.entries()) {
|
|
82
|
+
if (row.prevHash !== prev) return { ok: false, brokenIndex: index };
|
|
83
|
+
if ((await computeHash(row)) !== row.hash) return { ok: false, brokenIndex: index };
|
|
84
|
+
prev = row.hash;
|
|
85
|
+
}
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The status the daily anchor records — and the admin Audit page displays live. */
|
|
90
|
+
export interface ChainAssessment {
|
|
91
|
+
/** The chain verifies AND has not shrunk or diverged from the last anchored head. */
|
|
92
|
+
intact: boolean;
|
|
93
|
+
count: number;
|
|
94
|
+
/** The anchored head this was checked against, or null before the first anchor. */
|
|
95
|
+
anchored: { hash: string; count: number } | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One shared judgment of chain integrity for the anchor cron AND the Audit page (they must never
|
|
100
|
+
* disagree). `priorRaw` is the stored `audit_anchor_head` meta value (JSON), or null before the
|
|
101
|
+
* first anchor. A malformed prior is treated as suspicious, exactly like a mismatch.
|
|
102
|
+
*/
|
|
103
|
+
export async function assessChain(
|
|
104
|
+
rows: readonly AuditRow[],
|
|
105
|
+
priorRaw: string | null,
|
|
106
|
+
): Promise<ChainAssessment> {
|
|
107
|
+
let intact = (await verifyChain(rows)).ok;
|
|
108
|
+
const head = buildHead(rows);
|
|
109
|
+
|
|
110
|
+
let prior: { hash: string; count: number } | null = null;
|
|
111
|
+
if (priorRaw !== null) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(priorRaw) as { hash?: unknown; count?: unknown };
|
|
114
|
+
if (typeof parsed.hash === "string" && typeof parsed.count === "number") {
|
|
115
|
+
prior = { hash: parsed.hash, count: parsed.count };
|
|
116
|
+
} else {
|
|
117
|
+
intact = false; // corrupted anchor record — treat as suspicious
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
intact = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (prior !== null) {
|
|
124
|
+
if (head.count < prior.count) {
|
|
125
|
+
intact = false; // truncation — newest rows removed
|
|
126
|
+
} else if (prior.count > 0 && rows[prior.count - 1]?.hash !== prior.hash) {
|
|
127
|
+
intact = false; // divergence / rebuild
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { intact, count: head.count, anchored: prior };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The chain head for anchoring (§16): the latest hash and the row count. */
|
|
134
|
+
export function buildHead(rows: readonly AuditRow[]): { hash: string; count: number } {
|
|
135
|
+
const last = rows.at(-1);
|
|
136
|
+
return { hash: last?.hash ?? "", count: rows.length };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toHex(buffer: ArrayBuffer): string {
|
|
140
|
+
let hex = "";
|
|
141
|
+
for (const byte of new Uint8Array(buffer)) {
|
|
142
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
143
|
+
}
|
|
144
|
+
return hex;
|
|
145
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// §13/§6 — invite text and the branded /get page model. Pure: placeholder fill and default merge.
|
|
2
|
+
// The DEFAULT_INVITE_TEMPLATE here is the runtime fallback when the admin has set nothing in `meta`;
|
|
3
|
+
// templates/invite-email.txt mirrors it as the human-editable canonical (kept in sync by hand).
|
|
4
|
+
|
|
5
|
+
export interface InviteTemplate {
|
|
6
|
+
subject: string;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface InviteVars {
|
|
11
|
+
appName: string;
|
|
12
|
+
getUrl: string;
|
|
13
|
+
token: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_INVITE_TEMPLATE: InviteTemplate = {
|
|
17
|
+
subject: "You're invited to test {app_name}",
|
|
18
|
+
body: `Hi,
|
|
19
|
+
|
|
20
|
+
You've been added to the {app_name} alpha. To get started:
|
|
21
|
+
|
|
22
|
+
1. Open your private download page: {get_url}
|
|
23
|
+
2. Download and install the app.
|
|
24
|
+
3. Launch it, then click "Activate" on the page (or paste the key shown there).
|
|
25
|
+
|
|
26
|
+
That page is yours — revisit it any time to reinstall. It stops working if access is revoked.`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The §15 reactivation notice shown by Sparkle for a revoked/unknown token (an informational update,
|
|
31
|
+
* never an install). Editable per-instance via meta.notice_title / meta.notice_message; the message
|
|
32
|
+
* supports {app_name}. Defaults are clear and action-oriented.
|
|
33
|
+
*/
|
|
34
|
+
export interface AccessNotice {
|
|
35
|
+
title: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_ACCESS_NOTICE: AccessNotice = {
|
|
40
|
+
title: "Reactivate your access",
|
|
41
|
+
message:
|
|
42
|
+
"Your access to {app_name} has expired or been revoked, so updates are paused. " +
|
|
43
|
+
"Open your access page to get a new activation link.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Replaces just {app_name} — used by text that has no get_url/token (e.g. the §15 access notice). */
|
|
47
|
+
export function fillAppName(text: string, appName: string): string {
|
|
48
|
+
return text.replace(/\{app_name\}/g, appName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Replaces {app_name}/{get_url}/{token} in one pass (a value can't reintroduce a placeholder). */
|
|
52
|
+
export function fillTemplate(text: string, vars: InviteVars): string {
|
|
53
|
+
const replacements: Record<string, string> = {
|
|
54
|
+
"{app_name}": vars.appName,
|
|
55
|
+
"{get_url}": vars.getUrl,
|
|
56
|
+
"{token}": vars.token,
|
|
57
|
+
};
|
|
58
|
+
return text.replace(
|
|
59
|
+
/\{app_name\}|\{get_url\}|\{token\}/g,
|
|
60
|
+
(match) => replacements[match] ?? match,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderInvite(template: InviteTemplate, vars: InviteVars): InviteTemplate {
|
|
65
|
+
return {
|
|
66
|
+
subject: fillTemplate(template.subject, vars),
|
|
67
|
+
body: fillTemplate(template.body, vars),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The resolved /get page branding (§6). */
|
|
72
|
+
export interface Branding {
|
|
73
|
+
appName: string;
|
|
74
|
+
blurb: string | null;
|
|
75
|
+
accent: string;
|
|
76
|
+
iconUrl: string | null;
|
|
77
|
+
headerUrl: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Clean, never-blocking defaults (decision 0003 sibling): neutral name, system blue, no imagery. */
|
|
81
|
+
export const DEFAULT_BRANDING: Branding = {
|
|
82
|
+
appName: "Your App",
|
|
83
|
+
blurb: null,
|
|
84
|
+
accent: "#0A84FF",
|
|
85
|
+
iconUrl: null,
|
|
86
|
+
headerUrl: null,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The accent colour is interpolated RAW into a `<style>` on the public pages (no escaping is possible
|
|
91
|
+
* inside CSS), so it must be a hex colour and nothing else — otherwise a stored value could break out
|
|
92
|
+
* of the style block. `isValidAccent` gates writes; `safeAccent` coerces any stored value at read time
|
|
93
|
+
* so the sink is always safe even for data written before this guard existed.
|
|
94
|
+
*/
|
|
95
|
+
export function isValidAccent(value: string): boolean {
|
|
96
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(value.trim());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function safeAccent(value: string | null | undefined): string {
|
|
100
|
+
return value != null && isValidAccent(value) ? value.trim() : DEFAULT_BRANDING.accent;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The macOS app's custom URL scheme for the §7 activation deep link (`<scheme>://activate?token=`).
|
|
105
|
+
* Per-instance config (it must match the app's Info.plist CFBundleURLSchemes); "myapp" is the default.
|
|
106
|
+
*/
|
|
107
|
+
export const DEFAULT_ACTIVATE_SCHEME = "myapp";
|
|
108
|
+
|
|
109
|
+
// A valid RFC 3986 scheme: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). Restricting to this also keeps
|
|
110
|
+
// the value safe to interpolate into the Activate href (no quotes/spaces/colons to break out of it).
|
|
111
|
+
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*$/;
|
|
112
|
+
|
|
113
|
+
/** The configured activate scheme if it is a well-formed URL scheme, else the safe default. */
|
|
114
|
+
export function resolveActivateScheme(raw: string | null | undefined): string {
|
|
115
|
+
return typeof raw === "string" && SCHEME_RE.test(raw) ? raw : DEFAULT_ACTIVATE_SCHEME;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Merges admin overrides (from `meta` / R2 asset URL) over the defaults; ignores undefined. */
|
|
119
|
+
export function resolveBranding(overrides: Partial<Branding>): Branding {
|
|
120
|
+
return {
|
|
121
|
+
appName: overrides.appName ?? DEFAULT_BRANDING.appName,
|
|
122
|
+
blurb: overrides.blurb ?? DEFAULT_BRANDING.blurb,
|
|
123
|
+
accent: overrides.accent ?? DEFAULT_BRANDING.accent,
|
|
124
|
+
iconUrl: overrides.iconUrl ?? DEFAULT_BRANDING.iconUrl,
|
|
125
|
+
headerUrl: overrides.headerUrl ?? DEFAULT_BRANDING.headerUrl,
|
|
126
|
+
};
|
|
127
|
+
}
|