@tideworthy/tidysites-platform 0.1.6
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 +23 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +57 -0
- package/src/client.ts +63 -0
- package/src/index.ts +53 -0
- package/src/lib/auth.ts +50 -0
- package/src/lib/env.ts +26 -0
- package/src/lib/handlers.ts +245 -0
- package/src/lib/http.ts +81 -0
- package/src/lib/publish.ts +42 -0
- package/src/lib/runtime.ts +33 -0
- package/src/lib/store.ts +156 -0
- package/src/plugin.ts +47 -0
- package/src/routes/_shared.ts +35 -0
- package/src/routes/bootstrap.ts +13 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/maintenance.ts +25 -0
- package/src/routes/rotate-token.ts +13 -0
- package/src/routes/session-claim.ts +33 -0
- package/src/routes/session.ts +13 -0
- package/src/version.ts +3 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
- Renamed to `@tideworthy/tidysites-platform`: the npm scope is the parent brand, the package name says which product it serves. First version actually published to npm. No code changes.
|
|
6
|
+
|
|
7
|
+
## 0.1.2
|
|
8
|
+
|
|
9
|
+
- Release workflow publishes on version tags with provenance. Never reached npm (no scope yet).
|
|
10
|
+
|
|
11
|
+
## 0.1.1
|
|
12
|
+
|
|
13
|
+
- Fix: the routes read the worker env from `cloudflare:workers` only. 0.1.0 touched `locals.runtime.env` first, which the Cloudflare adapter for Astro 6+ removed with a throwing getter, so every gated route failed on Astro 7. Not usable; use 0.1.1.
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
First release. Replaces the routes and plugin that lived inline in `template-home-services`.
|
|
18
|
+
|
|
19
|
+
- Astro integration `tidyPlatform()` injecting `/_tidy/bootstrap`, `/_tidy/session`, `/_tidy/session/claim`, `/_tidy/health`, `/_tidy/rotate-token`, `/_tidy/maintenance` as server routes. The routes reach the database through `getDb()`, so the old worker-level `/_tidy/*` rewrite and edit-mode cookie trick are gone.
|
|
20
|
+
- EmDash plugin descriptor `tidyPlatformPlugin()` and `createPlugin()` with `content:afterPublish`, `content:afterUnpublish`, `content:afterDelete` hooks (`errorPolicy: "continue"`, 8 s timeout) that post `{ reason, collection, id }` to the control plane over the `PLATFORM` binding.
|
|
21
|
+
- One-time sign-in links: mint with the site secret, claim from a browser, single use, 30 to 600 s lifetime, local redirects only.
|
|
22
|
+
- Token rotation by email or by token name; health with migration status.
|
|
23
|
+
- `@tideworthy/tidysites-platform/client`: a typed caller for all routes.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4 Sons Holdings Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @tideworthy/tidysites-platform
|
|
2
|
+
|
|
3
|
+
The Tidysites platform integration for [EmDash](https://emdash.dev) sites on Astro. It adds a small set of platform routes under `/_tidy/*` and an EmDash plugin whose publish hooks tell the platform to ship the site live.
|
|
4
|
+
|
|
5
|
+
It is the only code in a Tidysites template that knows about the platform, and it does nothing when the platform's secret is not bound to the worker. A site exported from the platform keeps this package installed and runs unchanged.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @tideworthy/tidysites-platform
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies: `astro` 7, `emdash` and `@emdash-cms/auth` 0.37.x, `kysely`. Published on npm as `@tideworthy/tidysites-platform`; GitHub tags `vX.Y.Z` match each version.
|
|
14
|
+
|
|
15
|
+
## Wire it up
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
// astro.config.mjs
|
|
19
|
+
import emdash from "emdash/astro";
|
|
20
|
+
import { tidyPlatform, tidyPlatformPlugin } from "@tideworthy/tidysites-platform";
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
integrations: [
|
|
24
|
+
tidyPlatform(),
|
|
25
|
+
emdash({
|
|
26
|
+
// database, storage, ...
|
|
27
|
+
plugins: [tidyPlatformPlugin()]
|
|
28
|
+
})
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`tidyPlatform()` is an Astro integration that injects the routes below as server routes. `tidyPlatformPlugin()` is the EmDash plugin descriptor that registers the publish hooks. Both are needed.
|
|
34
|
+
|
|
35
|
+
The worker entry stays EmDash's own:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/worker.ts
|
|
39
|
+
export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Bindings the routes read
|
|
43
|
+
|
|
44
|
+
| Binding | Type | Purpose |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `TIDY_PLATFORM_SECRET` | secret | Bearer token every `/_tidy/*` route requires. When absent, every route answers 404. |
|
|
47
|
+
| `TIDY_CONTROL_ORIGIN` | var | Control plane origin the publish hooks call. |
|
|
48
|
+
| `TIDY_SITE_ID` | var | This site's id on the platform. |
|
|
49
|
+
| `PLATFORM` | service binding | The control plane worker. Publish requests go over this binding, never over public fetch. |
|
|
50
|
+
|
|
51
|
+
Without the last three, the publish hooks are a no-op.
|
|
52
|
+
|
|
53
|
+
## Routes
|
|
54
|
+
|
|
55
|
+
All routes are under `/_tidy/` on the staging hostname. Every route except the session claim requires `Authorization: Bearer <TIDY_PLATFORM_SECRET>`. Wrong or missing bearer: 401. No secret bound: 404. JSON in, JSON out, `cache-control: no-store`.
|
|
56
|
+
|
|
57
|
+
### `POST /_tidy/bootstrap`
|
|
58
|
+
|
|
59
|
+
Headless first run for a freshly provisioned site. Applies the template seed, creates the platform's admin user, marks setup complete, and mints an API token with every scope.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "adminEmail": "platform@example.com", "adminName": "Platform", "title": "Acme Plumbing", "tagline": "Fast fixes", "siteUrl": "https://acme.example.com", "tokenName": "platform" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Returns `{ ok, userId, token, steps }`. The token is only ever returned here and by `rotate-token`; store it. Idempotent: a later call skips the seed and setup, keeps the user, and replaces the token of that name.
|
|
66
|
+
|
|
67
|
+
### `POST /_tidy/session`
|
|
68
|
+
|
|
69
|
+
Mints a one-time sign-in link for a person the platform has already authenticated.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{ "email": "jo@agency.com", "name": "Jo", "role": "admin", "redirect": "/_emdash/admin", "editMode": false, "ttlSeconds": 120 }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`role` is `admin` or `editor` and is applied to the EmDash user every time, so the platform's role mapping wins. `redirect` must be a local path. `ttlSeconds` is clamped to 30 to 600. Returns `{ ok, userId, claimUrl, expiresAt }`. Send the browser to `claimUrl`.
|
|
76
|
+
|
|
77
|
+
### `GET /_tidy/session/claim?t=…`
|
|
78
|
+
|
|
79
|
+
The browser lands here. Signs the user in (an Astro session, exactly as EmDash's own login does), sets or clears the visual-editing cookie, and redirects with 303 to the requested path. A link can be used once. Expired, reused, or unknown links get a 410 page.
|
|
80
|
+
|
|
81
|
+
### `GET /_tidy/health`
|
|
82
|
+
|
|
83
|
+
`{ ok, plugin, setupComplete, siteTitle, siteUrl, migrations: { applied, pending }, time }`. `ok` is false and the status is 503 when migrations are pending.
|
|
84
|
+
|
|
85
|
+
### `POST /_tidy/rotate-token`
|
|
86
|
+
|
|
87
|
+
Replaces the platform API token. `{ "email": "...", "tokenName": "platform" }`. Both optional: with no email, the token's current owner is used. Returns `{ ok, userId, token }`.
|
|
88
|
+
|
|
89
|
+
### `POST /_tidy/maintenance?cron=…`
|
|
90
|
+
|
|
91
|
+
Runs EmDash's scheduled work now (scheduled publishing, cleanup) and returns what it published. Workers in a dispatch namespace never receive cron triggers, so the platform's cron calls this on every site each minute.
|
|
92
|
+
|
|
93
|
+
## Publish hooks
|
|
94
|
+
|
|
95
|
+
The plugin registers `content:afterPublish`, `content:afterUnpublish`, and `content:afterDelete`. Each posts `{ reason, collection, id }` to `POST <TIDY_CONTROL_ORIGIN>/sites/<TIDY_SITE_ID>/publish` over the `PLATFORM` binding with the site secret as bearer. Hooks run with `errorPolicy: "continue"` and an 8 second timeout, so a slow or failing control plane never fails the editor's publish.
|
|
96
|
+
|
|
97
|
+
## Calling the routes
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { createTidySiteClient } from "@tideworthy/tidysites-platform/client";
|
|
101
|
+
|
|
102
|
+
const site = createTidySiteClient({ origin: "https://staging-acme.example.dev", secret, fetch: (r) => namespace.get("site-acme").fetch(r) });
|
|
103
|
+
await site.health();
|
|
104
|
+
const { claimUrl } = await site.mintSession({ email, role: "editor", editMode: true, redirect: "/" });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Security model
|
|
108
|
+
|
|
109
|
+
- The secret is compared in constant time and never echoed.
|
|
110
|
+
- Every route disappears (404) when the secret is not bound, so an exported site exposes nothing.
|
|
111
|
+
- Sign-in links are random 256-bit tokens, single use, and expire within ten minutes. Redirect targets must be local paths.
|
|
112
|
+
- The plugin never reads or writes content; it only notifies the platform.
|
|
113
|
+
- Nothing in this package holds a credential. Secrets arrive as worker bindings.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tideworthy/tidysites-platform",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "The Tidysites platform integration for EmDash sites: bootstrap, sessions, maintenance, health, and publish hooks under /_tidy/*.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/4sons/tidysites-platform.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/4sons/tidysites-platform#readme",
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"CHANGELOG.md"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./plugin": "./src/plugin.ts",
|
|
21
|
+
"./client": "./src/client.ts",
|
|
22
|
+
"./routes/bootstrap": "./src/routes/bootstrap.ts",
|
|
23
|
+
"./routes/session": "./src/routes/session.ts",
|
|
24
|
+
"./routes/session-claim": "./src/routes/session-claim.ts",
|
|
25
|
+
"./routes/health": "./src/routes/health.ts",
|
|
26
|
+
"./routes/rotate-token": "./src/routes/rotate-token.ts",
|
|
27
|
+
"./routes/maintenance": "./src/routes/maintenance.ts"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"check": "npm run typecheck && npm run test"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@emdash-cms/auth": ">=0.37.0 <0.38.0",
|
|
36
|
+
"astro": "^7.0.0",
|
|
37
|
+
"emdash": ">=0.37.0 <0.38.0",
|
|
38
|
+
"kysely": ">=0.29.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@astrojs/react": "^6.0.0",
|
|
42
|
+
"@cloudflare/workers-types": "^5.20260915.1",
|
|
43
|
+
"@emdash-cms/auth": "0.37.0",
|
|
44
|
+
"@emdash-cms/auth-atproto": ">=0.2.8",
|
|
45
|
+
"astro": "^7.3.2",
|
|
46
|
+
"emdash": "0.37.0",
|
|
47
|
+
"kysely": "^0.29.0",
|
|
48
|
+
"react": "19.2.4",
|
|
49
|
+
"react-dom": "19.2.4",
|
|
50
|
+
"typescript": "^5.9.0",
|
|
51
|
+
"vitest": "^3.2.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public",
|
|
55
|
+
"provenance": true
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed caller for the /_tidy/* routes, for the control plane and the
|
|
3
|
+
* platform app. Pass `fetch` to route calls through a dispatch-namespace
|
|
4
|
+
* binding instead of the public internet.
|
|
5
|
+
*/
|
|
6
|
+
import type { BootstrapInput, BootstrapResult, HealthResult, MintSessionInput, MintSessionResult, RotateTokenInput } from "./lib/handlers";
|
|
7
|
+
|
|
8
|
+
export type { BootstrapInput, BootstrapResult, HealthResult, MintSessionInput, MintSessionResult, RotateTokenInput };
|
|
9
|
+
|
|
10
|
+
export interface MaintenanceResult {
|
|
11
|
+
ok: true;
|
|
12
|
+
cron: string | null;
|
|
13
|
+
published: number;
|
|
14
|
+
refs: Array<{ collection: string; id: string }>;
|
|
15
|
+
ms: number;
|
|
16
|
+
at: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TidySiteError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
public status: number,
|
|
22
|
+
public body: unknown
|
|
23
|
+
) {
|
|
24
|
+
super(`tidy site route failed: ${status}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TidySiteClientOptions {
|
|
29
|
+
/** The site's staging origin, e.g. https://staging-acme.tidysites.dev */
|
|
30
|
+
origin: string;
|
|
31
|
+
/** The per-site TIDY_PLATFORM_SECRET. */
|
|
32
|
+
secret: string;
|
|
33
|
+
/** A fetch to use, e.g. a dispatch namespace binding's `.fetch`. Defaults to global fetch. */
|
|
34
|
+
fetch?: (input: Request) => Promise<Response>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createTidySiteClient(options: TidySiteClientOptions) {
|
|
38
|
+
const origin = options.origin.replace(/\/+$/, "");
|
|
39
|
+
const doFetch = options.fetch ?? ((r: Request) => fetch(r));
|
|
40
|
+
|
|
41
|
+
async function call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
|
42
|
+
const r = await doFetch(
|
|
43
|
+
new Request(`${origin}${path}`, {
|
|
44
|
+
method,
|
|
45
|
+
headers: { authorization: `Bearer ${options.secret}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
|
|
46
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
const data: unknown = await r.json().catch(() => null);
|
|
50
|
+
if (!r.ok) throw new TidySiteError(r.status, data);
|
|
51
|
+
return data as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
bootstrap: (input: BootstrapInput) => call<BootstrapResult>("POST", "/_tidy/bootstrap", input),
|
|
56
|
+
mintSession: (input: MintSessionInput) => call<MintSessionResult>("POST", "/_tidy/session", input),
|
|
57
|
+
health: () => call<HealthResult>("GET", "/_tidy/health"),
|
|
58
|
+
rotateToken: (input: RotateTokenInput = {}) => call<{ ok: true; userId: string; token: string }>("POST", "/_tidy/rotate-token", input),
|
|
59
|
+
maintenance: (cron?: string) => call<MaintenanceResult>("POST", `/_tidy/maintenance${cron ? `?cron=${encodeURIComponent(cron)}` : ""}`)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TidySiteClient = ReturnType<typeof createTidySiteClient>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tideworthy/tidysites-platform
|
|
3
|
+
*
|
|
4
|
+
* Two exports a template wires into astro.config.mjs:
|
|
5
|
+
*
|
|
6
|
+
* import emdash from "emdash/astro";
|
|
7
|
+
* import { tidyPlatform, tidyPlatformPlugin } from "@tideworthy/tidysites-platform";
|
|
8
|
+
*
|
|
9
|
+
* integrations: [
|
|
10
|
+
* tidyPlatform(),
|
|
11
|
+
* emdash({ ..., plugins: [tidyPlatformPlugin()] }),
|
|
12
|
+
* ]
|
|
13
|
+
*
|
|
14
|
+
* `tidyPlatform()` injects the /_tidy/* routes. `tidyPlatformPlugin()` is the
|
|
15
|
+
* EmDash plugin descriptor for the publish hooks. See README.md.
|
|
16
|
+
*/
|
|
17
|
+
import type { AstroIntegration } from "astro";
|
|
18
|
+
import { PLUGIN_ID, VERSION } from "./version";
|
|
19
|
+
|
|
20
|
+
export { VERSION, PLUGIN_ID };
|
|
21
|
+
|
|
22
|
+
const PACKAGE = "@tideworthy/tidysites-platform";
|
|
23
|
+
|
|
24
|
+
export const PLATFORM_ROUTES = [
|
|
25
|
+
{ pattern: "/_tidy/bootstrap", entrypoint: `${PACKAGE}/routes/bootstrap` },
|
|
26
|
+
{ pattern: "/_tidy/session", entrypoint: `${PACKAGE}/routes/session` },
|
|
27
|
+
{ pattern: "/_tidy/session/claim", entrypoint: `${PACKAGE}/routes/session-claim` },
|
|
28
|
+
{ pattern: "/_tidy/health", entrypoint: `${PACKAGE}/routes/health` },
|
|
29
|
+
{ pattern: "/_tidy/rotate-token", entrypoint: `${PACKAGE}/routes/rotate-token` },
|
|
30
|
+
{ pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` }
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/** Astro integration: registers every platform route as a server route. */
|
|
34
|
+
export function tidyPlatform(): AstroIntegration {
|
|
35
|
+
return {
|
|
36
|
+
name: PACKAGE,
|
|
37
|
+
hooks: {
|
|
38
|
+
"astro:config:setup": ({ injectRoute }) => {
|
|
39
|
+
for (const route of PLATFORM_ROUTES) injectRoute({ pattern: route.pattern, entrypoint: route.entrypoint, prerender: false });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** EmDash plugin descriptor for `emdash({ plugins: [tidyPlatformPlugin()] })`. */
|
|
46
|
+
export function tidyPlatformPlugin() {
|
|
47
|
+
return {
|
|
48
|
+
id: PLUGIN_ID,
|
|
49
|
+
version: VERSION,
|
|
50
|
+
format: "native" as const,
|
|
51
|
+
entrypoint: `${PACKAGE}/plugin`
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-secret gate for every /_tidy/* route.
|
|
3
|
+
*
|
|
4
|
+
* Two outcomes matter for security:
|
|
5
|
+
* - No secret bound on the worker (an exported site, or a misconfigured one):
|
|
6
|
+
* every platform route answers 404, so the site shows no trace of the platform.
|
|
7
|
+
* - Secret bound but the request does not carry it: 401.
|
|
8
|
+
* The comparison is constant-time so response timing does not leak the secret.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SECRET_BINDING = "TIDY_PLATFORM_SECRET";
|
|
12
|
+
|
|
13
|
+
export function bearerToken(request: Request): string | null {
|
|
14
|
+
const header = request.headers.get("authorization") ?? "";
|
|
15
|
+
if (!/^Bearer\s+/i.test(header)) return null;
|
|
16
|
+
const token = header.replace(/^Bearer\s+/i, "").trim();
|
|
17
|
+
return token.length > 0 ? token : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Constant-time string equality. Never early-returns on the first mismatching byte. */
|
|
21
|
+
export function secretMatches(provided: string | null | undefined, expected: string | null | undefined): boolean {
|
|
22
|
+
if (!provided || !expected) return false;
|
|
23
|
+
const enc = new TextEncoder();
|
|
24
|
+
const a = enc.encode(provided);
|
|
25
|
+
const b = enc.encode(expected);
|
|
26
|
+
let diff = a.length ^ b.length;
|
|
27
|
+
for (let i = 0; i < b.length; i++) diff |= (a[i % a.length] ?? 0) ^ (b[i] ?? 0);
|
|
28
|
+
return diff === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Gate = { ok: true } | { ok: false; response: Response };
|
|
32
|
+
|
|
33
|
+
export function readSecret(env: Record<string, unknown> | undefined): string | null {
|
|
34
|
+
const v = env?.[SECRET_BINDING];
|
|
35
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gate a platform route. `requireBearer: false` is for the one route a browser
|
|
40
|
+
* reaches (the session claim); it still disappears when no secret is bound.
|
|
41
|
+
*/
|
|
42
|
+
export function gate(request: Request, env: Record<string, unknown> | undefined, requireBearer = true): Gate {
|
|
43
|
+
const secret = readSecret(env);
|
|
44
|
+
if (!secret) return { ok: false, response: new Response("Not found", { status: 404 }) };
|
|
45
|
+
if (!requireBearer) return { ok: true };
|
|
46
|
+
if (!secretMatches(bearerToken(request), secret)) {
|
|
47
|
+
return { ok: false, response: new Response("Unauthorized", { status: 401, headers: { "www-authenticate": "Bearer" } }) };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker env (bindings, vars, secrets), resolved from the `cloudflare:workers`
|
|
3
|
+
* module at call time.
|
|
4
|
+
*
|
|
5
|
+
* Why not `locals.runtime.env`: the Cloudflare adapter for Astro 6+ removed it
|
|
6
|
+
* and its getter throws. Why a dynamic import: EmDash probes plugin modules at
|
|
7
|
+
* build time in Node, where `cloudflare:workers` does not exist, and the routes
|
|
8
|
+
* must stay importable in tests.
|
|
9
|
+
*/
|
|
10
|
+
export type WorkerEnv = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
type Importer = () => Promise<unknown>;
|
|
13
|
+
|
|
14
|
+
const defaultImporter: Importer = () => import("cloudflare:workers");
|
|
15
|
+
|
|
16
|
+
export async function workerEnv(importer: Importer = defaultImporter): Promise<WorkerEnv | undefined> {
|
|
17
|
+
try {
|
|
18
|
+
const mod = (await importer()) as { env?: WorkerEnv } | undefined;
|
|
19
|
+
return mod?.env && typeof mod.env === "object" ? mod.env : undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Alias kept for the hook plugin, which runs outside any request. */
|
|
26
|
+
export const hookEnv = workerEnv;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route logic, free of Astro and EmDash imports. Each handler takes validated
|
|
3
|
+
* JSON in and returns plain data or throws HttpError. The route modules in
|
|
4
|
+
* src/routes wire real dependencies; the tests wire an in-memory store.
|
|
5
|
+
*/
|
|
6
|
+
import { HttpError, isEmail, isHttpUrl, isSafeLocalPath, randomToken } from "./http";
|
|
7
|
+
import type { PlatformStore, Role } from "./store";
|
|
8
|
+
import { ROLE_LEVEL } from "./store";
|
|
9
|
+
import { VERSION } from "../version";
|
|
10
|
+
|
|
11
|
+
export interface SeedOps {
|
|
12
|
+
load(): Promise<Record<string, unknown> & { settings?: Record<string, unknown> }>;
|
|
13
|
+
validate(seed: unknown): { valid: boolean; errors?: unknown };
|
|
14
|
+
apply(seed: Record<string, unknown>): Promise<{ collections?: unknown; content?: unknown }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TokenOps {
|
|
18
|
+
/** Returns the raw token to hand out and the hash to store. */
|
|
19
|
+
generate(): { raw: string; hash: string; prefix: string };
|
|
20
|
+
scopes: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Deps {
|
|
24
|
+
store: PlatformStore;
|
|
25
|
+
seed: SeedOps;
|
|
26
|
+
tokens: TokenOps;
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TOKEN_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
31
|
+
const DEFAULT_TOKEN_NAME = "platform";
|
|
32
|
+
const ROLES: readonly Role[] = ["admin", "editor"];
|
|
33
|
+
export const HANDOFF_TTL = { default: 120, min: 30, max: 600 } as const;
|
|
34
|
+
export const DEFAULT_REDIRECT = "/_emdash/admin";
|
|
35
|
+
|
|
36
|
+
function tokenName(value: unknown): string {
|
|
37
|
+
if (value === undefined || value === null) return DEFAULT_TOKEN_NAME;
|
|
38
|
+
if (typeof value !== "string" || !TOKEN_NAME.test(value)) throw new HttpError(400, "tokenName must be 1 to 64 letters, digits, - or _");
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Bootstrap
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface BootstrapInput {
|
|
47
|
+
adminEmail?: unknown;
|
|
48
|
+
adminName?: unknown;
|
|
49
|
+
title?: unknown;
|
|
50
|
+
tagline?: unknown;
|
|
51
|
+
siteUrl?: unknown;
|
|
52
|
+
tokenName?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BootstrapResult {
|
|
56
|
+
ok: true;
|
|
57
|
+
userId: string;
|
|
58
|
+
token: string;
|
|
59
|
+
steps: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Headless first run for a freshly provisioned site: apply the template seed,
|
|
64
|
+
* create the platform's admin user, mark setup complete, mint an API token.
|
|
65
|
+
* Idempotent: a later call skips the seed and setup, and re-mints the token.
|
|
66
|
+
*/
|
|
67
|
+
export async function bootstrap(input: BootstrapInput, deps: Deps, requestOrigin: string): Promise<BootstrapResult> {
|
|
68
|
+
if (!isEmail(input.adminEmail)) throw new HttpError(400, "adminEmail must be an email address");
|
|
69
|
+
if (input.title !== undefined && (typeof input.title !== "string" || input.title.length > 200)) throw new HttpError(400, "title must be a string of at most 200 characters");
|
|
70
|
+
if (input.tagline !== undefined && (typeof input.tagline !== "string" || input.tagline.length > 500)) throw new HttpError(400, "tagline must be a string of at most 500 characters");
|
|
71
|
+
if (input.siteUrl !== undefined && !isHttpUrl(input.siteUrl)) throw new HttpError(400, "siteUrl must be an http(s) URL");
|
|
72
|
+
if (input.adminName !== undefined && (typeof input.adminName !== "string" || input.adminName.length > 200)) throw new HttpError(400, "adminName must be a string of at most 200 characters");
|
|
73
|
+
const name = tokenName(input.tokenName);
|
|
74
|
+
const { store, seed, tokens } = deps;
|
|
75
|
+
const steps: Record<string, unknown> = {};
|
|
76
|
+
|
|
77
|
+
const alreadyComplete = (await store.getOption("emdash:setup_complete")) === true;
|
|
78
|
+
steps.alreadyComplete = alreadyComplete;
|
|
79
|
+
|
|
80
|
+
if (!alreadyComplete) {
|
|
81
|
+
const data = await seed.load();
|
|
82
|
+
data.settings = {
|
|
83
|
+
...(data.settings ?? {}),
|
|
84
|
+
...(typeof input.title === "string" ? { title: input.title } : {}),
|
|
85
|
+
...(typeof input.tagline === "string" ? { tagline: input.tagline } : {})
|
|
86
|
+
};
|
|
87
|
+
const v = seed.validate(data);
|
|
88
|
+
if (!v.valid) throw new HttpError(500, "invalid seed", v.errors);
|
|
89
|
+
const result = await seed.apply(data);
|
|
90
|
+
steps.seed = { collections: result.collections, content: result.content };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let user = await store.findUserByEmail(input.adminEmail);
|
|
94
|
+
if (!user) {
|
|
95
|
+
user = await store.createUser({ email: input.adminEmail, name: typeof input.adminName === "string" ? input.adminName : "Platform", role: "admin" });
|
|
96
|
+
steps.userCreated = true;
|
|
97
|
+
} else if (user.role < ROLE_LEVEL.admin) {
|
|
98
|
+
await store.setUserRole(user.id, "admin");
|
|
99
|
+
steps.userPromoted = true;
|
|
100
|
+
}
|
|
101
|
+
await store.markEmailVerified(user.id);
|
|
102
|
+
|
|
103
|
+
if (!alreadyComplete) {
|
|
104
|
+
if (typeof input.title === "string") await store.setOption("emdash:site_title", input.title);
|
|
105
|
+
if (typeof input.tagline === "string") await store.setOption("emdash:site_tagline", input.tagline);
|
|
106
|
+
await store.setOptionIfAbsent("emdash:site_url", typeof input.siteUrl === "string" ? input.siteUrl : requestOrigin);
|
|
107
|
+
await store.setOption("emdash:setup_complete", true);
|
|
108
|
+
await store.deleteOption("emdash:setup_state");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const t = tokens.generate();
|
|
112
|
+
await store.replaceToken({ userId: user.id, name, hash: t.hash, prefix: t.prefix, scopes: [...tokens.scopes] });
|
|
113
|
+
return { ok: true, userId: user.id, token: t.raw, steps };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Sessions: mint a one-time handoff, then a browser claims it
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface MintSessionInput {
|
|
121
|
+
email?: unknown;
|
|
122
|
+
name?: unknown;
|
|
123
|
+
role?: unknown;
|
|
124
|
+
redirect?: unknown;
|
|
125
|
+
editMode?: unknown;
|
|
126
|
+
ttlSeconds?: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface MintSessionResult {
|
|
130
|
+
ok: true;
|
|
131
|
+
userId: string;
|
|
132
|
+
claimUrl: string;
|
|
133
|
+
expiresAt: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function mintSession(input: MintSessionInput, deps: Deps, requestOrigin: string): Promise<MintSessionResult> {
|
|
137
|
+
if (!isEmail(input.email)) throw new HttpError(400, "email must be an email address");
|
|
138
|
+
if (typeof input.role !== "string" || !ROLES.includes(input.role as Role)) throw new HttpError(400, `role must be one of ${ROLES.join(", ")}`);
|
|
139
|
+
const role = input.role as Role;
|
|
140
|
+
if (input.name !== undefined && (typeof input.name !== "string" || input.name.length > 200)) throw new HttpError(400, "name must be a string of at most 200 characters");
|
|
141
|
+
const redirect = input.redirect === undefined ? DEFAULT_REDIRECT : input.redirect;
|
|
142
|
+
if (!isSafeLocalPath(redirect)) throw new HttpError(400, "redirect must be a local path starting with /");
|
|
143
|
+
if (input.editMode !== undefined && typeof input.editMode !== "boolean") throw new HttpError(400, "editMode must be a boolean");
|
|
144
|
+
let ttl: number = HANDOFF_TTL.default;
|
|
145
|
+
if (input.ttlSeconds !== undefined) {
|
|
146
|
+
if (typeof input.ttlSeconds !== "number" || !Number.isFinite(input.ttlSeconds)) throw new HttpError(400, "ttlSeconds must be a number");
|
|
147
|
+
ttl = Math.min(HANDOFF_TTL.max, Math.max(HANDOFF_TTL.min, Math.floor(input.ttlSeconds)));
|
|
148
|
+
}
|
|
149
|
+
const now = (deps.now ?? Date.now)();
|
|
150
|
+
const { store } = deps;
|
|
151
|
+
|
|
152
|
+
let user = await store.findUserByEmail(input.email);
|
|
153
|
+
if (!user) {
|
|
154
|
+
user = await store.createUser({ email: input.email, name: typeof input.name === "string" ? input.name : input.email, role });
|
|
155
|
+
await store.markEmailVerified(user.id);
|
|
156
|
+
} else {
|
|
157
|
+
if (user.disabled) throw new HttpError(403, "this user is disabled on the site");
|
|
158
|
+
if (user.role !== ROLE_LEVEL[role]) await store.setUserRole(user.id, role);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await store.sweepHandoffs(now);
|
|
162
|
+
const token = randomToken(32);
|
|
163
|
+
const expiresAt = now + ttl * 1000;
|
|
164
|
+
await store.putHandoff(token, { userId: user.id, redirect, editMode: input.editMode === true, expiresAt });
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
userId: user.id,
|
|
168
|
+
claimUrl: `${requestOrigin}/_tidy/session/claim?t=${encodeURIComponent(token)}`,
|
|
169
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type ClaimResult = { kind: "redirect"; userId: string; redirect: string; editMode: boolean } | { kind: "expired" } | { kind: "invalid" };
|
|
174
|
+
|
|
175
|
+
export async function claimSession(token: unknown, deps: Deps): Promise<ClaimResult> {
|
|
176
|
+
if (typeof token !== "string" || token.length < 16 || token.length > 128 || !/^[A-Za-z0-9_-]+$/.test(token)) return { kind: "invalid" };
|
|
177
|
+
const handoff = await deps.store.takeHandoff(token);
|
|
178
|
+
if (!handoff) return { kind: "invalid" };
|
|
179
|
+
const now = (deps.now ?? Date.now)();
|
|
180
|
+
if (handoff.expiresAt <= now) return { kind: "expired" };
|
|
181
|
+
const user = await deps.store.findUserById(handoff.userId);
|
|
182
|
+
if (!user || user.disabled) return { kind: "invalid" };
|
|
183
|
+
if (!isSafeLocalPath(handoff.redirect)) return { kind: "invalid" };
|
|
184
|
+
return { kind: "redirect", userId: user.id, redirect: handoff.redirect, editMode: handoff.editMode };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Token rotation
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export interface RotateTokenInput {
|
|
192
|
+
email?: unknown;
|
|
193
|
+
tokenName?: unknown;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function rotateToken(input: RotateTokenInput, deps: Deps): Promise<{ ok: true; userId: string; token: string }> {
|
|
197
|
+
const name = tokenName(input.tokenName);
|
|
198
|
+
const { store, tokens } = deps;
|
|
199
|
+
let userId: string | null = null;
|
|
200
|
+
if (input.email !== undefined) {
|
|
201
|
+
if (!isEmail(input.email)) throw new HttpError(400, "email must be an email address");
|
|
202
|
+
const user = await store.findUserByEmail(input.email);
|
|
203
|
+
if (!user) throw new HttpError(404, "no user with that email");
|
|
204
|
+
userId = user.id;
|
|
205
|
+
} else {
|
|
206
|
+
userId = await store.findTokenOwner(name);
|
|
207
|
+
if (!userId) throw new HttpError(404, `no token named ${name} exists; pass email to create one`);
|
|
208
|
+
}
|
|
209
|
+
const t = tokens.generate();
|
|
210
|
+
await store.replaceToken({ userId, name, hash: t.hash, prefix: t.prefix, scopes: [...tokens.scopes] });
|
|
211
|
+
return { ok: true, userId, token: t.raw };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Health
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export interface HealthResult {
|
|
219
|
+
ok: boolean;
|
|
220
|
+
plugin: string;
|
|
221
|
+
setupComplete: boolean;
|
|
222
|
+
siteTitle: string | null;
|
|
223
|
+
siteUrl: string | null;
|
|
224
|
+
migrations: { applied: number; pending: number };
|
|
225
|
+
time: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function health(deps: Deps): Promise<HealthResult> {
|
|
229
|
+
const { store } = deps;
|
|
230
|
+
const [setupComplete, siteTitle, siteUrl, migrations] = await Promise.all([
|
|
231
|
+
store.getOption("emdash:setup_complete"),
|
|
232
|
+
store.getOption<string>("emdash:site_title"),
|
|
233
|
+
store.getOption<string>("emdash:site_url"),
|
|
234
|
+
store.migrationStatus()
|
|
235
|
+
]);
|
|
236
|
+
return {
|
|
237
|
+
ok: migrations.pending === 0,
|
|
238
|
+
plugin: VERSION,
|
|
239
|
+
setupComplete: setupComplete === true,
|
|
240
|
+
siteTitle: siteTitle ?? null,
|
|
241
|
+
siteUrl: siteUrl ?? null,
|
|
242
|
+
migrations,
|
|
243
|
+
time: new Date((deps.now ?? Date.now)()).toISOString()
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/lib/http.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
public status: number,
|
|
4
|
+
message: string,
|
|
5
|
+
public details?: unknown
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
12
|
+
return new Response(JSON.stringify(body), {
|
|
13
|
+
status,
|
|
14
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function errorResponse(err: unknown): Response {
|
|
19
|
+
if (err instanceof HttpError) {
|
|
20
|
+
return json({ ok: false, error: err.message, ...(err.details !== undefined ? { details: err.details } : {}) }, err.status);
|
|
21
|
+
}
|
|
22
|
+
console.error("[tidy-platform]", err);
|
|
23
|
+
return json({ ok: false, error: "internal error" }, 500);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readJson<T>(request: Request): Promise<T> {
|
|
27
|
+
const text = await request.text();
|
|
28
|
+
if (!text.trim()) return {} as T;
|
|
29
|
+
let parsed: unknown;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new HttpError(400, "body must be a JSON object");
|
|
34
|
+
}
|
|
35
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new HttpError(400, "body must be a JSON object");
|
|
36
|
+
return parsed as T;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A redirect target we will send a browser to after sign-in. Only a local
|
|
41
|
+
* absolute path is allowed: no scheme, no protocol-relative `//host`, no
|
|
42
|
+
* backslash tricks, no control characters.
|
|
43
|
+
*/
|
|
44
|
+
export function isSafeLocalPath(value: unknown): value is string {
|
|
45
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) return false;
|
|
46
|
+
if (!value.startsWith("/")) return false;
|
|
47
|
+
if (value.startsWith("//") || value.startsWith("/\\")) return false;
|
|
48
|
+
for (let i = 0; i < value.length; i++) {
|
|
49
|
+
const c = value.charCodeAt(i);
|
|
50
|
+
if (c < 0x20 || c === 0x7f) return false;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(value, "https://placeholder.invalid");
|
|
54
|
+
return u.origin === "https://placeholder.invalid" && u.pathname.startsWith("/");
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
61
|
+
export function isEmail(value: unknown): value is string {
|
|
62
|
+
return typeof value === "string" && value.length <= 254 && EMAIL.test(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isHttpUrl(value: unknown): value is string {
|
|
66
|
+
if (typeof value !== "string") return false;
|
|
67
|
+
try {
|
|
68
|
+
const u = new URL(value);
|
|
69
|
+
return u.protocol === "https:" || u.protocol === "http:";
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function randomToken(bytes = 32): string {
|
|
76
|
+
const buf = new Uint8Array(bytes);
|
|
77
|
+
crypto.getRandomValues(buf);
|
|
78
|
+
let s = "";
|
|
79
|
+
for (const b of buf) s += String.fromCharCode(b);
|
|
80
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
81
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask the control plane to snapshot staging into the live site. Called by the
|
|
3
|
+
* EmDash hook plugin after publish, unpublish, and delete.
|
|
4
|
+
*
|
|
5
|
+
* The call goes over the PLATFORM service binding: a Worker cannot reach
|
|
6
|
+
* another Worker on the same zone with plain fetch. Without the bindings this
|
|
7
|
+
* is a no-op, which is what an exported site needs.
|
|
8
|
+
*/
|
|
9
|
+
export interface PublishEnv {
|
|
10
|
+
TIDY_CONTROL_ORIGIN?: unknown;
|
|
11
|
+
TIDY_SITE_ID?: unknown;
|
|
12
|
+
TIDY_PLATFORM_SECRET?: unknown;
|
|
13
|
+
PLATFORM?: { fetch(input: string, init?: RequestInit): Promise<Response> };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PublishEvent {
|
|
17
|
+
reason: "publish" | "unpublish" | "delete";
|
|
18
|
+
collection?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PublishOutcome = { sent: false; why: "no-bindings" } | { sent: true; status: number } | { sent: false; why: "error"; error: string };
|
|
23
|
+
|
|
24
|
+
export async function requestPublish(env: PublishEnv | undefined, event: PublishEvent): Promise<PublishOutcome> {
|
|
25
|
+
const origin = env?.TIDY_CONTROL_ORIGIN;
|
|
26
|
+
const siteId = env?.TIDY_SITE_ID;
|
|
27
|
+
const secret = env?.TIDY_PLATFORM_SECRET;
|
|
28
|
+
const platform = env?.PLATFORM;
|
|
29
|
+
if (!platform || typeof origin !== "string" || typeof siteId !== "string" || typeof secret !== "string") return { sent: false, why: "no-bindings" };
|
|
30
|
+
try {
|
|
31
|
+
const r = await platform.fetch(`${origin.replace(/\/+$/, "")}/sites/${encodeURIComponent(siteId)}/publish`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
|
|
34
|
+
body: JSON.stringify(event)
|
|
35
|
+
});
|
|
36
|
+
if (!r.ok) console.error("[tidy-platform] publish request failed:", r.status);
|
|
37
|
+
return { sent: true, status: r.status };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error("[tidy-platform] publish request error:", err);
|
|
40
|
+
return { sent: false, why: "error", error: String(err) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glue between an Astro request on Cloudflare and the pure handlers: resolve
|
|
3
|
+
* the worker env, build the store over EmDash's database, and provide the
|
|
4
|
+
* seed and token operations from EmDash's public exports.
|
|
5
|
+
*
|
|
6
|
+
* This is the only file (besides src/plugin.ts) that imports EmDash. Keep it
|
|
7
|
+
* that way: it is the list of things to re-verify on an EmDash upgrade.
|
|
8
|
+
*/
|
|
9
|
+
import { OptionsRepository, UserRepository, applySeed, getMigrationStatus, ulid, validateSeed } from "emdash";
|
|
10
|
+
import { getDb } from "emdash/runtime";
|
|
11
|
+
import { loadSeed } from "emdash/seed";
|
|
12
|
+
import { VALID_SCOPES, generatePrefixedToken } from "@emdash-cms/auth";
|
|
13
|
+
import type { Deps } from "./handlers";
|
|
14
|
+
import { createEmdashStore } from "./store";
|
|
15
|
+
|
|
16
|
+
export { workerEnv } from "./env";
|
|
17
|
+
|
|
18
|
+
export async function buildDeps(): Promise<Deps> {
|
|
19
|
+
const db = await getDb();
|
|
20
|
+
const store = createEmdashStore(db, { OptionsRepository, UserRepository, ulid, getMigrationStatus });
|
|
21
|
+
return {
|
|
22
|
+
store,
|
|
23
|
+
seed: {
|
|
24
|
+
load: async () => (await loadSeed()) as unknown as Record<string, unknown> & { settings?: Record<string, unknown> },
|
|
25
|
+
validate: (seed) => validateSeed(seed),
|
|
26
|
+
apply: (seed) => applySeed(db, seed as unknown as Parameters<typeof applySeed>[1], { includeContent: true, onConflict: "skip" })
|
|
27
|
+
},
|
|
28
|
+
tokens: {
|
|
29
|
+
generate: () => generatePrefixedToken("ec_pat_"),
|
|
30
|
+
scopes: [...VALID_SCOPES]
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/lib/store.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrow view of an EmDash database the platform routes need. Handlers
|
|
3
|
+
* depend on this interface only, so they are unit-tested against an in-memory
|
|
4
|
+
* store and run in production against `createEmdashStore(db)`.
|
|
5
|
+
*
|
|
6
|
+
* Every EmDash internal we touch is listed in MAINTAINING.md; when EmDash
|
|
7
|
+
* changes, that list is what to re-verify.
|
|
8
|
+
*/
|
|
9
|
+
import type { Kysely } from "kysely";
|
|
10
|
+
|
|
11
|
+
export type Role = "admin" | "editor";
|
|
12
|
+
|
|
13
|
+
export interface StoreUser {
|
|
14
|
+
id: string;
|
|
15
|
+
email: string;
|
|
16
|
+
name: string | null;
|
|
17
|
+
role: number;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Handoff {
|
|
22
|
+
userId: string;
|
|
23
|
+
redirect: string;
|
|
24
|
+
editMode: boolean;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PlatformStore {
|
|
29
|
+
getOption<T = unknown>(name: string): Promise<T | null>;
|
|
30
|
+
setOption(name: string, value: unknown): Promise<void>;
|
|
31
|
+
setOptionIfAbsent(name: string, value: unknown): Promise<void>;
|
|
32
|
+
deleteOption(name: string): Promise<void>;
|
|
33
|
+
|
|
34
|
+
findUserByEmail(email: string): Promise<StoreUser | null>;
|
|
35
|
+
findUserById(id: string): Promise<StoreUser | null>;
|
|
36
|
+
createUser(input: { email: string; name: string; role: Role }): Promise<StoreUser>;
|
|
37
|
+
setUserRole(id: string, role: Role): Promise<void>;
|
|
38
|
+
markEmailVerified(id: string): Promise<void>;
|
|
39
|
+
|
|
40
|
+
/** Replace the token named `name` for `userId` (at most one per name per user). */
|
|
41
|
+
replaceToken(input: { userId: string; name: string; hash: string; prefix: string; scopes: string[] }): Promise<void>;
|
|
42
|
+
findTokenOwner(name: string): Promise<string | null>;
|
|
43
|
+
|
|
44
|
+
putHandoff(token: string, handoff: Handoff): Promise<void>;
|
|
45
|
+
/** Read and delete in one go; a second call for the same token returns null. */
|
|
46
|
+
takeHandoff(token: string): Promise<Handoff | null>;
|
|
47
|
+
sweepHandoffs(now: number): Promise<number>;
|
|
48
|
+
|
|
49
|
+
migrationStatus(): Promise<{ applied: number; pending: number }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HANDOFF_PREFIX = "tidy:handoff:";
|
|
53
|
+
export const ROLE_LEVEL: Record<Role, number> = { admin: 50, editor: 40 };
|
|
54
|
+
|
|
55
|
+
type RepoUser = { id: string; email: string; name: string | null; role: number };
|
|
56
|
+
|
|
57
|
+
/** The slice of the `emdash` module the store uses. Injected so tests never import EmDash. */
|
|
58
|
+
export interface EmdashModule {
|
|
59
|
+
OptionsRepository: new (db: Kysely<any>) => {
|
|
60
|
+
get<T>(name: string): Promise<T | null>;
|
|
61
|
+
set<T>(name: string, value: T): Promise<void>;
|
|
62
|
+
setIfAbsent<T>(name: string, value: T): Promise<unknown>;
|
|
63
|
+
delete(name: string): Promise<unknown>;
|
|
64
|
+
};
|
|
65
|
+
UserRepository: new (db: Kysely<any>) => {
|
|
66
|
+
findByEmail(email: string): Promise<RepoUser | null>;
|
|
67
|
+
findById(id: string): Promise<RepoUser | null>;
|
|
68
|
+
create(input: { email: string; name?: string; role?: Role }): Promise<RepoUser>;
|
|
69
|
+
};
|
|
70
|
+
ulid: () => string;
|
|
71
|
+
getMigrationStatus: (db: Kysely<any>) => Promise<{ applied: string[]; pending: string[] }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Production store over EmDash's repositories plus a little direct Kysely for what they do not expose. */
|
|
75
|
+
export function createEmdashStore(db: Kysely<any>, emdash: EmdashModule): PlatformStore {
|
|
76
|
+
const options = new emdash.OptionsRepository(db);
|
|
77
|
+
const users = new emdash.UserRepository(db);
|
|
78
|
+
|
|
79
|
+
async function disabledFlag(id: string): Promise<boolean> {
|
|
80
|
+
const row = await db.selectFrom("users").select("disabled").where("id", "=", id).executeTakeFirst();
|
|
81
|
+
return Boolean(row?.disabled);
|
|
82
|
+
}
|
|
83
|
+
async function toStoreUser(u: RepoUser | null): Promise<StoreUser | null> {
|
|
84
|
+
if (!u) return null;
|
|
85
|
+
return { id: u.id, email: u.email, name: u.name, role: u.role, disabled: await disabledFlag(u.id) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
getOption: (name) => options.get(name),
|
|
90
|
+
setOption: (name, value) => options.set(name, value),
|
|
91
|
+
setOptionIfAbsent: async (name, value) => {
|
|
92
|
+
await options.setIfAbsent(name, value);
|
|
93
|
+
},
|
|
94
|
+
deleteOption: async (name) => {
|
|
95
|
+
await options.delete(name);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
findUserByEmail: async (email) => toStoreUser(await users.findByEmail(email)),
|
|
99
|
+
findUserById: async (id) => toStoreUser(await users.findById(id)),
|
|
100
|
+
createUser: async (input) => (await toStoreUser(await users.create(input)))!,
|
|
101
|
+
setUserRole: async (id, role) => {
|
|
102
|
+
await db.updateTable("users").set({ role: ROLE_LEVEL[role], updated_at: new Date().toISOString() }).where("id", "=", id).execute();
|
|
103
|
+
},
|
|
104
|
+
markEmailVerified: async (id) => {
|
|
105
|
+
await db.updateTable("users").set({ email_verified: 1 }).where("id", "=", id).execute();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
replaceToken: async ({ userId, name, hash, prefix, scopes }) => {
|
|
109
|
+
await db.deleteFrom("_emdash_api_tokens").where("user_id", "=", userId).where("name", "=", name).execute();
|
|
110
|
+
await db
|
|
111
|
+
.insertInto("_emdash_api_tokens")
|
|
112
|
+
.values({ id: emdash.ulid(), name, token_hash: hash, prefix, user_id: userId, scopes: JSON.stringify(scopes), expires_at: null })
|
|
113
|
+
.execute();
|
|
114
|
+
},
|
|
115
|
+
findTokenOwner: async (name) => {
|
|
116
|
+
const row = await db
|
|
117
|
+
.selectFrom("_emdash_api_tokens")
|
|
118
|
+
.select("user_id")
|
|
119
|
+
.where("name", "=", name)
|
|
120
|
+
.orderBy("created_at", "desc")
|
|
121
|
+
.executeTakeFirst();
|
|
122
|
+
return (row?.user_id as string | undefined) ?? null;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
putHandoff: (token, handoff) => options.set(HANDOFF_PREFIX + token, handoff),
|
|
126
|
+
takeHandoff: async (token) => {
|
|
127
|
+
const name = HANDOFF_PREFIX + token;
|
|
128
|
+
const value = await options.get<Handoff>(name);
|
|
129
|
+
if (!value) return null;
|
|
130
|
+
await options.delete(name);
|
|
131
|
+
return value;
|
|
132
|
+
},
|
|
133
|
+
sweepHandoffs: async (now) => {
|
|
134
|
+
const rows = await db.selectFrom("options").select(["name", "value"]).where("name", "like", `${HANDOFF_PREFIX}%`).execute();
|
|
135
|
+
let removed = 0;
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
let expiresAt = 0;
|
|
138
|
+
try {
|
|
139
|
+
expiresAt = Number((JSON.parse(String(row.value)) as Handoff).expiresAt ?? 0);
|
|
140
|
+
} catch {
|
|
141
|
+
/* unreadable row: remove it */
|
|
142
|
+
}
|
|
143
|
+
if (expiresAt <= now) {
|
|
144
|
+
await options.delete(String(row.name));
|
|
145
|
+
removed++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return removed;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
migrationStatus: async () => {
|
|
152
|
+
const s = await emdash.getMigrationStatus(db);
|
|
153
|
+
return { applied: s.applied.length, pending: s.pending.length };
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The EmDash hook plugin (native, in-process). Loaded by EmDash through the
|
|
3
|
+
* descriptor from `tidyPlatformPlugin()`; EmDash imports `createPlugin` from
|
|
4
|
+
* this module and calls it with the descriptor's options.
|
|
5
|
+
*
|
|
6
|
+
* After a publish, unpublish, or delete it asks the control plane to snapshot
|
|
7
|
+
* staging into the live site. Every hook is `errorPolicy: "continue"` with a
|
|
8
|
+
* short timeout, so a control-plane hiccup never fails an editor's publish.
|
|
9
|
+
*/
|
|
10
|
+
import { definePlugin } from "emdash";
|
|
11
|
+
import { hookEnv } from "./lib/env";
|
|
12
|
+
import { requestPublish, type PublishEnv, type PublishEvent } from "./lib/publish";
|
|
13
|
+
import { PLUGIN_ID, VERSION } from "./version";
|
|
14
|
+
|
|
15
|
+
export const HOOK_TIMEOUT_MS = 8000;
|
|
16
|
+
|
|
17
|
+
type ContentEvent = { content?: { id?: unknown }; collection?: string; id?: unknown };
|
|
18
|
+
|
|
19
|
+
async function notify(reason: PublishEvent["reason"], event: ContentEvent): Promise<void> {
|
|
20
|
+
const env = (await hookEnv()) as PublishEnv | undefined;
|
|
21
|
+
const id = event.content?.id ?? event.id;
|
|
22
|
+
await requestPublish(env, { reason, collection: event.collection, ...(typeof id === "string" ? { id } : {}) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hook(reason: PublishEvent["reason"]) {
|
|
26
|
+
return {
|
|
27
|
+
errorPolicy: "continue" as const,
|
|
28
|
+
timeout: HOOK_TIMEOUT_MS,
|
|
29
|
+
handler: async (event: ContentEvent) => {
|
|
30
|
+
await notify(reason, event);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createPlugin(_options: Record<string, unknown> = {}) {
|
|
36
|
+
return definePlugin({
|
|
37
|
+
id: PLUGIN_ID,
|
|
38
|
+
version: VERSION,
|
|
39
|
+
// Content hooks are only registered for plugins that may read content.
|
|
40
|
+
capabilities: ["content:read"],
|
|
41
|
+
hooks: {
|
|
42
|
+
"content:afterPublish": hook("publish"),
|
|
43
|
+
"content:afterUnpublish": hook("unpublish"),
|
|
44
|
+
"content:afterDelete": hook("delete")
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { APIContext, APIRoute } from "astro";
|
|
2
|
+
import { gate } from "../lib/auth";
|
|
3
|
+
import { errorResponse } from "../lib/http";
|
|
4
|
+
import { buildDeps, workerEnv } from "../lib/runtime";
|
|
5
|
+
import type { Deps } from "../lib/handlers";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrap a platform route: resolve env, apply the secret gate, build deps, and
|
|
9
|
+
* turn thrown HttpErrors into JSON. `requireBearer: false` only for the
|
|
10
|
+
* browser-facing session claim.
|
|
11
|
+
*/
|
|
12
|
+
export function platformRoute(
|
|
13
|
+
handler: (context: APIContext, deps: Deps, env: Record<string, unknown>) => Promise<Response>,
|
|
14
|
+
options: { requireBearer?: boolean } = {}
|
|
15
|
+
): APIRoute {
|
|
16
|
+
return async (context) => {
|
|
17
|
+
const env = await workerEnv();
|
|
18
|
+
const g = gate(context.request, env, options.requireBearer ?? true);
|
|
19
|
+
if (!g.ok) return g.response;
|
|
20
|
+
try {
|
|
21
|
+
const deps = await buildDeps();
|
|
22
|
+
return await handler(context, deps, env!);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return errorResponse(err);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function methodNotAllowed(allow: string): APIRoute {
|
|
30
|
+
return () => new Response("Method not allowed", { status: 405, headers: { allow } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function requestOrigin(context: APIContext): string {
|
|
34
|
+
return new URL(context.request.url).origin;
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** POST /_tidy/bootstrap: seed, first admin, setup complete, platform token. */
|
|
2
|
+
import { bootstrap, type BootstrapInput } from "../lib/handlers";
|
|
3
|
+
import { json, readJson } from "../lib/http";
|
|
4
|
+
import { methodNotAllowed, platformRoute, requestOrigin } from "./_shared";
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export const POST = platformRoute(async (context, deps) => {
|
|
9
|
+
const body = await readJson<BootstrapInput>(context.request);
|
|
10
|
+
return json(await bootstrap(body, deps, requestOrigin(context)));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const ALL = methodNotAllowed("POST");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** GET /_tidy/health: plugin version, setup state, migration status. */
|
|
2
|
+
import { health } from "../lib/handlers";
|
|
3
|
+
import { json } from "../lib/http";
|
|
4
|
+
import { methodNotAllowed, platformRoute } from "./_shared";
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export const GET = platformRoute(async (_context, deps) => {
|
|
9
|
+
const result = await health(deps);
|
|
10
|
+
return json(result, result.ok ? 200 : 503);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const ALL = methodNotAllowed("GET");
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_tidy/maintenance: run EmDash's scheduled work (scheduled publishing,
|
|
3
|
+
* cleanup) now. Dispatch-namespace scripts never receive cron triggers, so the
|
|
4
|
+
* control plane's cron calls this on every site instead.
|
|
5
|
+
*/
|
|
6
|
+
import { runScheduledTasks } from "emdash/middleware";
|
|
7
|
+
import { json } from "../lib/http";
|
|
8
|
+
import { methodNotAllowed, platformRoute } from "./_shared";
|
|
9
|
+
|
|
10
|
+
export const prerender = false;
|
|
11
|
+
|
|
12
|
+
export const POST = platformRoute(async (context) => {
|
|
13
|
+
const started = Date.now();
|
|
14
|
+
const { published } = await runScheduledTasks();
|
|
15
|
+
return json({
|
|
16
|
+
ok: true,
|
|
17
|
+
cron: context.url.searchParams.get("cron") ?? null,
|
|
18
|
+
published: published.length,
|
|
19
|
+
refs: published.map((r) => ({ collection: r.collection, id: r.id })),
|
|
20
|
+
ms: Date.now() - started,
|
|
21
|
+
at: new Date().toISOString()
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const ALL = methodNotAllowed("POST");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** POST /_tidy/rotate-token: replace the platform API token. */
|
|
2
|
+
import { rotateToken, type RotateTokenInput } from "../lib/handlers";
|
|
3
|
+
import { json, readJson } from "../lib/http";
|
|
4
|
+
import { methodNotAllowed, platformRoute } from "./_shared";
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export const POST = platformRoute(async (context, deps) => {
|
|
9
|
+
const body = await readJson<RotateTokenInput>(context.request);
|
|
10
|
+
return json(await rotateToken(body, deps));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const ALL = methodNotAllowed("POST");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_tidy/session/claim?t=…: the browser lands here from a minted link.
|
|
3
|
+
* Signs the EmDash user in (Astro session, exactly as EmDash's own login
|
|
4
|
+
* does), sets or clears edit mode, and redirects to the requested local path.
|
|
5
|
+
*/
|
|
6
|
+
import { claimSession } from "../lib/handlers";
|
|
7
|
+
import { HttpError } from "../lib/http";
|
|
8
|
+
import { methodNotAllowed, platformRoute } from "./_shared";
|
|
9
|
+
|
|
10
|
+
export const prerender = false;
|
|
11
|
+
|
|
12
|
+
const EDIT_COOKIE = "emdash-edit-mode";
|
|
13
|
+
|
|
14
|
+
function expiredPage(): Response {
|
|
15
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Link expired</title><meta name="robots" content="noindex"></head><body style="font-family:system-ui,sans-serif;margin:3rem auto;max-width:32rem;padding:0 1rem"><p>This sign-in link has expired or was already used.</p></body></html>`;
|
|
16
|
+
return new Response(html, { status: 410, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const GET = platformRoute(
|
|
20
|
+
async (context, deps) => {
|
|
21
|
+
const result = await claimSession(context.url.searchParams.get("t"), deps);
|
|
22
|
+
if (result.kind !== "redirect") return expiredPage();
|
|
23
|
+
if (!context.session) throw new HttpError(500, "sessions are not configured on this site");
|
|
24
|
+
context.session.set("user", { id: result.userId });
|
|
25
|
+
const secure = context.url.protocol === "https:";
|
|
26
|
+
if (result.editMode) context.cookies.set(EDIT_COOKIE, "true", { path: "/", sameSite: "lax", secure });
|
|
27
|
+
else context.cookies.delete(EDIT_COOKIE, { path: "/" });
|
|
28
|
+
return new Response(null, { status: 303, headers: { location: result.redirect, "cache-control": "no-store" } });
|
|
29
|
+
},
|
|
30
|
+
{ requireBearer: false }
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const ALL = methodNotAllowed("GET");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** POST /_tidy/session: mint a one-time sign-in link for a platform person. */
|
|
2
|
+
import { mintSession, type MintSessionInput } from "../lib/handlers";
|
|
3
|
+
import { json, readJson } from "../lib/http";
|
|
4
|
+
import { methodNotAllowed, platformRoute, requestOrigin } from "./_shared";
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export const POST = platformRoute(async (context, deps) => {
|
|
9
|
+
const body = await readJson<MintSessionInput>(context.request);
|
|
10
|
+
return json(await mintSession(body, deps, requestOrigin(context)));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const ALL = methodNotAllowed("POST");
|
package/src/version.ts
ADDED