wiki-formant 0.28.0 → 0.30.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.
@@ -225,3 +225,58 @@ export function RevisionChanges({ changes, label = type => type }) {
225
225
  return null;
226
226
  return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
227
227
  }
228
+ /** The windows the stats page offers, in days. */
229
+ export const STATS_RANGES = [1, 7, 30, 90];
230
+ /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
231
+ export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
232
+ const count = (n) => n.toLocaleString('en');
233
+ const regions = new Intl.DisplayNames(['en'], { type: 'region' });
234
+ const country = (code) => {
235
+ try {
236
+ return regions.of(code) ?? code;
237
+ }
238
+ catch {
239
+ return code;
240
+ }
241
+ };
242
+ const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
243
+ /** Unsigned at zero, so no change never reads as growth. */
244
+ const change = (now, before) => {
245
+ if (!before)
246
+ return null;
247
+ const pct = Math.round((100 * (now - before)) / before);
248
+ return pct > 0 ? `+${pct}%` : `${pct}%`;
249
+ };
250
+ function StatsList({ title, rows, label = v => v }) {
251
+ if (!rows.length)
252
+ return null;
253
+ const max = Math.max(...rows.map(([, n]) => n));
254
+ return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
255
+ }
256
+ /**
257
+ * A site's own analytics on one page: the headline figures against the window
258
+ * before, visitors per day, and every ranked list the digest carries. Lists
259
+ * with nothing in them are left out. Bars are `currentColor` until the
260
+ * importing stylesheet gives them a colour.
261
+ */
262
+ export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
263
+ const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
264
+ // A rolling window of n days touches n + 1 UTC dates, the first of them in part.
265
+ const now = Date.now();
266
+ const series = Array.from({ length: days + 1 }, (_, i) => {
267
+ const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
268
+ return [date, byDay.get(date) ?? 0];
269
+ });
270
+ const peak = Math.max(1, ...series.map(([, n]) => n));
271
+ const figures = [
272
+ ['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
273
+ ['Page views', count(d.pageviews), null],
274
+ ['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
275
+ ['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
276
+ ['With agents', count(d.visitors_incl_agents), null],
277
+ ];
278
+ if (d.follow_clicks)
279
+ figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
280
+ const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
281
+ return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx("ol", { className: "stats-chart", "aria-label": "Visitors per day", children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
282
+ }
@@ -19,6 +19,35 @@ export declare function validateLinkGroups(groups: unknown): boolean;
19
19
  export declare function validateCodeTabs(tabs: unknown): boolean;
20
20
  /** `[{ value, label, suffix? }]` — a stats block's cards. */
21
21
  export declare function validateStatItems(items: unknown): boolean;
22
+ /**
23
+ * The most rows an author-supplied count may ask for. Every `limit` and every
24
+ * `pageIds` list ends up in a database `take:` or `in:`, so the bound belongs
25
+ * between the editor and the query: a fractional `take` is a validation error
26
+ * against an Int column and an unbounded one is a table scan on every render.
27
+ */
28
+ export declare const MAX_BLOCK_ROWS = 100;
29
+ /** Clamp an author-supplied row count to a positive integer within the cap. The read-side twin of the check below. */
30
+ export declare function blockLimit(value: unknown, fallback: number, max?: number): number;
31
+ export interface CoreAtomicValidatorOptions {
32
+ /**
33
+ * Whether one `pageIds` entry is an id this wiki can look up. The format is
34
+ * each wiki's own — a cuid, an Int read back with `Number()`, a
35
+ * `section/slug` — so the default accepts any string.
36
+ */
37
+ pageId?: (id: string) => boolean;
38
+ /** Default `MAX_BLOCK_ROWS`. */
39
+ maxRows?: number;
40
+ }
41
+ /**
42
+ * The leaf checks for the core types, as one function a repo's `validateAtomic`
43
+ * falls through to: `default: return core(b)`. False for any type outside the
44
+ * core, so the repo's own cases still decide its native types.
45
+ *
46
+ * `recentPages.limit` must be a whole number within the cap and `pageIds` no
47
+ * longer than it — caper's rule, which the other two lacked. A scope field
48
+ * (`tagPath`, `section`) is a string when present.
49
+ */
50
+ export declare function coreAtomicValidator(opts?: CoreAtomicValidatorOptions): (block: Record<string, unknown>) => boolean;
22
51
  export interface BlockValidatorOptions {
23
52
  /** True for a type this wiki knows at all. */
24
53
  isKnownType: (type: string) => boolean;
@@ -72,6 +72,57 @@ export function validateStatItems(items) {
72
72
  (typeof s.value === 'string' || typeof s.value === 'number') &&
73
73
  (s.suffix === undefined || s.suffix === null || typeof s.suffix === 'string')));
74
74
  }
75
+ // ---- the core leaf set ------------------------------------------------------
76
+ /**
77
+ * The most rows an author-supplied count may ask for. Every `limit` and every
78
+ * `pageIds` list ends up in a database `take:` or `in:`, so the bound belongs
79
+ * between the editor and the query: a fractional `take` is a validation error
80
+ * against an Int column and an unbounded one is a table scan on every render.
81
+ */
82
+ export const MAX_BLOCK_ROWS = 100;
83
+ /** Clamp an author-supplied row count to a positive integer within the cap. The read-side twin of the check below. */
84
+ export function blockLimit(value, fallback, max = MAX_BLOCK_ROWS) {
85
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
86
+ return Math.min(Math.max(n, 1), max);
87
+ }
88
+ const okLimit = (value, max) => Number.isSafeInteger(value) && value > 0 && value <= max;
89
+ const optionalString = (v) => v === undefined || typeof v === 'string';
90
+ /**
91
+ * The leaf checks for the core types, as one function a repo's `validateAtomic`
92
+ * falls through to: `default: return core(b)`. False for any type outside the
93
+ * core, so the repo's own cases still decide its native types.
94
+ *
95
+ * `recentPages.limit` must be a whole number within the cap and `pageIds` no
96
+ * longer than it — caper's rule, which the other two lacked. A scope field
97
+ * (`tagPath`, `section`) is a string when present.
98
+ */
99
+ export function coreAtomicValidator(opts = {}) {
100
+ const { pageId = () => true, maxRows = MAX_BLOCK_ROWS } = opts;
101
+ return b => {
102
+ switch (b.type) {
103
+ case 'content':
104
+ return typeof b.text === 'string';
105
+ case 'recentPages':
106
+ return okLimit(b.limit, maxRows) && optionalString(b.tagPath) && optionalString(b.section);
107
+ case 'pageList':
108
+ return (Array.isArray(b.pageIds) &&
109
+ b.pageIds.length <= maxRows &&
110
+ b.pageIds.every(id => typeof id === 'string' && pageId(id)));
111
+ case 'codeTabs':
112
+ return validateCodeTabs(b.tabs);
113
+ case 'stats':
114
+ return validateStatItems(b.items);
115
+ case 'banner':
116
+ return typeof b.variant === 'string';
117
+ case 'references':
118
+ return validateReferenceItems(b.items);
119
+ case 'linkGrid':
120
+ return validateLinkGroups(b.groups);
121
+ default:
122
+ return false;
123
+ }
124
+ };
125
+ }
75
126
  /**
76
127
  * The block-tree walk, with one wiki's leaf switch plugged into it. The caller
77
128
  * keeps its own type parameter, so this stays free of any repo's types while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,10 +22,6 @@
22
22
  "*.css"
23
23
  ],
24
24
  "exports": {
25
- ".": {
26
- "types": "./dist/index.d.ts",
27
- "import": "./dist/index.js"
28
- },
29
25
  "./taxonomy": {
30
26
  "types": "./dist/taxonomy.d.ts",
31
27
  "import": "./dist/taxonomy.js"
@@ -58,10 +54,22 @@
58
54
  "types": "./dist/rate-limit.d.ts",
59
55
  "import": "./dist/rate-limit.js"
60
56
  },
57
+ "./db": {
58
+ "types": "./dist/db.d.ts",
59
+ "import": "./dist/db.js"
60
+ },
61
61
  "./analytics": {
62
62
  "types": "./dist/analytics.d.ts",
63
63
  "import": "./dist/analytics.js"
64
64
  },
65
+ "./passkey": {
66
+ "types": "./dist/passkey.d.ts",
67
+ "import": "./dist/passkey.js"
68
+ },
69
+ "./passkey-button": {
70
+ "types": "./dist/passkey-button.d.ts",
71
+ "import": "./dist/passkey-button.js"
72
+ },
65
73
  "./blocks": {
66
74
  "types": "./dist/blocks.d.ts",
67
75
  "import": "./dist/blocks.js"
@@ -142,14 +150,6 @@
142
150
  "types": "./dist/tiptap.d.ts",
143
151
  "import": "./dist/tiptap.js"
144
152
  },
145
- "./rola": {
146
- "types": "./dist/rola.d.ts",
147
- "import": "./dist/rola.js"
148
- },
149
- "./x402": {
150
- "types": "./dist/x402.d.ts",
151
- "import": "./dist/x402.js"
152
- },
153
153
  "./base.css": "./dist/base.css",
154
154
  "./block-views": {
155
155
  "types": "./dist/block-views.d.ts",
@@ -183,7 +183,8 @@
183
183
  "bin": {
184
184
  "check-classes": "bin/check-classes.mjs",
185
185
  "check-pages": "bin/check-pages.mjs",
186
- "analytics-digest": "bin/analytics-digest.mjs"
186
+ "analytics-digest": "bin/analytics-digest.mjs",
187
+ "passkey-invite": "bin/passkey-invite.mjs"
187
188
  },
188
189
  "files": [
189
190
  "dist",
@@ -198,7 +199,8 @@
198
199
  "prepare": "git config core.hooksPath .githooks || true"
199
200
  },
200
201
  "devDependencies": {
201
- "@radixdlt/rola": "^2.1.0",
202
+ "@simplewebauthn/browser": "^14.0.0",
203
+ "@simplewebauthn/server": "^14.0.3",
202
204
  "@tiptap/core": "^2.10.0",
203
205
  "@tiptap/extension-code-block": "^2.10.0",
204
206
  "@tiptap/extension-image": "^2.27.3",
@@ -216,14 +218,12 @@
216
218
  "@types/react": "^19.0.0",
217
219
  "@types/sanitize-html": "^2.16.1",
218
220
  "fast-diff": "^1.3.0",
219
- "jose": "^6.2.10",
220
221
  "jsdom": "^30.1.0",
221
222
  "react": "^19.2.4",
222
223
  "sanitize-html": "^2.17.7",
223
224
  "typescript": "^5.9.0"
224
225
  },
225
226
  "peerDependencies": {
226
- "@radixdlt/rola": ">=2",
227
227
  "@tiptap/core": ">=2.10",
228
228
  "@tiptap/extension-code-block": ">=2.10",
229
229
  "@tiptap/extension-image": ">=2.10",
@@ -238,10 +238,11 @@
238
238
  "@tiptap/react": ">=2.10",
239
239
  "@tiptap/starter-kit": ">=2.10",
240
240
  "fast-diff": ">=1.3",
241
- "jose": ">=5",
241
+ "pg": ">=8",
242
242
  "react": ">=18",
243
243
  "sanitize-html": ">=2",
244
- "pg": ">=8"
244
+ "@simplewebauthn/server": ">=13",
245
+ "@simplewebauthn/browser": ">=13"
245
246
  },
246
247
  "peerDependenciesMeta": {
247
248
  "react": {
@@ -259,12 +260,6 @@
259
260
  "@tiptap/extension-code-block": {
260
261
  "optional": true
261
262
  },
262
- "jose": {
263
- "optional": true
264
- },
265
- "@radixdlt/rola": {
266
- "optional": true
267
- },
268
263
  "@tiptap/starter-kit": {
269
264
  "optional": true
270
265
  },
@@ -300,6 +295,12 @@
300
295
  },
301
296
  "pg": {
302
297
  "optional": true
298
+ },
299
+ "@simplewebauthn/server": {
300
+ "optional": true
301
+ },
302
+ "@simplewebauthn/browser": {
303
+ "optional": true
303
304
  }
304
305
  }
305
306
  }
package/dist/index.d.ts DELETED
@@ -1,22 +0,0 @@
1
- export * from './taxonomy.js';
2
- export * from './mcp.js';
3
- export * from './headings.js';
4
- export * from './links.js';
5
- export * from './markdown.js';
6
- export * from './markdown.js';
7
- export * from './http.js';
8
- export * from './pagination.js';
9
- export * from './versioning.js';
10
- export * from './rate-limit.js';
11
- export * from './analytics.js';
12
- export * from './blocks.js';
13
- export * from './text.js';
14
- export * from './maps.js';
15
- export * from './freshness.js';
16
- export * from './combobox.js';
17
- export * from './crawlers.js';
18
- export * from './revisions.js';
19
- export * from './feed.js';
20
- export * from './license.js';
21
- export * from './seeded.js';
22
- export * from './metadata.js';
package/dist/index.js DELETED
@@ -1,27 +0,0 @@
1
- // wiki-formant — the portable half of a wiki.
2
- //
3
- // Nothing here touches a database, a framework or a design system. Those are
4
- // the parts every wiki must own; these are the parts every wiki rebuilds and
5
- // then lets drift.
6
- export * from './taxonomy.js';
7
- export * from './mcp.js';
8
- export * from './headings.js';
9
- export * from './links.js';
10
- export * from './markdown.js';
11
- export * from './markdown.js';
12
- export * from './http.js';
13
- export * from './pagination.js';
14
- export * from './versioning.js';
15
- export * from './rate-limit.js';
16
- export * from './analytics.js';
17
- export * from './blocks.js';
18
- export * from './text.js';
19
- export * from './maps.js';
20
- export * from './freshness.js';
21
- export * from './combobox.js';
22
- export * from './crawlers.js';
23
- export * from './revisions.js';
24
- export * from './feed.js';
25
- export * from './license.js';
26
- export * from './seeded.js';
27
- export * from './metadata.js';
package/dist/rola.d.ts DELETED
@@ -1,127 +0,0 @@
1
- export interface AuthSession {
2
- userId: string;
3
- radixAddress: string;
4
- personaAddress?: string;
5
- displayName?: string;
6
- expiresAt: Date;
7
- }
8
- export interface SignedChallenge {
9
- challenge: string;
10
- address: string;
11
- proof: {
12
- publicKey: string;
13
- signature: string;
14
- curve: 'curve25519' | 'secp256k1';
15
- };
16
- }
17
- /**
18
- * The two Prisma delegates this needs, structurally. Both wikis' `Session` and
19
- * `Challenge` models already satisfy it, so the binding is
20
- * `{ session: prisma.session, challenge: prisma.challenge }`.
21
- */
22
- export interface RolaStore {
23
- session: {
24
- create(args: {
25
- data: {
26
- userId: string;
27
- token: string;
28
- expiresAt: Date;
29
- };
30
- }): Promise<unknown>;
31
- findUnique(args: {
32
- where: {
33
- token: string;
34
- };
35
- }): Promise<{
36
- id: string;
37
- expiresAt: Date;
38
- } | null>;
39
- delete(args: {
40
- where: {
41
- id: string;
42
- };
43
- }): Promise<unknown>;
44
- deleteMany(args: {
45
- where: {
46
- token: string;
47
- };
48
- }): Promise<unknown>;
49
- };
50
- challenge: {
51
- create(args: {
52
- data: {
53
- challenge: string;
54
- expiresAt: Date;
55
- };
56
- }): Promise<unknown>;
57
- findUnique(args: {
58
- where: {
59
- challenge: string;
60
- };
61
- }): Promise<{
62
- id: string;
63
- expiresAt: Date;
64
- } | null>;
65
- delete(args: {
66
- where: {
67
- id: string;
68
- };
69
- }): Promise<unknown>;
70
- };
71
- }
72
- /** What `await cookies()` already gives you, narrowed to what this uses. */
73
- export interface CookieJar {
74
- get(name: string): {
75
- value: string;
76
- } | undefined;
77
- set(name: string, value: string, options: {
78
- httpOnly: boolean;
79
- secure: boolean;
80
- sameSite: 'lax';
81
- expires: Date;
82
- path: string;
83
- }): void;
84
- delete(name: string): void;
85
- }
86
- export interface RolaAuthConfig {
87
- /** This wiki's session cookie. The one thing the two copies actually differed in. */
88
- cookieName: string;
89
- jwtSecret: Uint8Array;
90
- store: RolaStore;
91
- cookies: () => Promise<CookieJar>;
92
- rola: {
93
- expectedOrigin: string;
94
- dAppDefinitionAddress: string;
95
- networkId: number;
96
- applicationName: string;
97
- };
98
- sessionDurationMs?: number;
99
- challengeTtlSec?: number;
100
- /** Defaults to `process.env.NODE_ENV === 'production'`. */
101
- secureCookies?: boolean;
102
- }
103
- /**
104
- * A wallet address tells you which proof type it is, and getting it wrong is a
105
- * silent rejection: the ROLA library verifies an `identity_*` proof only under
106
- * `type: 'persona'`. One of the two copies this replaces hardcoded `'account'`
107
- * and so could never accept a persona login at all.
108
- */
109
- export declare function proofType(address: string): 'account' | 'persona' | null;
110
- export interface RolaAuth {
111
- createSession(userId: string, radixAddress: string, personaAddress?: string, displayName?: string): Promise<string>;
112
- getSession(request?: {
113
- headers: {
114
- get(name: string): string | null;
115
- };
116
- }): Promise<AuthSession | null>;
117
- destroySession(): Promise<void>;
118
- generateChallenge(): Promise<{
119
- challenge: string;
120
- expiresAt: Date;
121
- }>;
122
- verifySignedChallenge(signed: SignedChallenge): Promise<{
123
- isValid: boolean;
124
- error?: string;
125
- }>;
126
- }
127
- export declare function createRolaAuth(config: RolaAuthConfig): RolaAuth;
package/dist/rola.js DELETED
@@ -1,150 +0,0 @@
1
- // rola.ts — Radix On-Ledger Authentication: challenge, proof, session.
2
- //
3
- // Parameterised on the two things a second wiki would differ in: the cookie
4
- // name and its dApp identity. It verifies persona (identity_*) proofs as well
5
- // as account proofs, and logs a failed verification so it is diagnosable.
6
- //
7
- // **One consumer.** caper's wallet auth never used this stack and acuiq gates
8
- // only its wiki editor, on a shared secret. Keep it — the
9
- // persona-proof fix and the error logging are worth not losing, and the ports
10
- // below are what make it reusable at all — but do not mistake it for a proven
11
- // shared abstraction. It is radix-wiki's auth that happens to live in a package.
12
- //
13
- // Storage and cookies are ports rather than imports. A Prisma client is
14
- // generated per repo and cannot be shared, and taking `next/headers` here would
15
- // bind the package to a framework it otherwise knows nothing about. Both ports
16
- // are shaped so the caller passes what it already has — the delegate and the
17
- // cookie jar — with no adapter to write.
18
- //
19
- // `jose` and `@radixdlt/rola` are optional peers: only a repo importing this
20
- // subpath needs them installed.
21
- import { SignJWT, jwtVerify } from 'jose';
22
- import { Rola } from '@radixdlt/rola';
23
- const DEFAULT_SESSION_MS = 7 * 24 * 60 * 60 * 1000;
24
- const DEFAULT_CHALLENGE_TTL_SEC = 300;
25
- const bytesToHex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
26
- /**
27
- * A wallet address tells you which proof type it is, and getting it wrong is a
28
- * silent rejection: the ROLA library verifies an `identity_*` proof only under
29
- * `type: 'persona'`. One of the two copies this replaces hardcoded `'account'`
30
- * and so could never accept a persona login at all.
31
- */
32
- export function proofType(address) {
33
- if (address.startsWith('account_'))
34
- return 'account';
35
- if (address.startsWith('identity_'))
36
- return 'persona';
37
- return null;
38
- }
39
- export function createRolaAuth(config) {
40
- const { cookieName, jwtSecret, store, cookies, sessionDurationMs = DEFAULT_SESSION_MS, challengeTtlSec = DEFAULT_CHALLENGE_TTL_SEC, secureCookies = process.env.NODE_ENV === 'production', } = config;
41
- const rola = Rola(config.rola);
42
- async function verifyToken(token) {
43
- try {
44
- const { payload } = await jwtVerify(token, jwtSecret);
45
- const session = await store.session.findUnique({ where: { token } });
46
- if (!session || session.expiresAt < new Date()) {
47
- if (session)
48
- await store.session.delete({ where: { id: session.id } }).catch(() => { });
49
- return null;
50
- }
51
- return {
52
- userId: payload.userId,
53
- radixAddress: payload.radixAddress,
54
- personaAddress: payload.personaAddress,
55
- displayName: payload.displayName,
56
- expiresAt: new Date(payload.exp * 1000),
57
- };
58
- }
59
- catch {
60
- return null;
61
- }
62
- }
63
- /** Consumes the challenge: a replayed one must not verify twice. */
64
- async function validateChallenge(challenge) {
65
- const stored = await store.challenge.findUnique({ where: { challenge } });
66
- if (!stored || stored.expiresAt < new Date()) {
67
- if (stored)
68
- await store.challenge.delete({ where: { id: stored.id } });
69
- return false;
70
- }
71
- await store.challenge.delete({ where: { id: stored.id } });
72
- return true;
73
- }
74
- return {
75
- async createSession(userId, radixAddress, personaAddress, displayName) {
76
- const expiresAt = new Date(Date.now() + sessionDurationMs);
77
- const token = await new SignJWT({ userId, radixAddress, personaAddress, displayName })
78
- .setProtectedHeader({ alg: 'HS256' })
79
- .setIssuedAt()
80
- .setExpirationTime(expiresAt)
81
- .setJti(crypto.randomUUID())
82
- .sign(jwtSecret);
83
- await store.session.create({ data: { userId, token, expiresAt } });
84
- const jar = await cookies();
85
- jar.set(cookieName, token, {
86
- httpOnly: true,
87
- secure: secureCookies,
88
- sameSite: 'lax',
89
- expires: expiresAt,
90
- path: '/',
91
- });
92
- return token;
93
- },
94
- async getSession(request) {
95
- const jar = await cookies();
96
- const cookieToken = jar.get(cookieName)?.value;
97
- if (cookieToken) {
98
- const session = await verifyToken(cookieToken);
99
- if (session)
100
- return session;
101
- }
102
- // Agents authenticate per-request with a Bearer token rather than a
103
- // cookie, so the header is a second, equal route in — not a fallback.
104
- if (request) {
105
- const authHeader = request.headers.get('Authorization');
106
- const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
107
- if (bearerToken)
108
- return verifyToken(bearerToken);
109
- }
110
- return null;
111
- },
112
- async destroySession() {
113
- const jar = await cookies();
114
- const token = jar.get(cookieName)?.value;
115
- if (token)
116
- await store.session.deleteMany({ where: { token } });
117
- jar.delete(cookieName);
118
- },
119
- async generateChallenge() {
120
- const array = new Uint8Array(32);
121
- crypto.getRandomValues(array);
122
- const challenge = bytesToHex(array);
123
- const expiresAt = new Date(Date.now() + challengeTtlSec * 1000);
124
- await store.challenge.create({ data: { challenge, expiresAt } });
125
- return { challenge, expiresAt };
126
- },
127
- async verifySignedChallenge(signedChallenge) {
128
- try {
129
- if (!(await validateChallenge(signedChallenge.challenge))) {
130
- console.error('ROLA: challenge validation failed', signedChallenge.challenge.slice(0, 16) + '...');
131
- return { isValid: false, error: 'Invalid or expired challenge' };
132
- }
133
- const type = proofType(signedChallenge.address);
134
- if (!type) {
135
- throw new Error(`ROLA: unrecognized address prefix (expected account_* or identity_*): ${signedChallenge.address}`);
136
- }
137
- const result = await rola.verifySignedChallenge({ ...signedChallenge, type });
138
- if (result.isErr()) {
139
- console.error('ROLA: verification failed', result.error.reason, result.error);
140
- return { isValid: false, error: result.error.reason };
141
- }
142
- return { isValid: true };
143
- }
144
- catch (error) {
145
- console.error('ROLA verification error:', error);
146
- return { isValid: false, error: 'Verification failed' };
147
- }
148
- },
149
- };
150
- }