@pirateandfox/pagestack 0.0.0-stage → 0.1.0-beta.4

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/README.md CHANGED
@@ -1,3 +1,227 @@
1
- # Temporary Holding Version
1
+ # PageStack
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A visual editor for Astro sites where pages are a stack of sections. Git-only: saving is a commit,
4
+ the public site stays static, and nothing lives outside the repo.
5
+
6
+ - The public site stays **fully static**. `astro build` is unchanged.
7
+ - The editor (`/edit/*`, `/api/editor/*`) is on-demand rendered and only exists in `astro dev`
8
+ or in a build made with `EDITOR=1` (which adds `@astrojs/node`).
9
+ - Pages are JSON documents in the repo (`src/content/pages/<slug>.json`). Saving writes a file
10
+ (dev) or makes **one commit** through the GitHub API (production). Nothing is stored anywhere else.
11
+ - Login is **passkeys** (WebAuthn). Users' public keys live in a committed `editor-users.json`;
12
+ the only server secret is `EDITOR_SECRET`. No database, no session store.
13
+ - Live preview renders the real page with the site's own components, in the browser's iframe.
14
+
15
+ Status: **early beta** (`0.1.0-beta.x`). The API will move; pin a version. See [ROADMAP.md](ROADMAP.md)
16
+ and [CHANGELOG.md](CHANGELOG.md).
17
+
18
+ ## Versions
19
+
20
+ Published on npm as **`@pirateandfox/pagestack`** (betas under the `beta` dist-tag):
21
+
22
+ ```bash
23
+ npm install @pirateandfox/pagestack@beta
24
+ ```
25
+
26
+ Or install from a git tag and bump the tag to upgrade:
27
+
28
+ ```json
29
+ "dependencies": { "@pirateandfox/pagestack": "github:pirateandfox/pagestack#v0.1.0-beta.4" }
30
+ ```
31
+
32
+ ```bash
33
+ npm install # after changing the tag
34
+ ```
35
+
36
+ Releases are tags `vX.Y.Z[-beta.N]` on `main`; each one has a CHANGELOG entry.
37
+
38
+ ## Install
39
+
40
+ ```js
41
+ // astro.config.mjs
42
+ import pagestack from '@pirateandfox/pagestack';
43
+
44
+ export default defineConfig({
45
+ integrations: [
46
+ pagestack({
47
+ renderer: './src/components/SectionPage.astro', // renders { page, editing }
48
+ sections: './src/sections/index.ts', // exports { schemas, presets }
49
+ siteName: 'Acme site editor',
50
+ interactiveSelector: 'summary, .tabs__title, .map__load', // preview elements that keep their own click
51
+ auth: 'passkey', // 'passkey' | 'none-behind-proxy' | 'none' (dev only)
52
+ storage: process.env.EDITOR === '1' ? 'github' : 'local',
53
+ github: { repo: 'acme/site', branch: 'main', pullRequest: true },
54
+ }),
55
+ ],
56
+ });
57
+ ```
58
+
59
+ ```bash
60
+ npm run dev # editor at http://localhost:4321/edit, saves to the working tree
61
+ EDITOR=1 astro build # dist/server (node) + dist/client — the editor for a host
62
+ astro build # plain static site, no editor code
63
+ ```
64
+
65
+ ## What the site provides
66
+
67
+ ### 1. A content model: `src/content/pages/<slug>.json`
68
+
69
+ ```jsonc
70
+ {
71
+ "title": "Home",
72
+ "description": "…",
73
+ "layout": { "footer": "big", "css": ["post-9.css"] }, // opaque to the editor
74
+ "sections": [
75
+ { "id": "c8da6f4", "type": "container", "settings": { "backgroundColor": "#fff" },
76
+ "blocks": [
77
+ { "id": "1a2b3c4", "type": "heading", "settings": { "text": "Hello", "tag": "h1" } },
78
+ { "id": "5d6e7f8", "type": "text", "settings": { "html": "<p>…</p>" } }
79
+ ] }
80
+ ]
81
+ }
82
+ ```
83
+
84
+ A page is a tree of nodes. Top-level nodes are *sections*; anything can nest (`blocks`).
85
+ `attrs` is a passthrough for element attributes the editor never shows (class, aria-*).
86
+
87
+ ### 2. A block registry: `src/sections/index.ts`
88
+
89
+ ```ts
90
+ import type { BlockSchema, Preset } from '@pirateandfox/pagestack/runtime';
91
+ export const schemas = {
92
+ heading: {
93
+ label: 'Heading',
94
+ fields: {
95
+ text: { type: 'text', label: 'Text', inline: true, default: 'Heading' },
96
+ tag: { type: 'select', label: 'HTML tag', options: [...], default: 'h2', group: 'advanced' },
97
+ color: { type: 'color', label: 'Text colour', group: 'style' },
98
+ },
99
+ component: Heading, // the Astro component
100
+ },
101
+ container: { label: 'Container', fields: {...}, blocks: '*', component: Container },
102
+ gallery: { label: 'Gallery', fields: {}, blocks: ['gallery-item'], component: Gallery },
103
+ 'gallery-item': { label: 'Image', fields: {...}, hidden: true, component: GalleryItem },
104
+ } satisfies Record<string, BlockSchema & { component: any }>;
105
+
106
+ export const presets: Preset[] = [
107
+ { label: 'Hero', description: 'Full-width image with a heading and two buttons', create: () => ({ ... }) },
108
+ ];
109
+ ```
110
+
111
+ Field types: `text`, `richtext`, `image`, `link`, `color`, `select`, `number`, `boolean`,
112
+ `textarea`, `code`, `svg`. `inline: true` on `text` / `richtext` / `image` makes the field
113
+ editable by clicking it in the preview. The schema drives the sidebar form, validation on save,
114
+ the add-block menus and the tree labels. **Presets are the section library.**
115
+
116
+ ### 3. Components that mark what they render
117
+
118
+ Each block component receives `{ node, edit }` and spreads two helpers onto its markup:
119
+
120
+ ```astro
121
+ ---
122
+ import { nodeAttrs, fieldAttrs } from '@pirateandfox/pagestack/runtime';
123
+ const { node, edit } = Astro.props;
124
+ ---
125
+ <div {...nodeAttrs(edit, node)}>
126
+ <h2 {...fieldAttrs(edit, 'text', 'text')} set:html={node.settings.text} />
127
+ </div>
128
+ ```
129
+
130
+ `edit` is `undefined` on the public site, so nothing leaks into the static output. A container
131
+ passes `child(edit, i)` to each child. The `renderer` component takes `{ page, editing }` and
132
+ renders the page inside the site's normal layout.
133
+
134
+ ## Editing
135
+
136
+ - **Sidebar form** generated from the schema; live-debounced for colours and text.
137
+ - **Inline**: click text to edit it in place (plain text, or bold / italic / link for rich text);
138
+ click an image to open the picker (library browse, search, upload; alt text is required).
139
+ - **Structure**: tree on the left (drag to reorder sections and nested blocks), in-page toolbar
140
+ (move, duplicate, delete, select parent), "+ Add section" from presets, "+ Add block" from the
141
+ schema's allowed children.
142
+ - **Preview**: every change POSTs the draft document to `/edit/preview/<slug>`; the server renders
143
+ the full page in edit mode and the editor swaps only the sections that changed. Typically well
144
+ under 20 ms server-side in dev.
145
+ - **Drafts** are kept in `localStorage` until saved; undo/redo is per session.
146
+ - **Save** validates the document and either writes the file (local) or commits (github). A hash of
147
+ the content you loaded is sent along; if someone saved first you get a 409 and a reload prompt.
148
+
149
+ ## Storage
150
+
151
+ | `storage` | Reads | Saves | Uploads |
152
+ |---|---|---|---|
153
+ | `local` (dev default) | working tree | writes the JSON file (you commit) | written to `public/images/uploads/` |
154
+ | `github` (build default) | Contents API on `github.branch` | one commit via the Git Data API: blobs → tree → commit → ref; with `pullRequest: true` a new `site-editor/<timestamp>-<sha>` branch + PR instead | staged in memory, served by the editor server until the next save commits them |
155
+
156
+ The GitHub token (`GITHUB_TOKEN` or `EDITOR_GITHUB_TOKEN`) is read from the environment on the
157
+ server only. A fine-grained token with *Contents: write* (and *Pull requests: write* for PR mode)
158
+ on the one repo is enough. The host that runs the editor rebuilds and deploys from the commit
159
+ like any other push.
160
+
161
+ ## Login
162
+
163
+ ### `auth: "passkey"` (default)
164
+
165
+ - `editor-users.json` (committed, path configurable with `usersFile`) holds users and the
166
+ **public keys** of their passkeys. Nothing in it is secret.
167
+ - `EDITOR_SECRET` (≥ 16 chars, environment only) signs three kinds of short-lived tokens:
168
+ WebAuthn challenges (5 min), the session cookie (`ase_session`, HttpOnly, SameSite=Lax, 12 h) and
169
+ invite links (3 days). Tokens are `base64url(json).base64url(hmac-sha256)` with the expiry inside,
170
+ so there is no server state.
171
+ - **Invites** are single-use: the invite id is recorded on the credential it created. Make the first
172
+ one from the CLI, later ones from the "Invite another editor" panel on `/edit`:
173
+
174
+ ```bash
175
+ EDITOR_SECRET=… npx pagestack-invite --name "Jane Doe" --email jane@example.com --origin https://example.com
176
+ ```
177
+ - `/edit/enroll?invite=…` creates the passkey (Touch ID / Face ID / Windows Hello / security key);
178
+ `/edit/login` signs in with it. Several passkeys per person are fine (enrol again with a new invite).
179
+ - Set `EDITOR_ORIGIN` when a proxy rewrites `Host`; WebAuthn binds credentials to the origin the
180
+ browser sees.
181
+ - The signature counter is not persisted (it would mean a commit per login; synced passkeys report 0).
182
+
183
+ ### `auth: "none-behind-proxy"`
184
+
185
+ Use when something in front of the editor already authenticates people, e.g. **Cloudflare Access**:
186
+ put the editor host behind an Access application for `/edit/*` and `/api/editor/*`, allow the
187
+ client's email addresses, and the editor trusts the proxy. It reads the identity from
188
+ `Cf-Access-Authenticated-User-Email` (or `X-Forwarded-User`) for commit attribution. This is a
189
+ good fit when the site already sits on Cloudflare and the client prefers email one-time codes over
190
+ passkeys. (A production deployment should also verify the Access JWT on the editor host; that's not
191
+ implemented here.)
192
+
193
+ ### `auth: "none"`
194
+
195
+ Dev only. A build with `auth: "none"` is refused.
196
+
197
+ ## Deploying the editor
198
+
199
+ The editor server is a normal `@astrojs/node` standalone build. Run `node dist/server/entry.mjs`
200
+ on any small Node host (Fly, Railway, a VPS…) with `EDITOR_SECRET`, `GITHUB_TOKEN` and
201
+ `EDITOR_ORIGIN` set, behind HTTPS. It serves the editor routes and the static pages it was built
202
+ with; the public site is still deployed separately from the same repo as static files. Every
203
+ editor response is `noindex` and `no-store`.
204
+
205
+ ## Options
206
+
207
+ | option | default | |
208
+ |---|---|---|
209
+ | `renderer` | required | Astro component rendering `{ page, editing }` |
210
+ | `sections` | required | module exporting `{ schemas, presets }` |
211
+ | `pagesDir` | `src/content/pages` | |
212
+ | `imagesDir` / `uploadsDir` / `publicDir` | `public/images` / `public/images/uploads` / `public` | |
213
+ | `usersFile` | `editor-users.json` | |
214
+ | `auth` | `passkey` | `passkey` \| `none-behind-proxy` \| `none` |
215
+ | `storage` | `local` in dev, `github` in build | |
216
+ | `github` | — | `{ repo, branch, pullRequest }` |
217
+ | `enabled` | dev, or `EDITOR=1` | |
218
+ | `siteName`, `secretEnv` | `Site editor`, `EDITOR_SECRET` | |
219
+
220
+ ## Open-source pieces used
221
+
222
+ - [SortableJS](https://github.com/SortableJS/Sortable) (MIT) for the tree drag-and-drop.
223
+ - [@simplewebauthn/server + browser](https://simplewebauthn.dev) (MIT) for WebAuthn.
224
+ - [@astrojs/node](https://docs.astro.build/en/guides/integrations-guide/node/) (MIT) as the editor's adapter.
225
+ - [Puck](https://puckeditor.com) was used as a design reference for JSON page editing only; no code.
226
+
227
+ MIT.
package/bin/invite.mjs ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Create the first invite link without a running editor (later invites can be made from /edit).
3
+ //
4
+ // EDITOR_SECRET=… node packages/pagestack/bin/invite.mjs --name "Jane Doe" --email jane@example.com --origin https://example.com [--days 3]
5
+ //
6
+ // Same token format as src/core/auth.ts (HMAC-SHA256, base64url(json).base64url(mac)) so the
7
+ // running site can verify it. Plain JS so it runs on any Node without a TypeScript step.
8
+ import { createHmac, randomBytes } from 'node:crypto';
9
+
10
+ const args = Object.fromEntries(process.argv.slice(2).map((a, i, all) => a.startsWith('--') ? [a.slice(2), all[i + 1]?.startsWith('--') || all[i + 1] === undefined ? 'true' : all[i + 1]] : []).filter((p) => p.length));
11
+ const secret = process.env[args.secretEnv || 'EDITOR_SECRET'] || '';
12
+ const { name, email, origin, days = '3' } = args;
13
+ if (!name || !email || !origin || args.help) {
14
+ console.error('usage: EDITOR_SECRET=… invite.mjs --name "Jane Doe" --email jane@example.com --origin https://example.com [--days 3]');
15
+ process.exit(args.help ? 0 : 2);
16
+ }
17
+ if (secret.length < 16) { console.error('EDITOR_SECRET must be set in the environment (>= 16 characters)'); process.exit(2); }
18
+
19
+ const b64url = (buf) => Buffer.from(buf).toString('base64url');
20
+ const ttl = Math.round(Number(days) * 86400);
21
+ const payload = { kind: 'invite', id: b64url(randomBytes(12)), name: name.trim(), email: email.trim().toLowerCase() };
22
+ const body = b64url(Buffer.from(JSON.stringify({ payload, exp: Math.floor(Date.now() / 1000) + ttl })));
23
+ const mac = b64url(createHmac('sha256', secret).update(body).digest());
24
+ const token = `${body}.${mac}`;
25
+ console.log(`${origin.replace(/\/$/, '')}/edit/enroll?invite=${encodeURIComponent(token)}`);
26
+ console.error(`invite for ${payload.name} <${payload.email}>, valid ${days} day(s), single use`);
package/index.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // pagestack — a git-only visual section editor that ships inside an Astro site.
2
+ //
3
+ // import pagestack from '@pirateandfox/pagestack';
4
+ // integrations: [sectionEditor({ renderer: './src/components/SectionPage.astro', sections: './src/sections/index.ts' })]
5
+ //
6
+ // The public site stays fully static. The editor (/edit/*, /api/editor/*) is on-demand rendered
7
+ // and only exists when the integration is enabled: always in `astro dev`, and in `astro build`
8
+ // when EDITOR=1 (which produces a Node server build with @astrojs/node).
9
+ import node from '@astrojs/node';
10
+ import { fileURLToPath } from 'node:url';
11
+ import path from 'node:path';
12
+
13
+ const VIRTUAL = 'virtual:pagestack/site';
14
+ const VIRTUAL_RENDERER = 'virtual:pagestack/renderer';
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
17
+
18
+ /**
19
+ * @typedef {object} SectionEditorOptions
20
+ * @property {string} renderer Astro component rendering a page: props { page, editing }.
21
+ * @property {string} sections Module exporting { schemas, presets } (see runtime.ts types).
22
+ * @property {string} [pagesDir='src/content/pages'] Directory of <slug>.json pages.
23
+ * @property {string} [imagesDir='public/images'] Browsable image library root.
24
+ * @property {string} [uploadsDir='public/images/uploads'] Where uploads are written.
25
+ * @property {string} [publicDir='public'] Prefix stripped to turn file paths into URLs.
26
+ * @property {string} [usersFile='editor-users.json'] Passkey users (public keys only).
27
+ * @property {'none'|'passkey'|'none-behind-proxy'} [auth='passkey']
28
+ * @property {'local'|'github'} [storage] Defaults: local in dev, github in a build.
29
+ * @property {{repo: string, branch?: string, pullRequest?: boolean}} [github]
30
+ * @property {boolean} [enabled] Default: dev, or EDITOR=1.
31
+ * @property {string} [siteName] Shown in the editor chrome.
32
+ * @property {string} [interactiveSelector='summary'] Elements in the preview that keep their own click
33
+ * behaviour (accordion summaries, tab titles, load-map buttons…). Everything else is
34
+ * intercepted so links don't navigate while editing.
35
+ * @property {string} [secretEnv='EDITOR_SECRET'] Name of the env var holding the signing secret.
36
+ *
37
+ * Runtime environment (never in the repo):
38
+ * EDITOR_SECRET >= 16 chars; signs sessions, WebAuthn challenges and invite links.
39
+ * EDITOR_ORIGIN Public origin (https://example.com) when the server sits behind a proxy
40
+ * that rewrites Host — passkeys are bound to the origin the browser sees.
41
+ * GITHUB_TOKEN Fine-grained token with contents (+ pull requests) on the repo, github storage only.
42
+ */
43
+
44
+ /** @param {SectionEditorOptions} userOptions */
45
+ export default function sectionEditor(userOptions) {
46
+ if (!userOptions?.renderer || !userOptions?.sections) throw new Error('pagestack: `renderer` and `sections` are required');
47
+ // When the site has no adapter of its own we bring @astrojs/node along and forward every
48
+ // integration hook to it (an integration added via updateConfig() doesn't get to call
49
+ // setAdapter() in time, so we proxy its hooks instead).
50
+ let adapter = null;
51
+ const forward = (hook) => async (params) => { if (adapter?.hooks?.[hook]) await adapter.hooks[hook](params); };
52
+ const forwarded = Object.fromEntries(['astro:config:done', 'astro:server:setup', 'astro:server:start', 'astro:server:done', 'astro:build:start', 'astro:build:setup', 'astro:build:ssr', 'astro:build:generated', 'astro:build:done'].map((h) => [h, forward(h)]));
53
+ return {
54
+ name: 'pagestack',
55
+ hooks: {
56
+ ...forwarded,
57
+ 'astro:config:setup': async (params) => {
58
+ const { config, command, injectRoute, updateConfig, addMiddleware, logger } = params;
59
+ const enabled = userOptions.enabled ?? (command === 'dev' || process.env.EDITOR === '1');
60
+ if (!enabled) { logger.info('disabled (static build). Set EDITOR=1 to build the editor.'); return; }
61
+ const isBuild = command === 'build';
62
+ const options = {
63
+ pagesDir: 'src/content/pages',
64
+ imagesDir: 'public/images',
65
+ uploadsDir: 'public/images/uploads',
66
+ publicDir: 'public',
67
+ usersFile: 'editor-users.json',
68
+ auth: 'passkey',
69
+ storage: isBuild ? 'github' : 'local',
70
+ siteName: 'Site editor',
71
+ secretEnv: 'EDITOR_SECRET',
72
+ interactiveSelector: 'summary',
73
+ ...userOptions,
74
+ github: userOptions.github ?? null,
75
+ };
76
+ if (isBuild && options.auth === 'none') {
77
+ throw new Error('pagestack: auth: "none" is only allowed in `astro dev`. Use "passkey" or "none-behind-proxy" for a build.');
78
+ }
79
+ if (options.storage === 'github' && !options.github?.repo) {
80
+ throw new Error('pagestack: storage "github" needs github.repo ("owner/name")');
81
+ }
82
+ const root = fileURLToPath(config.root);
83
+ const abs = (p) => path.isAbsolute(p) ? p : path.resolve(root, p);
84
+ const serial = { ...options, root, renderer: abs(options.renderer), sections: abs(options.sections), command };
85
+
86
+ updateConfig({
87
+ vite: {
88
+ plugins: [{
89
+ name: 'pagestack:virtual',
90
+ resolveId(id) { if (id === VIRTUAL || id === VIRTUAL_RENDERER) return '\0' + id; },
91
+ load(id) {
92
+ // The renderer (and the site layout/CSS behind it) is only imported by the preview
93
+ // route, so the editor shell doesn't drag the whole site's CSS into its page.
94
+ if (id === '\0' + VIRTUAL_RENDERER) return `export { default as Renderer } from ${JSON.stringify(serial.renderer)};`;
95
+ if (id !== '\0' + VIRTUAL) return;
96
+ return [
97
+ `export * as site from ${JSON.stringify(serial.sections)};`,
98
+ `export const options = ${JSON.stringify(serial)};`,
99
+ ].join('\n');
100
+ },
101
+ }],
102
+ // The package is consumed from source (.ts/.astro); make sure Vite transforms it.
103
+ ssr: { noExternal: ['@pirateandfox/pagestack'] },
104
+ // Pre-bundle the editor's browser deps so dev doesn't re-optimise (and reload) mid-session.
105
+ optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser'] },
106
+ },
107
+ });
108
+ if (!config.adapter) {
109
+ adapter = node({ mode: 'standalone' });
110
+ await adapter.hooks['astro:config:setup']?.(params);
111
+ // Astro's build checks `config.adapter`; integrations added this late aren't hooked
112
+ // up automatically, hence the forwarded hooks above.
113
+ updateConfig({ adapter });
114
+ }
115
+
116
+ addMiddleware({ order: 'pre', entrypoint: new URL('./src/server/middleware.ts', import.meta.url) });
117
+
118
+ injectRoute({ pattern: '/edit', entrypoint: route('edit-index.astro'), prerender: false });
119
+ injectRoute({ pattern: '/edit/login', entrypoint: route('login.astro'), prerender: false });
120
+ injectRoute({ pattern: '/edit/enroll', entrypoint: route('enroll.astro'), prerender: false });
121
+ injectRoute({ pattern: '/edit/preview/[...slug]', entrypoint: route('preview.astro'), prerender: false });
122
+ injectRoute({ pattern: '/edit/[...slug]', entrypoint: route('edit-page.astro'), prerender: false });
123
+ injectRoute({ pattern: '/api/editor/pages', entrypoint: route('api/pages.ts'), prerender: false });
124
+ injectRoute({ pattern: '/api/editor/page/[...slug]', entrypoint: route('api/page.ts'), prerender: false });
125
+ injectRoute({ pattern: '/api/editor/images', entrypoint: route('api/images.ts'), prerender: false });
126
+ injectRoute({ pattern: '/api/editor/asset/[...path]', entrypoint: route('api/asset.ts'), prerender: false });
127
+ injectRoute({ pattern: '/api/editor/auth/[action]', entrypoint: route('api/auth.ts'), prerender: false });
128
+ logger.info(`enabled — storage: ${options.storage}, auth: ${options.auth}. Open /edit`);
129
+ },
130
+ },
131
+ };
132
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@pirateandfox/pagestack",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.4",
4
+ "description": "PageStack: visual section editor for Astro sites. Git-only, static public site, passkey login, saves are commits.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "pagestack-invite": "bin/invite.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./index.mjs",
12
+ "./runtime": "./src/runtime.ts",
13
+ "./core/*": "./src/core/*.ts"
14
+ },
15
+ "files": [
16
+ "index.mjs",
17
+ "bin",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "test": "vitest run"
22
+ },
23
+ "peerDependencies": {
24
+ "astro": ">=5.0.0"
25
+ },
26
+ "dependencies": {
27
+ "@astrojs/node": "10.1.1",
28
+ "@simplewebauthn/browser": "^13.0.0",
29
+ "@simplewebauthn/server": "^13.0.0",
30
+ "sortablejs": "^1.15.7"
31
+ },
32
+ "devDependencies": {
33
+ "@types/sortablejs": "^1.15.8",
34
+ "happy-dom": "^21.0.4",
35
+ "vitest": "^4.0.0"
36
+ },
37
+ "keywords": [
38
+ "astro-integration",
39
+ "astro-component",
40
+ "withastro",
41
+ "visual-editor",
42
+ "cms",
43
+ "git-based-cms",
44
+ "sections"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/pirateandfox/pagestack.git"
49
+ },
50
+ "homepage": "https://github.com/pirateandfox/pagestack#readme",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,35 @@
1
+ // Browser side of passkey login / enrolment (login.astro, enroll.astro).
2
+ import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';
3
+
4
+ const root = document.querySelector<HTMLElement>('[data-ase-auth]')!;
5
+ const go = document.getElementById('ase-auth-go') as HTMLButtonElement | null;
6
+ const errEl = document.getElementById('ase-auth-error')!;
7
+ const fail = (msg: string) => { errEl.textContent = msg; errEl.hidden = false; if (go) go.disabled = false; };
8
+ const post = async (action: string, body: unknown) => {
9
+ const res = await fetch(`/api/editor/auth/${action}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
10
+ const data = await res.json().catch(() => ({}));
11
+ if (!res.ok) throw new Error(data.error || `${action} failed (${res.status})`);
12
+ return data;
13
+ };
14
+
15
+ if (!browserSupportsWebAuthn()) fail('This browser does not support passkeys.');
16
+ else if (go) go.addEventListener('click', async () => {
17
+ go.disabled = true; errEl.hidden = true;
18
+ try {
19
+ if (root.dataset.aseAuth === 'login') {
20
+ const { options, token } = await post('login-options', {});
21
+ const response = await startAuthentication({ optionsJSON: options });
22
+ const { next } = await post('login-verify', { token, response, next: root.dataset.next });
23
+ location.assign(next || '/edit');
24
+ } else {
25
+ const { options, token } = await post('register-options', { invite: root.dataset.invite });
26
+ const response = await startRegistration({ optionsJSON: options });
27
+ const label = (document.getElementById('ase-auth-label') as HTMLInputElement | null)?.value;
28
+ await post('register-verify', { token, response, label });
29
+ location.assign('/edit');
30
+ }
31
+ } catch (e: any) {
32
+ fail(e?.name === 'NotAllowedError' ? 'Cancelled, or no passkey was offered. Try again.' : e?.message || String(e));
33
+ }
34
+ });
35
+ root.dataset.ready = '1'; // tests wait for this before clicking