caracms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Live mode (`src/live/`)
|
|
2
|
+
|
|
3
|
+
M4's server side: git, Redis and the mode. Spec: `notes/2026-10-14-m4-drafts-publish.md`
|
|
4
|
+
(Decisions 1–3, 5–8, 10, 12, 14, 16, 17). Phase 1 built this folder with no UI; Phase 2's
|
|
5
|
+
live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on top.
|
|
6
|
+
|
|
7
|
+
| File | What |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `mode.ts` | `readMode(env, production)` → `local`, `live` (with `LiveSettings`) or `unconfigured` (`missing`, `problems`); `unconfiguredMessage()` for the 503 |
|
|
10
|
+
| `runtime.ts` | `liveRuntime(site, settings)`: one per process, lazy. `createLiveRuntime({...})` for tests (inject backend, Redis, clock). `CARACMS_GITHUB=memory`: a MemoryBackend seeded from disk (`siteFiles`), refused in production |
|
|
11
|
+
| `git.ts` | `LiveGit`: the `drafts` copy (`files()`, `read()`, `contentSource()`), `commitDrafts()`, `check()`, `publish()` and its `commitPending` hook, `waiting()`, `publishStatus()`, `history()`, `tryAgain()`. M5a: a draft entry with `text` null removes the file; "Contact (deleted)" in the headlines; `mainHas`, `changeOf` (new, deleted or changed), `titleFor` |
|
|
12
|
+
| `timings.ts` | `LiveTimings` and `DEFAULT_TIMINGS`: every timing, Phase 2's sweep and lock ones included |
|
|
13
|
+
| `merge.ts` | `mergeFile({ base, draft, other })`: the per-key three-way merge, pure and byte-preserving |
|
|
14
|
+
| `messages.ts` | The commit-message contract (`draftMessage`, `mergeMessage`, `publishMessage`, `parseMessage`), `Person`, `displayName` |
|
|
15
|
+
| `status.ts` | Pure: `waitingFromCommits()`, `publishState()`, `PublishRecord` |
|
|
16
|
+
| `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`) |
|
|
17
|
+
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
18
|
+
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
19
|
+
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
|
|
20
|
+
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
21
|
+
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|
|
22
|
+
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll, lapse only once committed, releasing, Edit here; presence |
|
|
23
|
+
| `sweep.ts` | Phase 2. `Sweep`: one commit per tick for everything ready; ticks only while something is pending (no Redis call otherwise); `commitAll` fills `git.commitPending` |
|
|
24
|
+
| `drafts-store.ts` | Phase 2. The live `DraftsStore`: the copy with autosaves on top (a gone one leaves the file out); `write` takes the lock and stores the autosave. M5a Phase 3: `create` (the lock, then the new document as an autosave) and `delete` (the lock, then a gone autosave, the lock released to drop once committed), with `editor/server/create.ts`'s rules. M5a: any kind of document; Site Settings' autosave is marked `sweepAtOnce` (the sweep commits it on its next tick, no idle wait); `source()` is the copy with every autosave on top (a preview's reads) |
|
|
25
|
+
| `editor.ts` | Phase 2. `createLiveEditor(runtime)` / `liveEditor(site, settings)`: the store, sweep and locks, and the handlers for write, status, lock, publish, history, try-again, notice |
|
|
26
|
+
| `locals.ts` | The hand-off from the sign-in guard to the routes: `setPerson` / `personOf` on Astro's `locals` |
|
|
27
|
+
| `auth/` | Phase 3. Sign-in: the whitelist, the session cookie, links and rate limits, email, the guard and the `/admin/login` pages (map: `auth/index.ts`'s header) |
|
|
28
|
+
|
|
29
|
+
Rules that hold everywhere here:
|
|
30
|
+
|
|
31
|
+
- **Two servers run at once during a deploy.** Ref moves are compare-and-swap; after any
|
|
32
|
+
failed move both heads are re-read and the copy refreshed before a retry. The newest
|
|
33
|
+
publish lives in Redis (`caracms:publish:last`), not in memory.
|
|
34
|
+
- **Status reads never ask GitHub**, except `check()`'s once-a-minute ref read (started,
|
|
35
|
+
never awaited: the poll answers with what's known) and the deployment reads while a
|
|
36
|
+
publish is in flight (at most every 10 s). Every GitHub request times out after 20 s.
|
|
37
|
+
- **The startup check runs in `createLiveEditor`**, once `commitPending` is filled, so a
|
|
38
|
+
merge from `main` at startup commits pending autosaves first (Decision 8).
|
|
39
|
+
- **Nothing polls Redis.** The client connects on the first command and closes after 30 s idle.
|
|
40
|
+
- **Paths:** the copy and every message use paths relative to the site's folder
|
|
41
|
+
(`content/pages/index.json`); `repoPath` is joined only at the git boundary.
|
|
42
|
+
|
|
43
|
+
The throwaway repo's scripts are in `../../scripts/throwaway/` (`seed.ts`, `run.ts`).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whitelist's rules for one entry (M4 Decision 15), browser-safe (no imports), so
|
|
3
|
+
* the editor's Site Settings form checks an entry as it's typed by exactly the rules
|
|
4
|
+
* the server applies (M5a Decision 10). allow.ts re-exports these; nothing here changed
|
|
5
|
+
* when they moved.
|
|
6
|
+
*/
|
|
7
|
+
/** Public mail domains: never a domain entry. The template's list, as is. */
|
|
8
|
+
export const PUBLIC_MAIL_DOMAINS: ReadonlySet<string> = new Set([
|
|
9
|
+
'gmail.com',
|
|
10
|
+
'googlemail.com',
|
|
11
|
+
'outlook.com',
|
|
12
|
+
'hotmail.com',
|
|
13
|
+
'live.com',
|
|
14
|
+
'msn.com',
|
|
15
|
+
'icloud.com',
|
|
16
|
+
'me.com',
|
|
17
|
+
'mac.com',
|
|
18
|
+
'yahoo.com',
|
|
19
|
+
'yahoo.co.nz',
|
|
20
|
+
'aol.com',
|
|
21
|
+
'proton.me',
|
|
22
|
+
'protonmail.com',
|
|
23
|
+
'gmx.com',
|
|
24
|
+
'xtra.co.nz',
|
|
25
|
+
// Regional versions and NZ internet providers.
|
|
26
|
+
'hotmail.co.nz',
|
|
27
|
+
'hotmail.co.uk',
|
|
28
|
+
'hotmail.com.au',
|
|
29
|
+
'outlook.co.nz',
|
|
30
|
+
'outlook.com.au',
|
|
31
|
+
'live.co.nz',
|
|
32
|
+
'live.com.au',
|
|
33
|
+
'yahoo.co.uk',
|
|
34
|
+
'yahoo.com.au',
|
|
35
|
+
'ymail.com',
|
|
36
|
+
'rocketmail.com',
|
|
37
|
+
'clear.net.nz',
|
|
38
|
+
'vodafone.co.nz',
|
|
39
|
+
'slingshot.co.nz',
|
|
40
|
+
'orcon.net.nz',
|
|
41
|
+
'paradise.net.nz',
|
|
42
|
+
'ihug.co.nz',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const ADDRESS_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
46
|
+
const DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
|
|
47
|
+
/** Longest address accepted (RFC 5321's limit). */
|
|
48
|
+
const MAX_ADDRESS = 254;
|
|
49
|
+
|
|
50
|
+
/** An address as it's compared and stored: trimmed and lower-cased, or '' if it isn't one. */
|
|
51
|
+
export function normaliseEmail(value: unknown): string {
|
|
52
|
+
if (typeof value !== 'string') return '';
|
|
53
|
+
const address = value.trim().toLowerCase();
|
|
54
|
+
return address.length <= MAX_ADDRESS && ADDRESS_RE.test(address) ? address : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type AllowEntry = { kind: 'address' | 'domain'; value: string } | { kind: 'public-domain' | 'invalid'; value: string };
|
|
58
|
+
|
|
59
|
+
/** What one whitelist entry is. */
|
|
60
|
+
export function parseAllowEntry(value: unknown): AllowEntry {
|
|
61
|
+
const entry = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
62
|
+
if (entry.startsWith('@')) {
|
|
63
|
+
const domain = entry.slice(1);
|
|
64
|
+
if (!DOMAIN_RE.test(domain)) return { kind: 'invalid', value: entry };
|
|
65
|
+
if (PUBLIC_MAIL_DOMAINS.has(domain)) return { kind: 'public-domain', value: entry };
|
|
66
|
+
return { kind: 'domain', value: domain };
|
|
67
|
+
}
|
|
68
|
+
if (normaliseEmail(entry)) return { kind: 'address', value: entry };
|
|
69
|
+
return { kind: 'invalid', value: entry };
|
|
70
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may sign in: the whitelist (M4 Decision 15; the template's `server/admin-access.mjs`).
|
|
3
|
+
*
|
|
4
|
+
* Two halves, one rule:
|
|
5
|
+
* - **The agency half, `CARACMS_ALLOW`**: comma-separated. Read once at startup
|
|
6
|
+
* (mode.ts → `LiveSettings.allow`), so nobody can edit the agency out from the editor.
|
|
7
|
+
* - **The client half, `editors` in `content/site-settings.json`**, read from the
|
|
8
|
+
* in-memory `drafts` copy on every check, so a change takes effect at once, with no
|
|
9
|
+
* deploy. A missing file is an empty half. A file that doesn't parse, or whose
|
|
10
|
+
* `editors` isn't a list of strings, is an empty half too, **logged once per version
|
|
11
|
+
* of the file**.
|
|
12
|
+
*
|
|
13
|
+
* An entry is an exact address (`aoife@fernhill.ie`) or a whole domain (`@fernhill.ie`),
|
|
14
|
+
* lower-cased and trimmed. **A public mail domain is never accepted as a domain entry**:
|
|
15
|
+
* it's ignored, logged once (per entry, per half, per process). An exact address at one
|
|
16
|
+
* of those domains is fine. Anything that is neither shape is ignored the same way.
|
|
17
|
+
*
|
|
18
|
+
* The backup account is its own path (backup.ts), not on this list.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
export { PUBLIC_MAIL_DOMAINS, normaliseEmail, parseAllowEntry, type AllowEntry } from './allow-rules.ts';
|
|
23
|
+
import { normaliseEmail, parseAllowEntry } from './allow-rules.ts';
|
|
24
|
+
|
|
25
|
+
export interface AllowList {
|
|
26
|
+
addresses: Set<string>;
|
|
27
|
+
domains: Set<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Log = (line: string) => void;
|
|
31
|
+
|
|
32
|
+
/** Addresses and domains out of raw entries; public-domain and malformed ones dropped, each logged once. */
|
|
33
|
+
export function compileAllowList(entries: readonly unknown[], source: string, log: Log, warned: Set<string>): AllowList {
|
|
34
|
+
const addresses = new Set<string>();
|
|
35
|
+
const domains = new Set<string>();
|
|
36
|
+
for (const raw of entries) {
|
|
37
|
+
const parsed = parseAllowEntry(raw);
|
|
38
|
+
if (parsed.kind === 'address') addresses.add(parsed.value);
|
|
39
|
+
else if (parsed.kind === 'domain') domains.add(parsed.value);
|
|
40
|
+
else {
|
|
41
|
+
const key = `${source}\u0000${parsed.value}`;
|
|
42
|
+
if (warned.has(key)) continue;
|
|
43
|
+
warned.add(key);
|
|
44
|
+
log(
|
|
45
|
+
parsed.kind === 'public-domain'
|
|
46
|
+
? `ignoring "${parsed.value}" in ${source}: a public mail domain can't be a whole-domain entry (add exact addresses instead).`
|
|
47
|
+
: `ignoring "${printable(parsed.value)}" in ${source}: not an email address or @domain.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { addresses, domains };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether a normalised address matches a list: the exact address, or its domain. */
|
|
55
|
+
export function matchesAllowList(email: string, list: AllowList): boolean {
|
|
56
|
+
if (!email) return false;
|
|
57
|
+
if (list.addresses.has(email)) return true;
|
|
58
|
+
return list.domains.has(email.slice(email.lastIndexOf('@') + 1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The client half's entries out of Site Settings' text. Null text (no file) is an
|
|
63
|
+
* empty half; text that doesn't parse, or an `editors` that isn't a list of strings,
|
|
64
|
+
* is an empty half plus a reason to log.
|
|
65
|
+
*/
|
|
66
|
+
export function editorsFrom(text: string | null): { entries: string[]; problem: string | null } {
|
|
67
|
+
if (text === null) return { entries: [], problem: null };
|
|
68
|
+
let doc: unknown;
|
|
69
|
+
try {
|
|
70
|
+
doc = JSON.parse(text);
|
|
71
|
+
} catch {
|
|
72
|
+
return { entries: [], problem: "it isn't valid JSON" };
|
|
73
|
+
}
|
|
74
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { entries: [], problem: "it isn't a JSON object" };
|
|
75
|
+
const editors = (doc as Record<string, unknown>).editors;
|
|
76
|
+
if (editors === undefined) return { entries: [], problem: null };
|
|
77
|
+
if (!Array.isArray(editors) || !editors.every((e) => typeof e === 'string')) return { entries: [], problem: '"editors" isn\'t a list of strings' };
|
|
78
|
+
return { entries: editors as string[], problem: null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface Whitelist {
|
|
82
|
+
/** Whether this address may sign in right now. */
|
|
83
|
+
allows(email: unknown): boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The whitelist over both halves. `agency` is CARACMS_ALLOW's entries; `readSettings`
|
|
88
|
+
* gives Site Settings' current text from the `drafts` copy (or null), read on every
|
|
89
|
+
* check. The compiled client half is cached per version of the file.
|
|
90
|
+
*/
|
|
91
|
+
export function createWhitelist(options: {
|
|
92
|
+
agency: readonly string[];
|
|
93
|
+
readSettings: () => string | null;
|
|
94
|
+
settingsFile: string;
|
|
95
|
+
log: Log;
|
|
96
|
+
}): Whitelist {
|
|
97
|
+
const warned = new Set<string>();
|
|
98
|
+
const agency = compileAllowList(options.agency, 'CARACMS_ALLOW', options.log, warned);
|
|
99
|
+
let cached: { text: string | null; list: AllowList } | null = null;
|
|
100
|
+
const loggedVersions = new Set<string>();
|
|
101
|
+
const client = (): AllowList => {
|
|
102
|
+
const text = options.readSettings();
|
|
103
|
+
if (cached && cached.text === text) return cached.list;
|
|
104
|
+
const { entries, problem } = editorsFrom(text);
|
|
105
|
+
if (problem) {
|
|
106
|
+
const version = createHash('sha256').update(text ?? '').digest('hex');
|
|
107
|
+
if (!loggedVersions.has(version)) {
|
|
108
|
+
loggedVersions.add(version);
|
|
109
|
+
options.log(`${options.settingsFile}: ${problem}, so its editors list is empty; only CARACMS_ALLOW (and the backup account) can sign in.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const list = compileAllowList(entries, `${options.settingsFile} → editors`, options.log, warned);
|
|
113
|
+
cached = { text, list };
|
|
114
|
+
return list;
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
allows(email) {
|
|
118
|
+
const address = normaliseEmail(email);
|
|
119
|
+
if (!address) return false;
|
|
120
|
+
return matchesAllowList(address, agency) || matchesAllowList(address, client());
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printable(value: string): string {
|
|
126
|
+
const shown = JSON.stringify(value).slice(1, -1);
|
|
127
|
+
return shown.length > 80 ? `${shown.slice(0, 77)}...` : shown;
|
|
128
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in's pieces, built once per process over the live runtime (M4 Phase 3):
|
|
3
|
+
* the whitelist, link tokens and rate limits in Redis, the backup account and the
|
|
4
|
+
* session. The guard (guard.ts) and the sign-in pages (login.ts) share one of these.
|
|
5
|
+
*
|
|
6
|
+
* `identify()` is the per-request check: the signed cookie, then **the whitelist
|
|
7
|
+
* again** (a `link` session whose address is no longer on it is `removed`, Decision
|
|
8
|
+
* 11), or the backup account with the same username and password as at sign-in.
|
|
9
|
+
* It makes no Redis call: the whitelist's client half is read from the in-memory
|
|
10
|
+
* `drafts` copy, so a guard check never wakes Redis.
|
|
11
|
+
*/
|
|
12
|
+
import { SITE_SETTINGS_FILE } from '../git.ts';
|
|
13
|
+
import type { Person } from '../messages.ts';
|
|
14
|
+
import type { RedisClient } from '../redis.ts';
|
|
15
|
+
import { createWhitelist, type Whitelist } from './allow.ts';
|
|
16
|
+
import { deliverLink, type Delivery } from './email.ts';
|
|
17
|
+
import { createLinkStore, createRateLimits, type LinkStore, type RateLimits } from './links.ts';
|
|
18
|
+
import {
|
|
19
|
+
backupConfig,
|
|
20
|
+
backupPerson,
|
|
21
|
+
clearedSessionCookie,
|
|
22
|
+
freshPayload,
|
|
23
|
+
readCookie,
|
|
24
|
+
safeEqual,
|
|
25
|
+
SESSION_MAX_AGE_S,
|
|
26
|
+
SESSION_RENEW_MS,
|
|
27
|
+
sessionCookie,
|
|
28
|
+
sessionCookieName,
|
|
29
|
+
signSession,
|
|
30
|
+
verifySession,
|
|
31
|
+
type BackupConfig,
|
|
32
|
+
type SessionPayload,
|
|
33
|
+
} from './session.ts';
|
|
34
|
+
|
|
35
|
+
export type SignedOutReason = 'signed-out' | 'removed';
|
|
36
|
+
|
|
37
|
+
export type Identity =
|
|
38
|
+
| { state: 'in'; person: Person; payload: SessionPayload; /** A renewed Set-Cookie value, when the cookie is over an hour old. */ renew: string | null }
|
|
39
|
+
| { state: 'out'; reason: SignedOutReason };
|
|
40
|
+
|
|
41
|
+
export interface AuthOptions {
|
|
42
|
+
secret: string;
|
|
43
|
+
/** CARACMS_ALLOW's entries (mode.ts → LiveSettings.allow). */
|
|
44
|
+
agency: readonly string[];
|
|
45
|
+
backup: { user: string; password: string } | null;
|
|
46
|
+
/** Secure cookies (`__Host-`, `Secure`): a production server. */
|
|
47
|
+
secure: boolean;
|
|
48
|
+
redis: RedisClient;
|
|
49
|
+
/** Site Settings' text from the `drafts` copy, or null. */
|
|
50
|
+
readSettings: () => string | null;
|
|
51
|
+
/** The site's name, for the email and the confirm page: a getter reads Site Settings' brand as committed (M5a Decision 10). */
|
|
52
|
+
siteName: string | (() => string);
|
|
53
|
+
/**
|
|
54
|
+
* The origin emailed links point at (the site's public address). Required to email
|
|
55
|
+
* a link: a request's own Host header is never used for an emailed link, or anyone
|
|
56
|
+
* could have a real token mailed out pointing at their own host.
|
|
57
|
+
*/
|
|
58
|
+
siteOrigin: string | null;
|
|
59
|
+
env: Record<string, string | undefined>;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
log?: (line: string) => void;
|
|
62
|
+
/** Tests: replace the delivery (email or log). */
|
|
63
|
+
deliver?: typeof deliverLink;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Auth {
|
|
67
|
+
readonly secure: boolean;
|
|
68
|
+
readonly cookieName: string;
|
|
69
|
+
readonly whitelist: Whitelist;
|
|
70
|
+
readonly links: LinkStore;
|
|
71
|
+
readonly limits: RateLimits;
|
|
72
|
+
readonly backup: BackupConfig;
|
|
73
|
+
/** Read on each use: Site Settings' brand name can change while the server runs (M5a). */
|
|
74
|
+
readonly siteName: string;
|
|
75
|
+
readonly now: () => number;
|
|
76
|
+
readonly log: (line: string) => void;
|
|
77
|
+
identify(cookieHeader: string | null): Identity;
|
|
78
|
+
/** The Set-Cookie value for a new session. */
|
|
79
|
+
startSession(identity: { kind: 'link'; email: string } | { kind: 'backup' }): string;
|
|
80
|
+
clearCookie(): string;
|
|
81
|
+
/** Issue and deliver a link, after the response. Never throws. */
|
|
82
|
+
sendLink(email: string, next: string, requestOrigin: string): Promise<Delivery | 'not-sent'>;
|
|
83
|
+
/** Links being sent right now (tests wait on them). */
|
|
84
|
+
readonly sending: Set<Promise<unknown>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createAuth(options: AuthOptions): Auth {
|
|
88
|
+
const now = options.now ?? Date.now;
|
|
89
|
+
const log = options.log ?? ((line: string) => console.log(`[caracms] sign-in: ${line}`));
|
|
90
|
+
const backup = backupConfig(options.backup, options.secret, log);
|
|
91
|
+
const whitelist = createWhitelist({ agency: options.agency, readSettings: options.readSettings, settingsFile: SITE_SETTINGS_FILE, log });
|
|
92
|
+
const links = createLinkStore(options.redis);
|
|
93
|
+
const limits = createRateLimits(options.redis);
|
|
94
|
+
const cookieName = sessionCookieName(options.secure);
|
|
95
|
+
const sending = new Set<Promise<unknown>>();
|
|
96
|
+
const cookieFor = (payload: SessionPayload) => sessionCookie(signSession(payload, options.secret), { secure: options.secure });
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
secure: options.secure,
|
|
100
|
+
cookieName,
|
|
101
|
+
whitelist,
|
|
102
|
+
links,
|
|
103
|
+
limits,
|
|
104
|
+
backup,
|
|
105
|
+
get siteName() {
|
|
106
|
+
return typeof options.siteName === 'function' ? options.siteName() : options.siteName;
|
|
107
|
+
},
|
|
108
|
+
now,
|
|
109
|
+
log,
|
|
110
|
+
sending,
|
|
111
|
+
identify(cookieHeader) {
|
|
112
|
+
const payload = verifySession(readCookie(cookieHeader, cookieName), options.secret, now());
|
|
113
|
+
if (!payload) return { state: 'out', reason: 'signed-out' };
|
|
114
|
+
let person: Person;
|
|
115
|
+
if (payload.kind === 'link') {
|
|
116
|
+
if (!whitelist.allows(payload.email)) return { state: 'out', reason: 'removed' };
|
|
117
|
+
person = { email: payload.email };
|
|
118
|
+
} else {
|
|
119
|
+
// Same username AND the same password as at sign-in, and the account still on.
|
|
120
|
+
const ok = backup.enabled && safeEqual(payload.user, backup.user) && safeEqual(payload.fingerprint, backup.fingerprint);
|
|
121
|
+
if (!ok) return { state: 'out', reason: 'signed-out' };
|
|
122
|
+
person = backupPerson(backup.user);
|
|
123
|
+
}
|
|
124
|
+
const renew = now() - payload.renewed > SESSION_RENEW_MS ? cookieFor({ ...payload, renewed: now(), expires: now() + SESSION_MAX_AGE_S * 1000 }) : null;
|
|
125
|
+
return { state: 'in', person, payload, renew };
|
|
126
|
+
},
|
|
127
|
+
startSession(identity) {
|
|
128
|
+
if (identity.kind === 'link') return cookieFor(freshPayload({ kind: 'link', email: identity.email }, now()));
|
|
129
|
+
if (!backup.enabled) throw new Error('the backup account is off');
|
|
130
|
+
return cookieFor(freshPayload({ kind: 'backup', user: backup.user, fingerprint: backup.fingerprint }, now()));
|
|
131
|
+
},
|
|
132
|
+
clearCookie: () => clearedSessionCookie({ secure: options.secure }),
|
|
133
|
+
sendLink(email, next, requestOrigin) {
|
|
134
|
+
const work = (async (): Promise<Delivery | 'not-sent'> => {
|
|
135
|
+
try {
|
|
136
|
+
const emailing = options.env['NODE_ENV'] === 'production';
|
|
137
|
+
const origin = options.siteOrigin ?? (emailing ? null : requestOrigin);
|
|
138
|
+
if (!origin) {
|
|
139
|
+
log("no sign-in link sent: the site's public address isn't known (set Astro's `site`, or run on Railway, which sets RAILWAY_PUBLIC_DOMAIN).");
|
|
140
|
+
return 'not-sent';
|
|
141
|
+
}
|
|
142
|
+
const token = await links.issue(email, next);
|
|
143
|
+
const link = `${origin}/admin/login/confirm?token=${token}`;
|
|
144
|
+
return await (options.deliver ?? deliverLink)({ to: email, link, siteName: typeof options.siteName === 'function' ? options.siteName() : options.siteName, env: options.env, log });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
log(`could not issue a sign-in link: ${error instanceof Error ? error.message : String(error)}`);
|
|
147
|
+
return 'not-sent';
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
sending.add(work);
|
|
151
|
+
void work.finally(() => sending.delete(work));
|
|
152
|
+
return work;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sign-in link email, and the one place that decides whether it's emailed or
|
|
3
|
+
* logged (M4 Decision 14; the template's `server/login-email.mjs`).
|
|
4
|
+
*
|
|
5
|
+
* **Only when `NODE_ENV` is `production`** is it sent, through Resend
|
|
6
|
+
* (`RESEND_API_KEY`) from `FORMS_FROM`. Everywhere else the link is printed to the
|
|
7
|
+
* server log and nothing is sent, so no test from a laptop reaches anyone's inbox.
|
|
8
|
+
* The email names the site by its brand name. The link carries only the token.
|
|
9
|
+
*
|
|
10
|
+
* The HTML part is one table layout with inline styles (mail clients strip `<style>`),
|
|
11
|
+
* the system font stack, no images; the plain-text part says the same.
|
|
12
|
+
*/
|
|
13
|
+
import { sendEmail, sendsEmail } from '../../email.ts';
|
|
14
|
+
|
|
15
|
+
export { RESEND_URL } from '../../email.ts';
|
|
16
|
+
const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
17
|
+
|
|
18
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
19
|
+
|
|
20
|
+
export function buildLinkEmail({ siteName, link, email }: { siteName: string; link: string; email: string }): { subject: string; text: string; html: string } {
|
|
21
|
+
const site = siteName.trim();
|
|
22
|
+
const subject = site ? `Sign in to the ${site} website` : 'Sign in to the website';
|
|
23
|
+
const button = site ? `Sign in to ${site}` : 'Sign in';
|
|
24
|
+
const expiry = 'The link expires in 15 minutes and works once.';
|
|
25
|
+
const ignore = "Didn't ask for this? Ignore it.";
|
|
26
|
+
const text = [subject, '', `Open this link and press "Sign in" to sign in as ${email}:`, '', link, '', expiry, '', ignore, ''].join('\n');
|
|
27
|
+
const href = escapeHtml(link);
|
|
28
|
+
const html = `<!doctype html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
33
|
+
<meta name="color-scheme" content="light">
|
|
34
|
+
<title>${escapeHtml(subject)}</title>
|
|
35
|
+
</head>
|
|
36
|
+
<body style="margin:0;padding:0;background:#f5f5f2;">
|
|
37
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f5f5f2;font-family:${FONT};">
|
|
38
|
+
<tr><td align="center" style="padding:32px 16px;">
|
|
39
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border:1px solid #e3e3de;border-radius:10px;">
|
|
40
|
+
<tr><td style="padding:28px 28px 8px;font-family:${FONT};font-size:20px;line-height:1.3;font-weight:600;color:#1b1b19;">${escapeHtml(subject)}</td></tr>
|
|
41
|
+
<tr><td style="padding:0 28px 20px;font-family:${FONT};font-size:15px;line-height:1.5;color:#1b1b19;">Sign in as <strong>${escapeHtml(email)}</strong>.</td></tr>
|
|
42
|
+
<tr><td style="padding:0 28px 20px;">
|
|
43
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#1b1b19;border-radius:6px;">
|
|
44
|
+
<a href="${href}" style="display:inline-block;padding:13px 22px;font-family:${FONT};font-size:15px;font-weight:600;line-height:1;color:#ffffff;text-decoration:none;border-radius:6px;">${escapeHtml(button)}</a>
|
|
45
|
+
</td></tr></table>
|
|
46
|
+
</td></tr>
|
|
47
|
+
<tr><td style="padding:0 28px 8px;font-family:${FONT};font-size:13px;line-height:1.5;color:#5e5e58;">${escapeHtml(expiry)}</td></tr>
|
|
48
|
+
<tr><td style="padding:0 28px 24px;font-family:${FONT};font-size:13px;line-height:1.5;color:#5e5e58;">${escapeHtml(ignore)}</td></tr>
|
|
49
|
+
<tr><td style="padding:16px 28px 24px;border-top:1px solid #e3e3de;font-family:${FONT};font-size:12px;line-height:1.5;color:#5e5e58;word-break:break-all;">Button not working? Paste this into your browser:<br><a href="${href}" style="color:#1b1b19;">${href}</a></td></tr>
|
|
50
|
+
</table>
|
|
51
|
+
</td></tr>
|
|
52
|
+
</table>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
55
|
+
`;
|
|
56
|
+
return { subject, text, html };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type Delivery = 'sent' | 'logged' | 'failed';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Email the link in production, log it everywhere else. Never throws: a Resend failure
|
|
63
|
+
* is logged, and the page never says whether a link went out. The sending is the
|
|
64
|
+
* package's one sender (../../email.ts, M6 Decision 8).
|
|
65
|
+
*/
|
|
66
|
+
export async function deliverLink(input: {
|
|
67
|
+
to: string;
|
|
68
|
+
link: string;
|
|
69
|
+
siteName: string;
|
|
70
|
+
env: Record<string, string | undefined>;
|
|
71
|
+
log: (line: string) => void;
|
|
72
|
+
fetch?: typeof fetch;
|
|
73
|
+
}): Promise<Delivery> {
|
|
74
|
+
const { to, link, siteName, env, log } = input;
|
|
75
|
+
if (!sendsEmail(env)) {
|
|
76
|
+
log(`sign-in link for ${to} (not emailed outside production): ${link}`);
|
|
77
|
+
return 'logged';
|
|
78
|
+
}
|
|
79
|
+
const { subject, text, html } = buildLinkEmail({ siteName, link, email: to });
|
|
80
|
+
const result = await sendEmail({ to: [to], subject, text, html }, { env, fetch: input.fetch });
|
|
81
|
+
if (result.status === 'sent') return 'sent';
|
|
82
|
+
log(`could not email a sign-in link: ${result.error}`);
|
|
83
|
+
return 'failed';
|
|
84
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard (M4 Decision 14): **one check on path prefixes**, `/admin` and
|
|
3
|
+
* `/_caracms/`, in live mode only. It knows no list of the editor's routes, so every
|
|
4
|
+
* route Phase 2 adds under those prefixes is covered without this file changing.
|
|
5
|
+
*
|
|
6
|
+
* - **Let through**: the sign-in pages, exactly (`/admin/login` and its own subpages,
|
|
7
|
+
* letters and hyphens only, no encoding), `POST /_caracms/github` (the webhook,
|
|
8
|
+
* which checks GitHub's signature instead, Decision 14), and `POST /_caracms/form`
|
|
9
|
+
* (the public forms' endpoint, M6 Decision 5: anyone, from anywhere).
|
|
10
|
+
* - **Every write** (any method but GET and HEAD) **also requires the same origin**
|
|
11
|
+
* (request.ts → isSameOrigin), else 403, checked before the session.
|
|
12
|
+
* - **No session**: a page (`/admin…`) redirects to sign-in with `next`; an API
|
|
13
|
+
* (`/_caracms/…`) answers 401 JSON, `{ error, code: 'signed-out', reason, signIn }`. **When the cookie is
|
|
14
|
+
* valid but the address is no longer on the whitelist, `reason` is `removed`**
|
|
15
|
+
* (Decision 11); otherwise `signed-out`. The page redirect is the same for everyone.
|
|
16
|
+
* - **A pass** gives the person (the caller records it with `setPerson`) and, when the
|
|
17
|
+
* cookie is over an hour old, a renewed one to set.
|
|
18
|
+
*
|
|
19
|
+
* Which paths count is decided on a normalised path (percent-decoded, lower-cased,
|
|
20
|
+
* repeated slashes collapsed), so `/ADMIN`, `/%61dmin` and `//admin` are guarded too.
|
|
21
|
+
* A path that won't decode is guarded. The let-through is decided on the raw path.
|
|
22
|
+
*/
|
|
23
|
+
import type { Person } from '../messages.ts';
|
|
24
|
+
import type { Auth, SignedOutReason } from './auth.ts';
|
|
25
|
+
import { isRead, isSameOrigin } from './request.ts';
|
|
26
|
+
import { cleanNext, LOGIN_PATH, loginHref } from './session.ts';
|
|
27
|
+
|
|
28
|
+
/** Whether the guard looks at this path at all. */
|
|
29
|
+
export function isGuardedPath(rawPathname: string): boolean {
|
|
30
|
+
let path: string;
|
|
31
|
+
try {
|
|
32
|
+
path = decodeURIComponent(rawPathname);
|
|
33
|
+
} catch {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
path = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/').toLowerCase();
|
|
37
|
+
return path === '/admin' || path.startsWith('/admin/') || path.startsWith('/admin.') || path === '/_caracms' || path.startsWith('/_caracms/');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const LOGIN_RE = /^\/admin\/login(?:\/[a-z-]+)?\/?$/;
|
|
41
|
+
|
|
42
|
+
/** The sign-in pages, the webhook and the form endpoint: reached with no session. */
|
|
43
|
+
export function isLetThrough(rawPathname: string, method: string): boolean {
|
|
44
|
+
if (LOGIN_RE.test(rawPathname)) return true;
|
|
45
|
+
if (method !== 'POST') return false;
|
|
46
|
+
return rawPathname === '/_caracms/github' || rawPathname === '/_caracms/github/' || isFormEndpoint(rawPathname);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `/_caracms/form`, exactly (a trailing slash allowed, no encoding): public in every mode (M6 Decision 5). */
|
|
50
|
+
export const isFormEndpoint = (rawPathname: string): boolean => rawPathname === '/_caracms/form' || rawPathname === '/_caracms/form/';
|
|
51
|
+
|
|
52
|
+
/** An API path: answered with JSON, never a redirect. */
|
|
53
|
+
const isApi = (rawPathname: string) => !/^\/+admin(?:[/.]|$)/i.test(safeDecode(rawPathname));
|
|
54
|
+
const safeDecode = (s: string) => {
|
|
55
|
+
try {
|
|
56
|
+
return decodeURIComponent(s);
|
|
57
|
+
} catch {
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The 401 body an API answers with no session. Phase 4's signed-out state reads it. */
|
|
63
|
+
export interface SignedOutBody {
|
|
64
|
+
error: string;
|
|
65
|
+
/** Always `signed-out`: types.ts → ApiError's code for a 401. */
|
|
66
|
+
code: 'signed-out';
|
|
67
|
+
/** `removed`: the cookie was valid but the address is no longer on the whitelist (Decision 11). */
|
|
68
|
+
reason: SignedOutReason;
|
|
69
|
+
/** Where "Sign in again" goes (opened in a new tab). */
|
|
70
|
+
signIn: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ForbiddenBody {
|
|
74
|
+
error: string;
|
|
75
|
+
code: 'cross-origin';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const NO_STORE = { 'Cache-Control': 'private, no-store' };
|
|
79
|
+
const json = (status: number, body: unknown) =>
|
|
80
|
+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', ...NO_STORE } });
|
|
81
|
+
|
|
82
|
+
export const SIGNED_OUT_MESSAGE: Record<SignedOutReason, string> = {
|
|
83
|
+
'signed-out': "You're signed out. Sign in again to keep editing.",
|
|
84
|
+
removed: "You can no longer sign in to this site, so your latest changes couldn't be kept.",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type GuardResult = { kind: 'skip' } | { kind: 'pass'; person: Person; setCookie: string | null } | { kind: 'respond'; response: Response };
|
|
88
|
+
|
|
89
|
+
export function guard(request: Request, auth: Auth): GuardResult {
|
|
90
|
+
const url = new URL(request.url);
|
|
91
|
+
const raw = url.pathname;
|
|
92
|
+
if (!isGuardedPath(raw) || isLetThrough(raw, request.method)) return { kind: 'skip' };
|
|
93
|
+
const api = isApi(raw);
|
|
94
|
+
|
|
95
|
+
if (!isRead(request.method) && !isSameOrigin(request)) {
|
|
96
|
+
const error = 'This only takes requests from the editor on this site.';
|
|
97
|
+
if (api) return { kind: 'respond', response: json(403, { error, code: 'cross-origin' } satisfies ForbiddenBody) };
|
|
98
|
+
return { kind: 'respond', response: new Response(error, { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...NO_STORE } }) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const who = auth.identify(request.headers.get('cookie'));
|
|
102
|
+
if (who.state === 'in') return { kind: 'pass', person: who.person, setCookie: who.renew };
|
|
103
|
+
|
|
104
|
+
if (api) {
|
|
105
|
+
const body: SignedOutBody = { error: SIGNED_OUT_MESSAGE[who.reason], code: 'signed-out', reason: who.reason, signIn: LOGIN_PATH };
|
|
106
|
+
return { kind: 'respond', response: json(401, body) };
|
|
107
|
+
}
|
|
108
|
+
// A page: the same redirect for everyone (the sign-in pages never say why).
|
|
109
|
+
const next = cleanNext(`${raw}${url.search}`);
|
|
110
|
+
return { kind: 'respond', response: new Response(null, { status: 303, headers: { Location: loginHref({}, next), ...NO_STORE } }) };
|
|
111
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in for the running server (M4 Phase 3): one `Auth` per process over the live
|
|
3
|
+
* runtime, shared by the guard (middleware.ts) and the sign-in route
|
|
4
|
+
* (`../../editor/routes/login/login.ts`). Map of this folder:
|
|
5
|
+
*
|
|
6
|
+
* | File | What |
|
|
7
|
+
* |---|---|
|
|
8
|
+
* | `allow.ts` | The whitelist: `PUBLIC_MAIL_DOMAINS`, entries, `createWhitelist` (CARACMS_ALLOW + Site Settings' `editors`) |
|
|
9
|
+
* | `session.ts` | The signed cookie, the backup account's config and fingerprint, `cleanNext` |
|
|
10
|
+
* | `links.ts` | Link tokens (SHA-256 only, 15 min, GETDEL) and rate limits, in Redis |
|
|
11
|
+
* | `email.ts` | The link email: Resend in production, the server log otherwise |
|
|
12
|
+
* | `request.ts` | The client IP (X-Real-IP first), same-origin |
|
|
13
|
+
* | `auth.ts` | `createAuth`: the pieces together, `identify()` per request |
|
|
14
|
+
* | `guard.ts` | The one check on `/admin` and `/_caracms/` |
|
|
15
|
+
* | `login.ts` | The `/admin/login…` pages |
|
|
16
|
+
* | `views.ts` | Their HTML, CSS and the one script |
|
|
17
|
+
* | `middleware.ts` | The guard as Astro middleware |
|
|
18
|
+
* | `routes.ts` | `LOGIN_ROUTES`, for the integration |
|
|
19
|
+
*/
|
|
20
|
+
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
|
|
21
|
+
import { brandFrom } from '../../editor/brand.ts';
|
|
22
|
+
import type { LiveSettings } from '../mode.ts';
|
|
23
|
+
import { liveRuntime } from '../runtime.ts';
|
|
24
|
+
import { createAuth, type Auth } from './auth.ts';
|
|
25
|
+
|
|
26
|
+
const SHARED = Symbol.for('caracms.auth');
|
|
27
|
+
type Shared = { [SHARED]?: Promise<Auth> };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The site's public origin for emailed links: Astro's `site`, else Railway's public
|
|
31
|
+
* domain. Never a request's Host header (auth.ts → `siteOrigin`).
|
|
32
|
+
*/
|
|
33
|
+
export function publicOrigin(astroSite: URL | undefined, env: Record<string, string | undefined> = process.env): string | null {
|
|
34
|
+
if (astroSite) return astroSite.origin;
|
|
35
|
+
const domain = env['RAILWAY_PUBLIC_DOMAIN']?.trim();
|
|
36
|
+
return domain ? `https://${domain}` : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The process's Auth, built on first use over the shared live runtime. */
|
|
40
|
+
export function authFor(site: ResolvedSite, settings: LiveSettings, astroSite?: URL): Promise<Auth> {
|
|
41
|
+
const g = globalThis as Shared;
|
|
42
|
+
g[SHARED] ??= liveRuntime(site, settings)
|
|
43
|
+
.then((rt) =>
|
|
44
|
+
createAuth({
|
|
45
|
+
secret: settings.secret,
|
|
46
|
+
agency: settings.allow,
|
|
47
|
+
backup: settings.backup,
|
|
48
|
+
secure: settings.production,
|
|
49
|
+
redis: rt.redis,
|
|
50
|
+
readSettings: () => rt.git.read(SITE_SETTINGS_FILE),
|
|
51
|
+
// M5a Decision 10: Site Settings' brand name as committed in `drafts`.
|
|
52
|
+
siteName: () => brandFrom(rt.git.read(SITE_SETTINGS_FILE), site).name,
|
|
53
|
+
siteOrigin: publicOrigin(astroSite),
|
|
54
|
+
env: process.env,
|
|
55
|
+
now: rt.now,
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
.catch((error: unknown) => {
|
|
59
|
+
delete g[SHARED];
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
return g[SHARED];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Forget the shared Auth (tests). */
|
|
66
|
+
export function resetAuth(): void {
|
|
67
|
+
delete (globalThis as Shared)[SHARED];
|
|
68
|
+
}
|