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,113 @@
|
|
|
1
|
+
// §22 self-update version logic. Pure: the manifest is passed in (the fetch lives in
|
|
2
|
+
// services/self-update). The manifest is untrusted input from an upstream URL, so isUpdateAvailable
|
|
3
|
+
// is defensive — it never throws and defaults to "no update" on anything malformed.
|
|
4
|
+
//
|
|
5
|
+
// Two manifest shapes are accepted so the check works against the npm registry OR a static file:
|
|
6
|
+
// - npm's registry `/latest` endpoint returns the published package.json: { version, homepage,
|
|
7
|
+
// alphaGate? }. The upgrade signals (breaking / min-supported / notes) ride in a custom
|
|
8
|
+
// `alphaGate` field, which npm preserves in the version manifest.
|
|
9
|
+
// - the legacy static release.json: { latest, min_supported, notes_url, breaking }.
|
|
10
|
+
|
|
11
|
+
/** The upstream release manifest — either npm's `/latest` response or a static release.json. */
|
|
12
|
+
export interface UpdateManifest {
|
|
13
|
+
/** Static release.json: the latest version string. */
|
|
14
|
+
latest?: string;
|
|
15
|
+
/** npm registry `/latest`: the published version. */
|
|
16
|
+
version?: string;
|
|
17
|
+
min_supported?: string;
|
|
18
|
+
notes_url?: string;
|
|
19
|
+
breaking?: boolean;
|
|
20
|
+
/** npm package.json `homepage` — a notes-URL fallback for the npm channel. */
|
|
21
|
+
homepage?: string;
|
|
22
|
+
/** Upgrade signals carried through npm (a custom package.json field npm preserves). */
|
|
23
|
+
alphaGate?: {
|
|
24
|
+
minSupported?: string;
|
|
25
|
+
breaking?: boolean;
|
|
26
|
+
notesUrl?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UpdateStatus {
|
|
31
|
+
available: boolean;
|
|
32
|
+
breaking: boolean;
|
|
33
|
+
belowMinSupported: boolean;
|
|
34
|
+
latest: string | null;
|
|
35
|
+
notesUrl: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ParsedVersion {
|
|
39
|
+
core: number[];
|
|
40
|
+
prerelease: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parse(version: string): ParsedVersion {
|
|
44
|
+
const cleaned = version.trim().replace(/^v/i, "");
|
|
45
|
+
const dash = cleaned.indexOf("-");
|
|
46
|
+
const core = dash === -1 ? cleaned : cleaned.slice(0, dash);
|
|
47
|
+
const prerelease = dash === -1 ? null : cleaned.slice(dash + 1);
|
|
48
|
+
const segments = core.split(".").map((part) => {
|
|
49
|
+
const n = Number.parseInt(part, 10);
|
|
50
|
+
return Number.isFinite(n) ? n : 0;
|
|
51
|
+
});
|
|
52
|
+
return { core: segments, prerelease };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Returns <0, 0, or >0 (semver-ish; numeric segments, missing treated as 0, prerelease < release). */
|
|
56
|
+
export function compareVersion(a: string, b: string): number {
|
|
57
|
+
const pa = parse(a);
|
|
58
|
+
const pb = parse(b);
|
|
59
|
+
|
|
60
|
+
const length = Math.max(pa.core.length, pb.core.length);
|
|
61
|
+
for (let i = 0; i < length; i++) {
|
|
62
|
+
const diff = (pa.core[i] ?? 0) - (pb.core[i] ?? 0);
|
|
63
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Equal core: a release outranks a prerelease of the same core; otherwise compare the tags.
|
|
67
|
+
if (pa.prerelease === pb.prerelease) return 0;
|
|
68
|
+
if (pa.prerelease === null) return 1;
|
|
69
|
+
if (pb.prerelease === null) return -1;
|
|
70
|
+
if (pa.prerelease < pb.prerelease) return -1;
|
|
71
|
+
if (pa.prerelease > pb.prerelease) return 1;
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isUpdateAvailable(toolVersion: string, manifest: UpdateManifest): UpdateStatus {
|
|
76
|
+
const noUpdate: UpdateStatus = {
|
|
77
|
+
available: false,
|
|
78
|
+
breaking: false,
|
|
79
|
+
belowMinSupported: false,
|
|
80
|
+
latest: null,
|
|
81
|
+
notesUrl: null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (typeof manifest !== "object" || manifest === null) return noUpdate;
|
|
85
|
+
// The latest version: release.json's `latest`, else npm's `version`.
|
|
86
|
+
const latestRaw = manifest.latest ?? manifest.version;
|
|
87
|
+
if (typeof latestRaw !== "string" || latestRaw.length === 0) return noUpdate;
|
|
88
|
+
const latest = latestRaw;
|
|
89
|
+
|
|
90
|
+
const ag = manifest.alphaGate;
|
|
91
|
+
const minSupported =
|
|
92
|
+
typeof manifest.min_supported === "string"
|
|
93
|
+
? manifest.min_supported
|
|
94
|
+
: typeof ag?.minSupported === "string"
|
|
95
|
+
? ag.minSupported
|
|
96
|
+
: null;
|
|
97
|
+
const notesUrl =
|
|
98
|
+
typeof manifest.notes_url === "string"
|
|
99
|
+
? manifest.notes_url
|
|
100
|
+
: typeof ag?.notesUrl === "string"
|
|
101
|
+
? ag.notesUrl
|
|
102
|
+
: typeof manifest.homepage === "string"
|
|
103
|
+
? manifest.homepage
|
|
104
|
+
: null;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
available: compareVersion(latest, toolVersion) > 0,
|
|
108
|
+
breaking: manifest.breaking === true || ag?.breaking === true,
|
|
109
|
+
belowMinSupported: minSupported !== null && compareVersion(toolVersion, minSupported) < 0,
|
|
110
|
+
latest,
|
|
111
|
+
notesUrl,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { prune } from "./db/access-log";
|
|
2
|
+
import { buildDeps, type Deps } from "./deps";
|
|
3
|
+
import type { Env } from "./env";
|
|
4
|
+
import { isoDaysAgo } from "./lib/clock";
|
|
5
|
+
import { anchorAudit } from "./services/anchor";
|
|
6
|
+
import { checkSelfUpdate } from "./services/self-update";
|
|
7
|
+
|
|
8
|
+
// §16/§22 — the daily scheduled job. Only the admin Worker acts; the app Worker shares the cron
|
|
9
|
+
// config but no-ops. Deps is injectable so tests drive it with a mocked fetch / recording email.
|
|
10
|
+
|
|
11
|
+
const LOG_RETENTION_DAYS = 90;
|
|
12
|
+
|
|
13
|
+
export async function runScheduled(env: Env, deps: Deps = buildDeps(env)): Promise<void> {
|
|
14
|
+
if (env.ROLE !== "admin") return; // the app Worker no-ops the shared cron
|
|
15
|
+
|
|
16
|
+
const ownerEmail = env.EMAIL_FROM.length > 0 ? env.EMAIL_FROM : null;
|
|
17
|
+
|
|
18
|
+
// Each step runs independently — a failure in one (e.g. a transient email/manifest error) must not
|
|
19
|
+
// skip the others (notably the audit anchor and the retention prune).
|
|
20
|
+
await safely("self-update", () =>
|
|
21
|
+
checkSelfUpdate(deps, {
|
|
22
|
+
toolVersion: env.TOOL_VERSION,
|
|
23
|
+
manifestUrl: env.UPDATE_MANIFEST_URL,
|
|
24
|
+
ownerEmail,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
await safely("audit-anchor", () => anchorAudit(deps, { now: deps.clock(), ownerEmail }));
|
|
28
|
+
await safely("prune", () => prune(deps.db, isoDaysAgo(LOG_RETENTION_DAYS)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function safely(step: string, fn: () => Promise<unknown>): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
await fn();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`cron step "${step}" failed`, error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { AccessEvent } from "../core/types";
|
|
2
|
+
import { execute, queryAll, queryOne } from "./client";
|
|
3
|
+
|
|
4
|
+
// §5/§16 — the access log: the source of truth for distribution stats (download/update counts,
|
|
5
|
+
// per-user last activity, current version). `created_at` is supplied by the caller's Clock so
|
|
6
|
+
// time-dependent reads (stats, the §16 prune) are deterministic in tests.
|
|
7
|
+
|
|
8
|
+
export interface NewAccessEvent {
|
|
9
|
+
clientId: number | null;
|
|
10
|
+
email: string | null;
|
|
11
|
+
event: AccessEvent;
|
|
12
|
+
shortVersion?: string | null;
|
|
13
|
+
buildNumber?: number | null;
|
|
14
|
+
ip?: string | null;
|
|
15
|
+
userAgent?: string | null;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function insertEvent(db: D1Database, entry: NewAccessEvent): Promise<void> {
|
|
20
|
+
await execute(
|
|
21
|
+
db,
|
|
22
|
+
`INSERT INTO access_log
|
|
23
|
+
(client_id, email, event, short_version, build_number, ip, user_agent, created_at)
|
|
24
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
25
|
+
[
|
|
26
|
+
entry.clientId,
|
|
27
|
+
entry.email,
|
|
28
|
+
entry.event,
|
|
29
|
+
entry.shortVersion ?? null,
|
|
30
|
+
entry.buildNumber ?? null,
|
|
31
|
+
entry.ip ?? null,
|
|
32
|
+
entry.userAgent ?? null,
|
|
33
|
+
entry.createdAt,
|
|
34
|
+
],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Per-build count of a given event (e.g. downloads or updates of a build_number). */
|
|
39
|
+
export async function countByBuild(
|
|
40
|
+
db: D1Database,
|
|
41
|
+
buildNumber: number,
|
|
42
|
+
event: AccessEvent,
|
|
43
|
+
): Promise<number> {
|
|
44
|
+
const row = await queryOne<{ n: number }>(
|
|
45
|
+
db,
|
|
46
|
+
"SELECT COUNT(*) AS n FROM access_log WHERE build_number = ? AND event = ?",
|
|
47
|
+
[buildNumber, event],
|
|
48
|
+
);
|
|
49
|
+
return row?.n ?? 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The most recent time a client produced a given event (per-user "last installed/updated/seen"). */
|
|
53
|
+
export async function lastEventAt(
|
|
54
|
+
db: D1Database,
|
|
55
|
+
clientId: number,
|
|
56
|
+
event: AccessEvent,
|
|
57
|
+
): Promise<string | null> {
|
|
58
|
+
const row = await queryOne<{ v: string | null }>(
|
|
59
|
+
db,
|
|
60
|
+
"SELECT MAX(created_at) AS v FROM access_log WHERE client_id = ? AND event = ?",
|
|
61
|
+
[clientId, event],
|
|
62
|
+
);
|
|
63
|
+
return row?.v ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The most recent time a client produced ANY event — the Users list's "last seen". */
|
|
67
|
+
export async function lastSeenAt(db: D1Database, clientId: number): Promise<string | null> {
|
|
68
|
+
const row = await queryOne<{ v: string | null }>(
|
|
69
|
+
db,
|
|
70
|
+
"SELECT MAX(created_at) AS v FROM access_log WHERE client_id = ?",
|
|
71
|
+
[clientId],
|
|
72
|
+
);
|
|
73
|
+
return row?.v ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The build_number the client reported on its latest `check` — its current installed version. */
|
|
77
|
+
export async function currentBuild(db: D1Database, clientId: number): Promise<number | null> {
|
|
78
|
+
const row = await queryOne<{ build_number: number | null }>(
|
|
79
|
+
db,
|
|
80
|
+
`SELECT build_number FROM access_log
|
|
81
|
+
WHERE client_id = ? AND event = 'check' AND build_number IS NOT NULL
|
|
82
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
83
|
+
[clientId],
|
|
84
|
+
);
|
|
85
|
+
return row?.build_number ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** §16 retention prune: delete rows older than `before` (an ISO timestamp). */
|
|
89
|
+
export async function prune(db: D1Database, before: string): Promise<void> {
|
|
90
|
+
await execute(db, "DELETE FROM access_log WHERE created_at < ?", [before]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AccessLogEntry {
|
|
94
|
+
id: number;
|
|
95
|
+
clientId: number | null;
|
|
96
|
+
email: string | null;
|
|
97
|
+
event: AccessEvent;
|
|
98
|
+
shortVersion: string | null;
|
|
99
|
+
buildNumber: number | null;
|
|
100
|
+
createdAt: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ActivityFilter {
|
|
104
|
+
limit?: number;
|
|
105
|
+
email?: string | undefined;
|
|
106
|
+
event?: AccessEvent | undefined;
|
|
107
|
+
buildNumber?: number | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The most recent events, newest first — the §13 activity feed; optionally filtered. */
|
|
111
|
+
export async function recent(
|
|
112
|
+
db: D1Database,
|
|
113
|
+
filter: ActivityFilter = {},
|
|
114
|
+
): Promise<AccessLogEntry[]> {
|
|
115
|
+
const where: string[] = [];
|
|
116
|
+
const params: unknown[] = [];
|
|
117
|
+
if (filter.email) {
|
|
118
|
+
// Contains-match: "alice" should find alice@corner.studio (LIKE is case-insensitive for ASCII).
|
|
119
|
+
where.push("email LIKE ?");
|
|
120
|
+
params.push(`%${filter.email.replaceAll("%", "").replaceAll("_", "")}%`);
|
|
121
|
+
}
|
|
122
|
+
if (filter.event) {
|
|
123
|
+
where.push("event = ?");
|
|
124
|
+
params.push(filter.event);
|
|
125
|
+
}
|
|
126
|
+
if (filter.buildNumber !== undefined) {
|
|
127
|
+
where.push("build_number = ?");
|
|
128
|
+
params.push(filter.buildNumber);
|
|
129
|
+
}
|
|
130
|
+
const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
131
|
+
|
|
132
|
+
const rows = await queryAll<{
|
|
133
|
+
id: number;
|
|
134
|
+
client_id: number | null;
|
|
135
|
+
email: string | null;
|
|
136
|
+
event: string;
|
|
137
|
+
short_version: string | null;
|
|
138
|
+
build_number: number | null;
|
|
139
|
+
created_at: string;
|
|
140
|
+
}>(
|
|
141
|
+
db,
|
|
142
|
+
`SELECT id, client_id, email, event, short_version, build_number, created_at
|
|
143
|
+
FROM access_log ${clause} ORDER BY id DESC LIMIT ?`,
|
|
144
|
+
[...params, filter.limit ?? 100],
|
|
145
|
+
);
|
|
146
|
+
return rows.map((row) => ({
|
|
147
|
+
id: row.id,
|
|
148
|
+
clientId: row.client_id,
|
|
149
|
+
email: row.email,
|
|
150
|
+
event: row.event as AccessEvent,
|
|
151
|
+
shortVersion: row.short_version,
|
|
152
|
+
buildNumber: row.build_number,
|
|
153
|
+
createdAt: row.created_at,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The most recent event time for a build (any event) — the §13 builds-list "last activity". */
|
|
158
|
+
export async function lastActivityForBuild(
|
|
159
|
+
db: D1Database,
|
|
160
|
+
buildNumber: number,
|
|
161
|
+
): Promise<string | null> {
|
|
162
|
+
const row = await queryOne<{ v: string | null }>(
|
|
163
|
+
db,
|
|
164
|
+
"SELECT MAX(created_at) AS v FROM access_log WHERE build_number = ?",
|
|
165
|
+
[buildNumber],
|
|
166
|
+
);
|
|
167
|
+
return row?.v ?? null;
|
|
168
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execute, queryAll, queryOne } from "./client";
|
|
2
|
+
|
|
3
|
+
// §13 #10 — pending access requests submitted from the public /access page.
|
|
4
|
+
|
|
5
|
+
export interface AccessRequest {
|
|
6
|
+
id: number;
|
|
7
|
+
email: string;
|
|
8
|
+
ip: string | null;
|
|
9
|
+
userAgent: string | null;
|
|
10
|
+
status: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Row {
|
|
15
|
+
id: number;
|
|
16
|
+
email: string;
|
|
17
|
+
ip: string | null;
|
|
18
|
+
user_agent: string | null;
|
|
19
|
+
status: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toRequest(row: Row): AccessRequest {
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
email: row.email,
|
|
27
|
+
ip: row.ip,
|
|
28
|
+
userAgent: row.user_agent,
|
|
29
|
+
status: row.status,
|
|
30
|
+
createdAt: row.created_at,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NewAccessRequest {
|
|
35
|
+
email: string;
|
|
36
|
+
ip: string | null;
|
|
37
|
+
userAgent: string | null;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function insert(db: D1Database, entry: NewAccessRequest): Promise<void> {
|
|
42
|
+
await execute(
|
|
43
|
+
db,
|
|
44
|
+
"INSERT INTO access_requests (email, ip, user_agent, created_at) VALUES (?, ?, ?, ?)",
|
|
45
|
+
[entry.email, entry.ip, entry.userAgent, entry.createdAt],
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function listByStatus(db: D1Database, status = "pending"): Promise<AccessRequest[]> {
|
|
50
|
+
const rows = await queryAll<Row>(
|
|
51
|
+
db,
|
|
52
|
+
"SELECT * FROM access_requests WHERE status = ? ORDER BY id DESC",
|
|
53
|
+
[status],
|
|
54
|
+
);
|
|
55
|
+
return rows.map(toRequest);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getById(db: D1Database, id: number): Promise<AccessRequest | null> {
|
|
59
|
+
const row = await queryOne<Row>(db, "SELECT * FROM access_requests WHERE id = ?", [id]);
|
|
60
|
+
return row ? toRequest(row) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function setStatus(db: D1Database, id: number, status: string): Promise<void> {
|
|
64
|
+
await execute(db, "UPDATE access_requests SET status = ? WHERE id = ?", [status, id]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolves EVERY still-pending request for an email at once. Testers re-submit the public form when
|
|
69
|
+
* no confirmation arrives (copy-paste mode sends none), so duplicates are the norm — handling only
|
|
70
|
+
* the clicked row would leave stale siblings whose Invite button silently rotates a just-sent token.
|
|
71
|
+
*/
|
|
72
|
+
export async function setStatusByEmail(
|
|
73
|
+
db: D1Database,
|
|
74
|
+
email: string,
|
|
75
|
+
status: string,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
await execute(
|
|
78
|
+
db,
|
|
79
|
+
"UPDATE access_requests SET status = ? WHERE email = ? AND status = 'pending'",
|
|
80
|
+
[status, email],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function countPending(db: D1Database): Promise<number> {
|
|
85
|
+
const row = await queryOne<{ n: number }>(
|
|
86
|
+
db,
|
|
87
|
+
"SELECT COUNT(*) AS n FROM access_requests WHERE status = 'pending'",
|
|
88
|
+
);
|
|
89
|
+
return row?.n ?? 0;
|
|
90
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { AuditRow } from "../core/audit-chain";
|
|
2
|
+
import { executeWithChanges, queryAll, queryOne } from "./client";
|
|
3
|
+
|
|
4
|
+
// §16 — admin_audit persistence. Storage only: the hash is computed in core/audit-chain and passed in.
|
|
5
|
+
// appendIfHead is a conditional insert guarded on the chain head being unchanged, so two concurrent
|
|
6
|
+
// writers can't fork the chain (the loser inserts 0 rows and retries — see services/audit.ts).
|
|
7
|
+
|
|
8
|
+
interface AuditDbRow {
|
|
9
|
+
id: number;
|
|
10
|
+
actor_email: string;
|
|
11
|
+
action: string;
|
|
12
|
+
target: string | null;
|
|
13
|
+
detail: string | null;
|
|
14
|
+
ip: string | null;
|
|
15
|
+
ray_id: string | null;
|
|
16
|
+
prev_hash: string | null;
|
|
17
|
+
hash: string;
|
|
18
|
+
created_at: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toAuditRow(row: AuditDbRow): AuditRow {
|
|
22
|
+
return {
|
|
23
|
+
actorEmail: row.actor_email,
|
|
24
|
+
action: row.action,
|
|
25
|
+
target: row.target,
|
|
26
|
+
detail: row.detail,
|
|
27
|
+
ip: row.ip,
|
|
28
|
+
rayId: row.ray_id,
|
|
29
|
+
prevHash: row.prev_hash,
|
|
30
|
+
hash: row.hash,
|
|
31
|
+
createdAt: row.created_at,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function getHead(db: D1Database): Promise<AuditRow | null> {
|
|
36
|
+
const row = await queryOne<AuditDbRow>(db, "SELECT * FROM admin_audit ORDER BY id DESC LIMIT 1");
|
|
37
|
+
return row ? toAuditRow(row) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listInOrder(db: D1Database): Promise<AuditRow[]> {
|
|
41
|
+
const rows = await queryAll<AuditDbRow>(db, "SELECT * FROM admin_audit ORDER BY id");
|
|
42
|
+
return rows.map(toAuditRow);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuditFilter {
|
|
46
|
+
actor?: string | undefined;
|
|
47
|
+
action?: string | undefined;
|
|
48
|
+
limit?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Newest-first, optionally filtered by actor/action — for the §13 #15 audit page. */
|
|
52
|
+
export async function listForDisplay(
|
|
53
|
+
db: D1Database,
|
|
54
|
+
filter: AuditFilter = {},
|
|
55
|
+
): Promise<AuditRow[]> {
|
|
56
|
+
const where: string[] = [];
|
|
57
|
+
const params: unknown[] = [];
|
|
58
|
+
if (filter.actor) {
|
|
59
|
+
// Contains-match: a partial address is the natural filter input (LIKE is ASCII case-insensitive).
|
|
60
|
+
where.push("actor_email LIKE ?");
|
|
61
|
+
params.push(`%${filter.actor.replaceAll("%", "").replaceAll("_", "")}%`);
|
|
62
|
+
}
|
|
63
|
+
if (filter.action) {
|
|
64
|
+
where.push("action = ?");
|
|
65
|
+
params.push(filter.action);
|
|
66
|
+
}
|
|
67
|
+
const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
68
|
+
const rows = await queryAll<AuditDbRow>(
|
|
69
|
+
db,
|
|
70
|
+
`SELECT * FROM admin_audit ${clause} ORDER BY id DESC LIMIT ?`,
|
|
71
|
+
[...params, filter.limit ?? 200],
|
|
72
|
+
);
|
|
73
|
+
return rows.map(toAuditRow);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Inserts only if the current head hash still equals expectedHeadHash. Returns whether it inserted. */
|
|
77
|
+
export async function appendIfHead(
|
|
78
|
+
db: D1Database,
|
|
79
|
+
row: AuditRow,
|
|
80
|
+
expectedHeadHash: string | null,
|
|
81
|
+
): Promise<boolean> {
|
|
82
|
+
const changes = await executeWithChanges(
|
|
83
|
+
db,
|
|
84
|
+
`INSERT INTO admin_audit (actor_email, action, target, detail, ip, ray_id, prev_hash, hash, created_at)
|
|
85
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
86
|
+
WHERE COALESCE((SELECT hash FROM admin_audit ORDER BY id DESC LIMIT 1), '') = ?`,
|
|
87
|
+
[
|
|
88
|
+
row.actorEmail,
|
|
89
|
+
row.action,
|
|
90
|
+
row.target,
|
|
91
|
+
row.detail,
|
|
92
|
+
row.ip,
|
|
93
|
+
row.rayId,
|
|
94
|
+
row.prevHash,
|
|
95
|
+
row.hash,
|
|
96
|
+
row.createdAt,
|
|
97
|
+
expectedHeadHash ?? "",
|
|
98
|
+
],
|
|
99
|
+
);
|
|
100
|
+
return changes > 0;
|
|
101
|
+
}
|
package/src/db/builds.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Build, BuildStatus, BuildStreamLink } from "../core/types";
|
|
2
|
+
import { execute, queryAll, queryOne } from "./client";
|
|
3
|
+
|
|
4
|
+
// Raw prepared statements for `builds` and the `build_streams` join. Maps the INTEGER `critical`
|
|
5
|
+
// flag to boolean and the snake_case columns to the Build domain shape.
|
|
6
|
+
|
|
7
|
+
interface BuildRow {
|
|
8
|
+
id: number;
|
|
9
|
+
short_version: string;
|
|
10
|
+
build_number: number;
|
|
11
|
+
object_key: string;
|
|
12
|
+
ed_signature: string;
|
|
13
|
+
length: number;
|
|
14
|
+
min_os: string | null;
|
|
15
|
+
critical: number;
|
|
16
|
+
rollback_target: number;
|
|
17
|
+
status: string;
|
|
18
|
+
hidden: number;
|
|
19
|
+
dmg_object_key: string | null;
|
|
20
|
+
dmg_length: number | null;
|
|
21
|
+
purged_at: string | null;
|
|
22
|
+
created_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toBuild(row: BuildRow): Build {
|
|
26
|
+
return {
|
|
27
|
+
id: row.id,
|
|
28
|
+
shortVersion: row.short_version,
|
|
29
|
+
buildNumber: row.build_number,
|
|
30
|
+
objectKey: row.object_key,
|
|
31
|
+
edSignature: row.ed_signature,
|
|
32
|
+
length: row.length,
|
|
33
|
+
minOs: row.min_os,
|
|
34
|
+
critical: row.critical !== 0,
|
|
35
|
+
rollbackTarget: row.rollback_target !== 0,
|
|
36
|
+
status: row.status as BuildStatus,
|
|
37
|
+
hidden: row.hidden !== 0,
|
|
38
|
+
dmgObjectKey: row.dmg_object_key,
|
|
39
|
+
dmgLength: row.dmg_length,
|
|
40
|
+
purgedAt: row.purged_at,
|
|
41
|
+
createdAt: row.created_at,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface NewBuild {
|
|
46
|
+
shortVersion: string;
|
|
47
|
+
buildNumber: number;
|
|
48
|
+
objectKey: string;
|
|
49
|
+
edSignature: string;
|
|
50
|
+
length: number;
|
|
51
|
+
minOs?: string | null;
|
|
52
|
+
critical?: boolean;
|
|
53
|
+
dmgObjectKey?: string | null;
|
|
54
|
+
dmgLength?: number | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getById(db: D1Database, id: number): Promise<Build | null> {
|
|
58
|
+
const row = await queryOne<BuildRow>(db, "SELECT * FROM builds WHERE id = ?", [id]);
|
|
59
|
+
return row ? toBuild(row) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function getByBuildNumber(db: D1Database, buildNumber: number): Promise<Build | null> {
|
|
63
|
+
const row = await queryOne<BuildRow>(db, "SELECT * FROM builds WHERE build_number = ?", [
|
|
64
|
+
buildNumber,
|
|
65
|
+
]);
|
|
66
|
+
return row ? toBuild(row) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listAll(db: D1Database): Promise<Build[]> {
|
|
70
|
+
const rows = await queryAll<BuildRow>(db, "SELECT * FROM builds ORDER BY build_number");
|
|
71
|
+
return rows.map(toBuild);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function listAvailable(db: D1Database): Promise<Build[]> {
|
|
75
|
+
const rows = await queryAll<BuildRow>(
|
|
76
|
+
db,
|
|
77
|
+
"SELECT * FROM builds WHERE status = 'available' ORDER BY build_number",
|
|
78
|
+
);
|
|
79
|
+
return rows.map(toBuild);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function insert(db: D1Database, input: NewBuild): Promise<Build> {
|
|
83
|
+
const row = await queryOne<BuildRow>(
|
|
84
|
+
db,
|
|
85
|
+
`INSERT INTO builds
|
|
86
|
+
(short_version, build_number, object_key, ed_signature, length, min_os, critical, dmg_object_key, dmg_length)
|
|
87
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
88
|
+
RETURNING *`,
|
|
89
|
+
[
|
|
90
|
+
input.shortVersion,
|
|
91
|
+
input.buildNumber,
|
|
92
|
+
input.objectKey,
|
|
93
|
+
input.edSignature,
|
|
94
|
+
input.length,
|
|
95
|
+
input.minOs ?? null,
|
|
96
|
+
input.critical ? 1 : 0,
|
|
97
|
+
input.dmgObjectKey ?? null,
|
|
98
|
+
input.dmgLength ?? null,
|
|
99
|
+
],
|
|
100
|
+
);
|
|
101
|
+
if (row === null) throw new Error("INSERT builds returned no row");
|
|
102
|
+
return toBuild(row);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function setStatus(db: D1Database, id: number, status: BuildStatus): Promise<void> {
|
|
106
|
+
await execute(db, "UPDATE builds SET status = ? WHERE id = ?", [status, id]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Admin-list visibility (declutter only; does not affect resolution/serving). */
|
|
110
|
+
export async function setHidden(db: D1Database, id: number, hidden: boolean): Promise<void> {
|
|
111
|
+
await execute(db, "UPDATE builds SET hidden = ? WHERE id = ?", [hidden ? 1 : 0, id]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function setCritical(db: D1Database, id: number, critical: boolean): Promise<void> {
|
|
115
|
+
await execute(db, "UPDATE builds SET critical = ? WHERE id = ?", [critical ? 1 : 0, id]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function setRollbackTarget(
|
|
119
|
+
db: D1Database,
|
|
120
|
+
id: number,
|
|
121
|
+
rollbackTarget: boolean,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
await execute(db, "UPDATE builds SET rollback_target = ? WHERE id = ?", [
|
|
124
|
+
rollbackTarget ? 1 : 0,
|
|
125
|
+
id,
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function linkStream(db: D1Database, buildId: number, streamId: number): Promise<void> {
|
|
130
|
+
await execute(db, "INSERT OR IGNORE INTO build_streams (build_id, stream_id) VALUES (?, ?)", [
|
|
131
|
+
buildId,
|
|
132
|
+
streamId,
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function unlinkStream(
|
|
137
|
+
db: D1Database,
|
|
138
|
+
buildId: number,
|
|
139
|
+
streamId: number,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
await execute(db, "DELETE FROM build_streams WHERE build_id = ? AND stream_id = ?", [
|
|
142
|
+
buildId,
|
|
143
|
+
streamId,
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stamp a build's archive as purged (bytes deleted from R2; the row is kept). `at` is the clock. */
|
|
148
|
+
export async function markPurged(db: D1Database, id: number, at: string): Promise<void> {
|
|
149
|
+
await execute(db, "UPDATE builds SET purged_at = ? WHERE id = ?", [at, id]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The sum of all stored archive bytes (zip + optional DMG) — the Builds page's bucket-total line. */
|
|
153
|
+
export async function totalArchiveBytes(db: D1Database): Promise<number> {
|
|
154
|
+
const row = await queryOne<{ total: number | null }>(
|
|
155
|
+
db,
|
|
156
|
+
"SELECT COALESCE(SUM(length), 0) + COALESCE(SUM(dmg_length), 0) AS total" +
|
|
157
|
+
" FROM builds WHERE purged_at IS NULL",
|
|
158
|
+
);
|
|
159
|
+
return row?.total ?? 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** All build→stream links (the resolver/no-build World input). */
|
|
163
|
+
export async function listBuildStreams(db: D1Database): Promise<BuildStreamLink[]> {
|
|
164
|
+
const rows = await queryAll<{ build_id: number; stream_id: number }>(
|
|
165
|
+
db,
|
|
166
|
+
"SELECT build_id, stream_id FROM build_streams",
|
|
167
|
+
);
|
|
168
|
+
return rows.map((row) => ({ buildId: row.build_id, streamId: row.stream_id }));
|
|
169
|
+
}
|