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,146 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { buildDeps, type Deps } from "../../deps";
|
|
3
|
+
import type { Env } from "../../env";
|
|
4
|
+
import type { AdminEnv } from "./admin-context";
|
|
5
|
+
import { saveBranding, sendTestEmail } from "./branding";
|
|
6
|
+
import {
|
|
7
|
+
bulkBuilds,
|
|
8
|
+
linkBuildStream,
|
|
9
|
+
markCritical,
|
|
10
|
+
markRollbackTarget,
|
|
11
|
+
purgeArchive,
|
|
12
|
+
restoreBuild,
|
|
13
|
+
setBuildHidden,
|
|
14
|
+
unlinkBuildStream,
|
|
15
|
+
withdrawBuild,
|
|
16
|
+
} from "./builds";
|
|
17
|
+
import {
|
|
18
|
+
assignStream,
|
|
19
|
+
createClient,
|
|
20
|
+
pinClient,
|
|
21
|
+
reactivateClient,
|
|
22
|
+
reissueClient,
|
|
23
|
+
revokeClient,
|
|
24
|
+
setClientHidden,
|
|
25
|
+
unassignStream,
|
|
26
|
+
unpinClient,
|
|
27
|
+
} from "./clients";
|
|
28
|
+
import { adminAuth } from "./middleware";
|
|
29
|
+
import { dismissPending, invitePending } from "./pending";
|
|
30
|
+
import { assignUsersToStream, createStream, deleteStream, linkBuildsToStream } from "./streams";
|
|
31
|
+
import { setTheme } from "./theme";
|
|
32
|
+
import { publishInfo, registerBuild, uploadBuild } from "./upload";
|
|
33
|
+
import {
|
|
34
|
+
activityView,
|
|
35
|
+
auditView,
|
|
36
|
+
buildManageView,
|
|
37
|
+
buildsView,
|
|
38
|
+
ciView,
|
|
39
|
+
dashboardView,
|
|
40
|
+
pendingView,
|
|
41
|
+
settingsView,
|
|
42
|
+
setupView,
|
|
43
|
+
streamManageView,
|
|
44
|
+
streamsView,
|
|
45
|
+
uploadView,
|
|
46
|
+
userManageView,
|
|
47
|
+
usersView,
|
|
48
|
+
} from "./views";
|
|
49
|
+
|
|
50
|
+
// The gated Admin Worker surface (ROLE=admin). EVERY request passes through adminAuth from one mount,
|
|
51
|
+
// so no route can forget verification. Public paths (/get, /appcast, …) aren't mounted → 404. Read
|
|
52
|
+
// views and mutations are added in M12+. depsFor is injectable so tests swap the Access verifier.
|
|
53
|
+
export function createAdminApp(depsFor: (env: Env) => Deps = buildDeps) {
|
|
54
|
+
const app = new Hono<AdminEnv>();
|
|
55
|
+
|
|
56
|
+
app.use("*", async (c, next) => {
|
|
57
|
+
c.set("deps", depsFor(c.env));
|
|
58
|
+
await next();
|
|
59
|
+
});
|
|
60
|
+
app.use("*", adminAuth);
|
|
61
|
+
|
|
62
|
+
// Decision 0006 enforced globally, not just on mutations: a service token may reach ONLY the
|
|
63
|
+
// publish surface. Reads matter too — user pages render live invite links, so a leaked CI
|
|
64
|
+
// credential must not be able to browse the back office.
|
|
65
|
+
const serviceAllowed = new Set([
|
|
66
|
+
"/admin/builds/upload",
|
|
67
|
+
"/admin/builds/register",
|
|
68
|
+
"/admin/publish-info",
|
|
69
|
+
]);
|
|
70
|
+
app.use("*", async (c, next) => {
|
|
71
|
+
const actor = c.get("actor");
|
|
72
|
+
if (actor.kind === "service" && !serviceAllowed.has(new URL(c.req.url).pathname)) {
|
|
73
|
+
return c.text("Forbidden — service tokens may only publish (decision 0006)", 403);
|
|
74
|
+
}
|
|
75
|
+
await next();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// The deploy output and onboarding hand out the bare admin origin — route it home instead of 404.
|
|
79
|
+
app.get("/", (c) => c.redirect("/admin", 302));
|
|
80
|
+
|
|
81
|
+
app.get("/admin", dashboardView);
|
|
82
|
+
app.get("/admin/users", usersView);
|
|
83
|
+
app.get("/admin/users/:id", userManageView);
|
|
84
|
+
app.get("/admin/builds", buildsView);
|
|
85
|
+
app.get("/admin/builds/:id", buildManageView);
|
|
86
|
+
app.get("/admin/streams", streamsView);
|
|
87
|
+
app.get("/admin/streams/:id", streamManageView);
|
|
88
|
+
app.get("/admin/pending", pendingView);
|
|
89
|
+
app.get("/admin/upload", uploadView);
|
|
90
|
+
app.get("/admin/ci", ciView);
|
|
91
|
+
app.get("/admin/setup", setupView);
|
|
92
|
+
app.get("/admin/settings", settingsView);
|
|
93
|
+
app.get("/admin/activity", activityView);
|
|
94
|
+
app.get("/admin/audit", auditView);
|
|
95
|
+
|
|
96
|
+
// Read-only publish helper (service-token allowed, decision 0006): top build + channels + caps,
|
|
97
|
+
// so a publish script pre-checks locally instead of failing after a full upload.
|
|
98
|
+
app.get("/admin/publish-info", publishInfo);
|
|
99
|
+
|
|
100
|
+
// UI preference (theme toggle) — human only, not audited (no domain change)
|
|
101
|
+
app.post("/admin/theme", setTheme);
|
|
102
|
+
|
|
103
|
+
// Channel mutations (§13). The :id/link and :id/assign batch routes serve the channel page's
|
|
104
|
+
// multi-select pickers (repeated buildId/clientId fields); both are additive and never strand.
|
|
105
|
+
app.post("/admin/streams", createStream);
|
|
106
|
+
app.post("/admin/streams/:id/delete", deleteStream);
|
|
107
|
+
app.post("/admin/streams/:id/link", linkBuildsToStream);
|
|
108
|
+
app.post("/admin/streams/:id/assign", assignUsersToStream);
|
|
109
|
+
|
|
110
|
+
// Pending access requests (§13 #10)
|
|
111
|
+
app.post("/admin/pending/:id/invite", invitePending);
|
|
112
|
+
app.post("/admin/pending/:id/dismiss", dismissPending);
|
|
113
|
+
|
|
114
|
+
// Client mutations (§10/§13)
|
|
115
|
+
app.post("/admin/clients", createClient);
|
|
116
|
+
app.post("/admin/clients/:id/revoke", revokeClient);
|
|
117
|
+
app.post("/admin/clients/:id/reactivate", reactivateClient);
|
|
118
|
+
app.post("/admin/clients/:id/reissue", reissueClient);
|
|
119
|
+
app.post("/admin/clients/:id/pin", pinClient);
|
|
120
|
+
app.post("/admin/clients/:id/unpin", unpinClient);
|
|
121
|
+
app.post("/admin/clients/:id/streams/assign", assignStream);
|
|
122
|
+
app.post("/admin/clients/:id/streams/unassign", unassignStream);
|
|
123
|
+
app.post("/admin/clients/:id/hidden", setClientHidden);
|
|
124
|
+
|
|
125
|
+
// Publish (§20) — service tokens accepted here only (decision 0006)
|
|
126
|
+
app.post("/admin/builds/upload", uploadBuild);
|
|
127
|
+
app.post("/admin/builds/register", registerBuild);
|
|
128
|
+
// Branding + invite template (§13) — human only
|
|
129
|
+
app.post("/admin/branding", saveBranding);
|
|
130
|
+
app.post("/admin/settings/test-email", sendTestEmail);
|
|
131
|
+
|
|
132
|
+
// Build mutations (§9/§10/§11). The literal /bulk is mounted before the :id forms (no collision —
|
|
133
|
+
// distinct paths — but kept adjacent to the per-build mutations it batches).
|
|
134
|
+
app.post("/admin/builds/bulk", bulkBuilds);
|
|
135
|
+
app.post("/admin/builds/:id/withdraw", withdrawBuild);
|
|
136
|
+
app.post("/admin/builds/:id/restore", restoreBuild);
|
|
137
|
+
app.post("/admin/builds/:id/critical", markCritical);
|
|
138
|
+
app.post("/admin/builds/:id/rollback", markRollbackTarget);
|
|
139
|
+
app.post("/admin/builds/:id/hidden", setBuildHidden);
|
|
140
|
+
app.post("/admin/builds/:id/purge-archive", purgeArchive);
|
|
141
|
+
app.post("/admin/builds/:id/streams/link", linkBuildStream);
|
|
142
|
+
app.post("/admin/builds/:id/streams/unlink", unlinkBuildStream);
|
|
143
|
+
|
|
144
|
+
app.notFound((c) => c.text("Not found", 404));
|
|
145
|
+
return app;
|
|
146
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { renderInvite } from "../../core/invite-template";
|
|
2
|
+
import type { Env } from "../../env";
|
|
3
|
+
import { inviteUrl } from "../../lib/hosts";
|
|
4
|
+
import { loadBranding, loadInviteTemplate } from "../../services/branding";
|
|
5
|
+
import { emailStatus } from "../../services/email";
|
|
6
|
+
import type { AdminContext } from "./admin-context";
|
|
7
|
+
|
|
8
|
+
// Composing + delivering the §13 invite, shared by Add-user (clients) and Invite-request (pending).
|
|
9
|
+
// A delivery failure must NEVER 500 the request: the client row is already written, and the copy-paste
|
|
10
|
+
// link is the always-available fallback. So this catches the send error and reports it for the page to
|
|
11
|
+
// surface, rather than letting it bubble into a bare "Internal Server Error".
|
|
12
|
+
|
|
13
|
+
export interface Delivery {
|
|
14
|
+
/** True only when email is configured AND the send succeeded. */
|
|
15
|
+
sent: boolean;
|
|
16
|
+
/** The provider's error when a configured send failed (shown to the admin). */
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the invite link, attempt email delivery, and report the outcome. Returns `delivery: undefined`
|
|
22
|
+
* in copy-paste mode (no email configured, nothing attempted) — callers then just show the link.
|
|
23
|
+
*/
|
|
24
|
+
export interface InviteMessage {
|
|
25
|
+
subject: string;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function sendInvite(
|
|
30
|
+
c: AdminContext,
|
|
31
|
+
to: string,
|
|
32
|
+
token: string,
|
|
33
|
+
): Promise<{ url: string; delivery?: Delivery | undefined; message: InviteMessage }> {
|
|
34
|
+
const deps = c.get("deps");
|
|
35
|
+
const url = inviteUrl(c.req.url, token);
|
|
36
|
+
const [branding, template] = await Promise.all([loadBranding(deps), loadInviteTemplate(deps)]);
|
|
37
|
+
const invite = renderInvite(template, { appName: branding.appName, getUrl: url, token });
|
|
38
|
+
|
|
39
|
+
// Only report a delivery status when email is actually configured; otherwise the sender is the no-op
|
|
40
|
+
// (copy-paste) and there is nothing to report. `c.env` is always present at runtime; the guard keeps
|
|
41
|
+
// this safe if a caller (or test) invokes the app without binding env.
|
|
42
|
+
const env = c.env as Env | undefined;
|
|
43
|
+
const configured = env !== undefined && emailStatus(env).mode === "active";
|
|
44
|
+
const message: InviteMessage = { subject: invite.subject, body: invite.body };
|
|
45
|
+
try {
|
|
46
|
+
await deps.email.send({ to, subject: invite.subject, body: invite.body });
|
|
47
|
+
return { url, delivery: configured ? { sent: true } : undefined, message };
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Surface the full provider error in the Worker logs (`wrangler tail`) — the page only gets .message.
|
|
50
|
+
console.error("[invite] email send to", to, "failed:", e);
|
|
51
|
+
return {
|
|
52
|
+
url,
|
|
53
|
+
delivery: { sent: false, error: e instanceof Error ? e.message : String(e) },
|
|
54
|
+
message,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AdminContext, AdminEnv } from "./admin-context";
|
|
3
|
+
|
|
4
|
+
// §4 — the single auth mount for every admin request. Verifies the Access JWT and fails CLOSED (403)
|
|
5
|
+
// on any rejection; the verified actor is attached for downstream authorization and audit.
|
|
6
|
+
//
|
|
7
|
+
// Reason disclosure is careful: when NO JWT header is present, the request never passed edge Access,
|
|
8
|
+
// so we reveal nothing (bare 403 — an attacker probing the origin learns only "denied"). When a JWT
|
|
9
|
+
// IS present but fails verification, the caller already holds a team-issued token, so naming the
|
|
10
|
+
// category (stale AUD after recreating the Access app, expired session, wrong team) turns the most-
|
|
11
|
+
// cited support issue — a mystery 403 the operator debugs via the troubleshooting doc — into a
|
|
12
|
+
// self-explaining page, without leaking anything a token-holder couldn't already infer.
|
|
13
|
+
export const adminAuth: MiddlewareHandler<AdminEnv> = async (c, next) => {
|
|
14
|
+
const result = await c.get("deps").access.verify(c.req.raw.headers);
|
|
15
|
+
if (result.kind === "reject") {
|
|
16
|
+
const presented = c.req.raw.headers.has("Cf-Access-Jwt-Assertion");
|
|
17
|
+
if (!presented) return c.text("Forbidden", 403);
|
|
18
|
+
return c.text(rejectionHelp(result.reason), 403);
|
|
19
|
+
}
|
|
20
|
+
c.set("actor", result);
|
|
21
|
+
await next();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A short operator-facing explanation + fix for a verification failure, keyed off its reason. */
|
|
25
|
+
function rejectionHelp(reason: string): string {
|
|
26
|
+
const r = reason.toLowerCase();
|
|
27
|
+
const fix =
|
|
28
|
+
"Re-run: ./deploy/deploy.sh --instance <slug> --access-team-domain <team> --access-aud <aud>";
|
|
29
|
+
if (r.includes("aud")) {
|
|
30
|
+
return `Access rejected: the token's audience doesn't match this Worker's configured AUD. This usually means the Access application was deleted and recreated (new AUD). ${fix}`;
|
|
31
|
+
}
|
|
32
|
+
if (r.includes("iss") || r.includes("team")) {
|
|
33
|
+
return `Access rejected: the token's issuer doesn't match the configured team domain — likely a renamed Zero Trust team. ${fix.replace("<aud>", "<same aud>")}`;
|
|
34
|
+
}
|
|
35
|
+
if (r.includes("exp") || r.includes("expired")) {
|
|
36
|
+
return "Access rejected: your session token expired. Reload this page to get a fresh one.";
|
|
37
|
+
}
|
|
38
|
+
if (r.includes("secret") || r.includes("unset") || r.includes("not configured")) {
|
|
39
|
+
return `Access rejected: this admin Worker has no Access secrets set, so it fails closed. ${fix}`;
|
|
40
|
+
}
|
|
41
|
+
return `Access rejected (${reason}). If the Access application was recently changed, re-wire it: ${fix}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Restricts a route to a human admin (email JWT), rejecting service tokens. Mutation handlers call
|
|
46
|
+
* this; only build upload/register (M15) accept a service token (decision 0006 — bounds a leaked CI
|
|
47
|
+
* credential to publishing). Returns the actor email, or null if a service token must be refused.
|
|
48
|
+
*/
|
|
49
|
+
export function requireUser(c: AdminContext): { email: string } | null {
|
|
50
|
+
const actor = c.get("actor");
|
|
51
|
+
return actor.kind === "user" ? { email: actor.email } : null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AdminContext } from "./admin-context";
|
|
2
|
+
|
|
3
|
+
// Content negotiation for the dual-audience admin POSTs. Build upload/register accept BOTH a human
|
|
4
|
+
// admin and a CI service token (decision 0006), so a single response shape can't serve both: a browser
|
|
5
|
+
// form submit should land on a real page, while a CI client must keep getting machine JSON.
|
|
6
|
+
//
|
|
7
|
+
// Rule: HTML only for a human actor whose request accepts text/html. So a service token (CI) always
|
|
8
|
+
// gets JSON, and a human hitting the API with `Accept: application/json` still gets JSON — only an
|
|
9
|
+
// actual browser form post (user JWT + `Accept: text/html`) is rendered as a page.
|
|
10
|
+
export function wantsHtml(c: AdminContext): boolean {
|
|
11
|
+
if (c.get("actor").kind !== "user") return false;
|
|
12
|
+
return (c.req.header("accept") ?? "").includes("text/html");
|
|
13
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { generateToken } from "../../core/tokens";
|
|
2
|
+
import * as accessRequests from "../../db/access-requests";
|
|
3
|
+
import * as clients from "../../db/clients";
|
|
4
|
+
import { recordAudit } from "../../services/audit";
|
|
5
|
+
import { InvitePage } from "../../views/admin/manage-pages";
|
|
6
|
+
import { renderPage } from "../../views/layout";
|
|
7
|
+
import type { AdminContext } from "./admin-context";
|
|
8
|
+
import { auditFields } from "./audit-fields";
|
|
9
|
+
import { doneRedirect } from "./flash";
|
|
10
|
+
import { toId } from "./form";
|
|
11
|
+
import { sendInvite } from "./invite";
|
|
12
|
+
import { requireUser } from "./middleware";
|
|
13
|
+
|
|
14
|
+
// §13 #10 — acting on a pending access request: invite (reissue an existing client by email, or
|
|
15
|
+
// create a new one) and show the /get link, or dismiss. Human-only. Both resolve EVERY pending row
|
|
16
|
+
// for the email, not just the clicked one — duplicates are the norm in copy-paste mode, and a stale
|
|
17
|
+
// sibling's Invite button would otherwise silently rotate the token that was just sent.
|
|
18
|
+
|
|
19
|
+
export async function invitePending(c: AdminContext): Promise<Response> {
|
|
20
|
+
if (requireUser(c) === null) return c.text("Forbidden", 403);
|
|
21
|
+
const deps = c.get("deps");
|
|
22
|
+
const id = toId(c.req.param("id"));
|
|
23
|
+
if (id === null) return c.text("Bad request", 400);
|
|
24
|
+
const request = await accessRequests.getById(deps.db, id);
|
|
25
|
+
if (request === null) return c.text("Not found", 404);
|
|
26
|
+
|
|
27
|
+
const token = generateToken();
|
|
28
|
+
const existing = await clients.findByEmail(deps.db, request.email);
|
|
29
|
+
const restored = existing !== null && existing.status !== "active";
|
|
30
|
+
if (existing !== null) {
|
|
31
|
+
// Re-grant to an existing client (the revoked-user re-access path, §12). Reissue the token AND
|
|
32
|
+
// re-activate — without flipping status back to "active" a revoked re-requester's new /get link is
|
|
33
|
+
// dead on arrival (every public route gates on kind === "active").
|
|
34
|
+
await clients.setToken(deps.db, existing.id, token);
|
|
35
|
+
if (restored) {
|
|
36
|
+
await clients.setStatus(deps.db, existing.id, "active");
|
|
37
|
+
await recordAudit(deps, auditFields(c, "client.reactivate", request.email));
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
await clients.insert(deps.db, { email: request.email, token });
|
|
41
|
+
}
|
|
42
|
+
await accessRequests.setStatusByEmail(deps.db, request.email, "handled");
|
|
43
|
+
await recordAudit(deps, auditFields(c, "request.invite", request.email));
|
|
44
|
+
|
|
45
|
+
// Delivery never throws: a failed send still returns the link (the copy-paste fallback) with a notice.
|
|
46
|
+
const { url, delivery, message } = await sendInvite(c, request.email, token);
|
|
47
|
+
return c.html(
|
|
48
|
+
renderPage(
|
|
49
|
+
<InvitePage
|
|
50
|
+
email={request.email}
|
|
51
|
+
getUrl={url}
|
|
52
|
+
delivery={delivery}
|
|
53
|
+
message={message}
|
|
54
|
+
restored={restored}
|
|
55
|
+
/>,
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function dismissPending(c: AdminContext): Promise<Response> {
|
|
61
|
+
if (requireUser(c) === null) return c.text("Forbidden", 403);
|
|
62
|
+
const deps = c.get("deps");
|
|
63
|
+
const body = await c.req.parseBody();
|
|
64
|
+
const id = toId(c.req.param("id"));
|
|
65
|
+
if (id === null) return c.text("Bad request", 400);
|
|
66
|
+
|
|
67
|
+
const request = await accessRequests.getById(deps.db, id);
|
|
68
|
+
if (request === null) return c.text("Not found", 404); // no phantom audit rows for stale clicks
|
|
69
|
+
|
|
70
|
+
await accessRequests.setStatusByEmail(deps.db, request.email, "dismissed");
|
|
71
|
+
await recordAudit(deps, auditFields(c, "request.dismiss", request.email));
|
|
72
|
+
return doneRedirect(c, body, "/admin/pending", "request.dismissed", request.email);
|
|
73
|
+
}
|