dineway 0.1.3

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.
Files changed (96) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +89 -0
  3. package/dist/adapters-BlzWJG82.d.mts +106 -0
  4. package/dist/apply-CAPvMfoU.mjs +1339 -0
  5. package/dist/astro/index.d.mts +50 -0
  6. package/dist/astro/index.mjs +1326 -0
  7. package/dist/astro/middleware/auth.d.mts +30 -0
  8. package/dist/astro/middleware/auth.mjs +708 -0
  9. package/dist/astro/middleware/redirect.d.mts +21 -0
  10. package/dist/astro/middleware/redirect.mjs +62 -0
  11. package/dist/astro/middleware/request-context.d.mts +17 -0
  12. package/dist/astro/middleware/request-context.mjs +1371 -0
  13. package/dist/astro/middleware/setup.d.mts +19 -0
  14. package/dist/astro/middleware/setup.mjs +46 -0
  15. package/dist/astro/middleware.d.mts +12 -0
  16. package/dist/astro/middleware.mjs +1716 -0
  17. package/dist/astro/types.d.mts +269 -0
  18. package/dist/astro/types.mjs +1 -0
  19. package/dist/base64-F8-DUraK.mjs +58 -0
  20. package/dist/byline-DeWCMU_i.mjs +234 -0
  21. package/dist/bylines-DyqBV9EQ.mjs +137 -0
  22. package/dist/chunk-ClPoSABd.mjs +21 -0
  23. package/dist/cli/index.d.mts +1 -0
  24. package/dist/cli/index.mjs +3987 -0
  25. package/dist/client/external-auth-headers.d.mts +38 -0
  26. package/dist/client/external-auth-headers.mjs +101 -0
  27. package/dist/client/index.d.mts +397 -0
  28. package/dist/client/index.mjs +345 -0
  29. package/dist/config-Cq8H0SfX.mjs +46 -0
  30. package/dist/connection-C9pxzuag.mjs +52 -0
  31. package/dist/content-zSgdNmnt.mjs +836 -0
  32. package/dist/db/index.d.mts +4 -0
  33. package/dist/db/index.mjs +62 -0
  34. package/dist/db/libsql.d.mts +10 -0
  35. package/dist/db/libsql.mjs +21 -0
  36. package/dist/db/postgres.d.mts +10 -0
  37. package/dist/db/postgres.mjs +29 -0
  38. package/dist/db/sqlite.d.mts +10 -0
  39. package/dist/db/sqlite.mjs +15 -0
  40. package/dist/default-WYlzADZL.mjs +80 -0
  41. package/dist/dialect-helpers-B9uSp2GJ.mjs +89 -0
  42. package/dist/error-DrxtnGPg.mjs +26 -0
  43. package/dist/index-C-jx21qs.d.mts +4771 -0
  44. package/dist/index.d.mts +16 -0
  45. package/dist/index.mjs +30 -0
  46. package/dist/load-C6FCD1FU.mjs +27 -0
  47. package/dist/loader-qKmo0wAY.mjs +446 -0
  48. package/dist/manifest-schema-CTSEyIJ3.mjs +186 -0
  49. package/dist/media/index.d.mts +25 -0
  50. package/dist/media/index.mjs +54 -0
  51. package/dist/media/local-runtime.d.mts +38 -0
  52. package/dist/media/local-runtime.mjs +132 -0
  53. package/dist/media-DMTr80Gv.mjs +199 -0
  54. package/dist/mode-BlyYtIFO.mjs +22 -0
  55. package/dist/page/index.d.mts +148 -0
  56. package/dist/page/index.mjs +419 -0
  57. package/dist/placeholder-B3knXwNc.mjs +267 -0
  58. package/dist/placeholder-bOx1xCTY.d.mts +283 -0
  59. package/dist/plugin-utils.d.mts +57 -0
  60. package/dist/plugin-utils.mjs +77 -0
  61. package/dist/plugins/adapt-sandbox-entry.d.mts +21 -0
  62. package/dist/plugins/adapt-sandbox-entry.mjs +112 -0
  63. package/dist/query-BiaPl_g2.mjs +459 -0
  64. package/dist/redirect-JPqLAbxa.mjs +328 -0
  65. package/dist/registry-DSd1GWB8.mjs +851 -0
  66. package/dist/request-context.d.mts +49 -0
  67. package/dist/request-context.mjs +42 -0
  68. package/dist/runner-B5l1JfOj.d.mts +26 -0
  69. package/dist/runner-BGUGywgG.mjs +1529 -0
  70. package/dist/runtime.d.mts +25 -0
  71. package/dist/runtime.mjs +41 -0
  72. package/dist/search-BNruJHDL.mjs +11054 -0
  73. package/dist/seed/index.d.mts +3 -0
  74. package/dist/seed/index.mjs +15 -0
  75. package/dist/seo/index.d.mts +69 -0
  76. package/dist/seo/index.mjs +69 -0
  77. package/dist/storage/local.d.mts +38 -0
  78. package/dist/storage/local.mjs +165 -0
  79. package/dist/storage/s3.d.mts +31 -0
  80. package/dist/storage/s3.mjs +174 -0
  81. package/dist/tokens-4vgYuXsZ.mjs +170 -0
  82. package/dist/transport-C5FYnid7.mjs +417 -0
  83. package/dist/transport-gIL-e43D.d.mts +41 -0
  84. package/dist/types-BawVha09.mjs +30 -0
  85. package/dist/types-BgQeVaPj.d.mts +192 -0
  86. package/dist/types-CLLdsG3g.d.mts +103 -0
  87. package/dist/types-D38djUXv.d.mts +1196 -0
  88. package/dist/types-DShnjzb6.mjs +15 -0
  89. package/dist/types-DkvMXalq.d.mts +425 -0
  90. package/dist/types-DuNbGKjF.mjs +74 -0
  91. package/dist/types-ju-_ORz7.d.mts +182 -0
  92. package/dist/validate-CXnRKfJK.mjs +327 -0
  93. package/dist/validate-CqRJb_xU.mjs +96 -0
  94. package/dist/validate-DVKJJ-M_.d.mts +377 -0
  95. package/locals.d.ts +47 -0
  96. package/package.json +313 -0
@@ -0,0 +1,708 @@
1
+ import { t as apiError } from "../../error-DrxtnGPg.mjs";
2
+ import { t as getAuthMode } from "../../mode-BlyYtIFO.mjs";
3
+ import { ulid } from "ulidx";
4
+ import { defineMiddleware } from "astro:middleware";
5
+ import { createKyselyAdapter } from "@dineway-ai/auth/adapters/kysely";
6
+ import { authenticate } from "virtual:dineway/auth";
7
+ import { hasScope, hashPrefixedToken as hashApiToken } from "@dineway-ai/auth";
8
+
9
+ //#region src/api/csrf.ts
10
+ /**
11
+ * CSRF protection utilities.
12
+ *
13
+ * Two mechanisms:
14
+ * 1. Custom header check (X-Dineway-Request: 1) — used for authenticated API routes.
15
+ * Browsers block cross-origin custom headers, so presence proves same-origin.
16
+ * 2. Origin check — used for public API routes that skip auth. Compares the Origin
17
+ * header against the request origin. Same approach as Astro's `checkOrigin`.
18
+ */
19
+ /**
20
+ * Origin-based CSRF check for public API routes that skip auth.
21
+ *
22
+ * State-changing requests (POST/PUT/DELETE) to public endpoints must either:
23
+ * 1. Include the X-Dineway-Request: 1 header (custom header blocked cross-origin), OR
24
+ * 2. Have an Origin header matching the request origin (or the configured public origin)
25
+ *
26
+ * This prevents cross-origin form submissions (which can't set custom headers)
27
+ * and cross-origin fetch (blocked by CORS unless allowed). Same-origin requests
28
+ * always include a matching Origin header.
29
+ *
30
+ * Returns a 403 Response if the check fails, or null if allowed.
31
+ *
32
+ * @param request The incoming request
33
+ * @param url The request URL (internal origin)
34
+ * @param publicOrigin The public-facing origin from config.siteUrl. Must be
35
+ * `undefined` when absent — never `null` or `""` (security invariant H-1a).
36
+ */
37
+ function checkPublicCsrf(request, url, publicOrigin) {
38
+ if (request.headers.get("X-Dineway-Request") === "1") return null;
39
+ const origin = request.headers.get("Origin");
40
+ if (origin) {
41
+ try {
42
+ const originUrl = new URL(origin);
43
+ if (originUrl.origin === url.origin) return null;
44
+ if (publicOrigin && originUrl.origin === publicOrigin) return null;
45
+ } catch {}
46
+ return apiError("CSRF_REJECTED", "Cross-origin request blocked", 403);
47
+ }
48
+ return null;
49
+ }
50
+
51
+ //#endregion
52
+ //#region src/api/public-url.ts
53
+ /**
54
+ * Resolve siteUrl from runtime environment variables.
55
+ *
56
+ * Uses process.env (not import.meta.env) because Vite statically replaces
57
+ * import.meta.env at build time, baking out any env vars not present during
58
+ * the build. Container deployments set env vars at runtime, so we must read
59
+ * process.env which Vite leaves untouched.
60
+ *
61
+ * On non-Node runtimes process.env may be unavailable, so the fallback chain
62
+ * continues to url.origin.
63
+ *
64
+ * Caches after first call.
65
+ */
66
+ let _envSiteUrl = null;
67
+ function getEnvSiteUrl() {
68
+ if (_envSiteUrl !== null) return _envSiteUrl || void 0;
69
+ try {
70
+ const value = typeof process !== "undefined" && process.env?.DINEWAY_SITE_URL || typeof process !== "undefined" && process.env?.SITE_URL || "";
71
+ if (value) {
72
+ const parsed = new URL(value);
73
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
74
+ _envSiteUrl = "";
75
+ return;
76
+ }
77
+ _envSiteUrl = parsed.origin;
78
+ } else _envSiteUrl = "";
79
+ } catch {
80
+ _envSiteUrl = "";
81
+ }
82
+ return _envSiteUrl || void 0;
83
+ }
84
+ /**
85
+ * Return the public-facing origin for the site.
86
+ *
87
+ * Resolution order:
88
+ * 1. `config.siteUrl` (set in astro.config.mjs, origin-normalized at startup)
89
+ * 2. `DINEWAY_SITE_URL` or `SITE_URL` env var (resolved at runtime for containers)
90
+ * 3. `url.origin` (internal request URL — correct when no proxy)
91
+ *
92
+ * @param url The request URL (`new URL(request.url)` or `Astro.url`)
93
+ * @param config The Dineway config (from `locals.dineway?.config`)
94
+ * @returns Origin string, e.g. `"https://mysite.example.com"`
95
+ */
96
+ function getPublicOrigin(url, config) {
97
+ return config?.siteUrl || getEnvSiteUrl() || url.origin;
98
+ }
99
+
100
+ //#endregion
101
+ //#region src/api/handlers/api-tokens.ts
102
+ /**
103
+ * Resolve a raw API token (ec_pat_...) to a user ID and scopes.
104
+ * Updates last_used_at on successful lookup.
105
+ * Returns null if the token is invalid or expired.
106
+ */
107
+ async function resolveApiToken(db, rawToken) {
108
+ const hash = hashApiToken(rawToken);
109
+ const row = await db.selectFrom("_dineway_api_tokens").select([
110
+ "id",
111
+ "user_id",
112
+ "scopes",
113
+ "expires_at"
114
+ ]).where("token_hash", "=", hash).executeTakeFirst();
115
+ if (!row) return null;
116
+ if (row.expires_at && new Date(row.expires_at) < /* @__PURE__ */ new Date()) return null;
117
+ db.updateTable("_dineway_api_tokens").set({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).where("id", "=", row.id).execute().catch(() => {});
118
+ return {
119
+ userId: row.user_id,
120
+ scopes: JSON.parse(row.scopes)
121
+ };
122
+ }
123
+ /**
124
+ * Resolve an OAuth access token (ec_oat_...) to a user ID and scopes.
125
+ * Returns null if the token is invalid or expired.
126
+ */
127
+ async function resolveOAuthToken(db, rawToken) {
128
+ const hash = hashApiToken(rawToken);
129
+ const row = await db.selectFrom("_dineway_oauth_tokens").select([
130
+ "user_id",
131
+ "scopes",
132
+ "expires_at",
133
+ "token_type"
134
+ ]).where("token_hash", "=", hash).where("token_type", "=", "access").executeTakeFirst();
135
+ if (!row) return null;
136
+ if (new Date(row.expires_at) < /* @__PURE__ */ new Date()) return null;
137
+ return {
138
+ userId: row.user_id,
139
+ scopes: JSON.parse(row.scopes)
140
+ };
141
+ }
142
+
143
+ //#endregion
144
+ //#region src/astro/middleware/csp.ts
145
+ /**
146
+ * Strict Content-Security-Policy for /_dineway routes (admin + API).
147
+ *
148
+ * Applied via middleware header rather than Astro's built-in CSP because
149
+ * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'
150
+ * when hashes are present), which would break user-facing pages.
151
+ *
152
+ * img-src allows any HTTPS origin because the admin renders user content that
153
+ * may reference external images (migrations, external hosting, embeds).
154
+ * Plugin security does not rely on img-src -- sandboxed plugins run behind the
155
+ * host-mediated `SandboxRunner` boundary with no DOM access, and connect-src
156
+ * 'self' blocks fetch-based exfiltration from the admin itself.
157
+ */
158
+ function buildDinewayCsp() {
159
+ return [
160
+ "default-src 'self'",
161
+ "script-src 'self' 'unsafe-inline'",
162
+ "style-src 'self' 'unsafe-inline'",
163
+ "connect-src 'self'",
164
+ "form-action 'self'",
165
+ "frame-ancestors 'none'",
166
+ "img-src 'self' https: data: blob:",
167
+ "object-src 'none'",
168
+ "base-uri 'self'"
169
+ ].join("; ");
170
+ }
171
+
172
+ //#endregion
173
+ //#region src/astro/middleware/auth.ts
174
+ /** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
175
+ const MW_CACHE_HEADERS = { "Cache-Control": "private, no-store" };
176
+ const ROLE_ADMIN = 50;
177
+ /**
178
+ * API routes that skip auth — each handles its own access control.
179
+ *
180
+ * Prefix entries match any path starting with that prefix.
181
+ * Exact entries (no trailing slash or wildcard) match that path only.
182
+ */
183
+ const PUBLIC_API_PREFIXES = [
184
+ "/_dineway/api/setup",
185
+ "/_dineway/api/auth/login",
186
+ "/_dineway/api/auth/register",
187
+ "/_dineway/api/auth/dev-bypass",
188
+ "/_dineway/api/auth/signup/",
189
+ "/_dineway/api/auth/magic-link/",
190
+ "/_dineway/api/auth/invite/accept",
191
+ "/_dineway/api/auth/invite/complete",
192
+ "/_dineway/api/auth/oauth/",
193
+ "/_dineway/api/oauth/device/token",
194
+ "/_dineway/api/oauth/device/code",
195
+ "/_dineway/api/oauth/token",
196
+ "/_dineway/api/comments/",
197
+ "/_dineway/api/media/file/",
198
+ "/_dineway/.well-known/"
199
+ ];
200
+ const PUBLIC_API_EXACT = new Set([
201
+ "/_dineway/api/auth/passkey/options",
202
+ "/_dineway/api/auth/passkey/verify",
203
+ "/_dineway/api/oauth/token",
204
+ "/_dineway/api/snapshot",
205
+ "/_dineway/api/search"
206
+ ]);
207
+ function isPublicDinewayRoute(pathname) {
208
+ if (PUBLIC_API_EXACT.has(pathname)) return true;
209
+ if (PUBLIC_API_PREFIXES.some((p) => pathname.startsWith(p))) return true;
210
+ if (import.meta.env.DEV && pathname === "/_dineway/api/typegen") return true;
211
+ return false;
212
+ }
213
+ const onRequest = defineMiddleware(async (context, next) => {
214
+ const { url } = context;
215
+ const isAdminRoute = url.pathname.startsWith("/_dineway/admin");
216
+ const isSetupRoute = url.pathname.startsWith("/_dineway/admin/setup");
217
+ const isApiRoute = url.pathname.startsWith("/_dineway/api");
218
+ const isPublicApiRoute = isPublicDinewayRoute(url.pathname);
219
+ const isPublicRoute = !isAdminRoute && !isApiRoute;
220
+ if (isPublicApiRoute) {
221
+ const method = context.request.method.toUpperCase();
222
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
223
+ const publicOrigin = getPublicOrigin(url, context.locals.dineway?.config);
224
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
225
+ if (csrfError) return csrfError;
226
+ }
227
+ return next();
228
+ }
229
+ if (url.pathname.startsWith("/_dineway/api/plugins/")) {
230
+ const method = context.request.method.toUpperCase();
231
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
232
+ const publicOrigin = getPublicOrigin(url, context.locals.dineway?.config);
233
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
234
+ if (csrfError) return csrfError;
235
+ }
236
+ return handlePluginRouteAuth(context, next);
237
+ }
238
+ if (isSetupRoute) {
239
+ const method = context.request.method.toUpperCase();
240
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
241
+ if (context.request.headers.get("X-Dineway-Request") !== "1") return new Response(JSON.stringify({ error: {
242
+ code: "CSRF_REJECTED",
243
+ message: "Missing required header"
244
+ } }), {
245
+ status: 403,
246
+ headers: {
247
+ "Content-Type": "application/json",
248
+ ...MW_CACHE_HEADERS
249
+ }
250
+ });
251
+ }
252
+ return next();
253
+ }
254
+ if (isPublicRoute) return handlePublicRouteAuth(context, next);
255
+ const bearerResult = await handleBearerAuth(context);
256
+ if (bearerResult === "invalid") {
257
+ const headers = {
258
+ "Content-Type": "application/json",
259
+ ...MW_CACHE_HEADERS
260
+ };
261
+ if (url.pathname === "/_dineway/api/mcp") headers["WWW-Authenticate"] = `Bearer resource_metadata="${getPublicOrigin(url, context.locals.dineway?.config)}/.well-known/oauth-protected-resource"`;
262
+ return new Response(JSON.stringify({ error: {
263
+ code: "INVALID_TOKEN",
264
+ message: "Invalid or expired token"
265
+ } }), {
266
+ status: 401,
267
+ headers
268
+ });
269
+ }
270
+ const isTokenAuth = bearerResult === "authenticated";
271
+ const method = context.request.method.toUpperCase();
272
+ const isOAuthConsent = url.pathname.startsWith("/_dineway/oauth/authorize");
273
+ if (isApiRoute && !isTokenAuth && !isOAuthConsent && method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && !isPublicApiRoute) {
274
+ if (context.request.headers.get("X-Dineway-Request") !== "1") return new Response(JSON.stringify({ error: {
275
+ code: "CSRF_REJECTED",
276
+ message: "Missing required header"
277
+ } }), {
278
+ status: 403,
279
+ headers: {
280
+ "Content-Type": "application/json",
281
+ ...MW_CACHE_HEADERS
282
+ }
283
+ });
284
+ }
285
+ if (isTokenAuth) {
286
+ const scopeError = enforceTokenScope(url.pathname, method, context.locals.tokenScopes);
287
+ if (scopeError) return scopeError;
288
+ const response = await next();
289
+ if (!import.meta.env.DEV) response.headers.set("Content-Security-Policy", buildDinewayCsp());
290
+ return response;
291
+ }
292
+ const response = await handleDinewayAuth(context, next);
293
+ if (!import.meta.env.DEV) response.headers.set("Content-Security-Policy", buildDinewayCsp());
294
+ return response;
295
+ });
296
+ /**
297
+ * Auth handling for /_dineway routes. Returns a Response from either
298
+ * an auth error/redirect or the downstream route handler.
299
+ */
300
+ async function handleDinewayAuth(context, next) {
301
+ const { url, locals } = context;
302
+ const { dineway } = locals;
303
+ const isLoginRoute = url.pathname.startsWith("/_dineway/admin/login");
304
+ const isApiRoute = url.pathname.startsWith("/_dineway/api");
305
+ if (!dineway?.db) return next();
306
+ const authMode = getAuthMode(dineway.config);
307
+ if (authMode.type === "external") {
308
+ if (import.meta.env.DEV) {
309
+ if (isLoginRoute) return next();
310
+ return handlePasskeyAuth(context, next, isApiRoute);
311
+ }
312
+ return handleExternalAuth(context, next, authMode, isApiRoute);
313
+ }
314
+ if (isLoginRoute) return next();
315
+ return handlePasskeyAuth(context, next, isApiRoute);
316
+ }
317
+ /**
318
+ * Soft auth for plugin routes: resolve user from Bearer token or session if present,
319
+ * but never block unauthenticated requests. The catch-all handler checks route
320
+ * metadata to decide whether auth is required (public vs private routes).
321
+ */
322
+ async function handlePluginRouteAuth(context, next) {
323
+ const { locals } = context;
324
+ const { dineway } = locals;
325
+ try {
326
+ const bearerResult = await handleBearerAuth(context);
327
+ if (bearerResult === "authenticated") return next();
328
+ if (bearerResult === "invalid") return new Response(JSON.stringify({ error: {
329
+ code: "INVALID_TOKEN",
330
+ message: "Invalid or expired token"
331
+ } }), {
332
+ status: 401,
333
+ headers: {
334
+ "Content-Type": "application/json",
335
+ ...MW_CACHE_HEADERS
336
+ }
337
+ });
338
+ } catch (error) {
339
+ console.error("Plugin route bearer auth error:", error);
340
+ }
341
+ try {
342
+ const { session } = context;
343
+ const sessionUser = await session?.get("user");
344
+ if (sessionUser?.id && dineway?.db) {
345
+ const user = await createKyselyAdapter(dineway.db).getUserById(sessionUser.id);
346
+ if (user && !user.disabled) locals.user = user;
347
+ }
348
+ } catch (error) {
349
+ console.error("Plugin route session auth error:", error);
350
+ }
351
+ return next();
352
+ }
353
+ /**
354
+ * Soft auth check for public routes with edit mode cookie.
355
+ * Checks the session and sets locals.user if valid, but never blocks the request.
356
+ */
357
+ async function handlePublicRouteAuth(context, next) {
358
+ const { locals, session } = context;
359
+ const { dineway } = locals;
360
+ try {
361
+ const sessionUser = await session?.get("user");
362
+ if (sessionUser?.id && dineway?.db) {
363
+ const user = await createKyselyAdapter(dineway.db).getUserById(sessionUser.id);
364
+ if (user && !user.disabled) locals.user = user;
365
+ }
366
+ } catch {}
367
+ return next();
368
+ }
369
+ /**
370
+ * Handle external auth provider authentication
371
+ */
372
+ async function handleExternalAuth(context, next, authMode, _isApiRoute) {
373
+ const { locals, request } = context;
374
+ const { dineway } = locals;
375
+ try {
376
+ if (typeof authenticate !== "function") throw new Error(`Auth provider ${authMode.entrypoint} does not export an authenticate function`);
377
+ const authResult = await authenticate(request, authMode.config);
378
+ const externalConfig = authMode.config;
379
+ const adapter = createKyselyAdapter(dineway.db);
380
+ let user = await adapter.getUserByEmail(authResult.email);
381
+ if (!user) {
382
+ if (externalConfig.autoProvision === false) return new Response("User not authorized", {
383
+ status: 403,
384
+ headers: {
385
+ "Content-Type": "text/plain",
386
+ ...MW_CACHE_HEADERS
387
+ }
388
+ });
389
+ const userCount = await dineway.db.selectFrom("users").select(dineway.db.fn.count("id").as("count")).executeTakeFirst();
390
+ const isFirstUser = Number(userCount?.count ?? 0) === 0;
391
+ const role = isFirstUser ? ROLE_ADMIN : authResult.role;
392
+ const now = (/* @__PURE__ */ new Date()).toISOString();
393
+ const newUser = {
394
+ id: ulid(),
395
+ email: authResult.email,
396
+ name: authResult.name,
397
+ role,
398
+ email_verified: 1,
399
+ created_at: now,
400
+ updated_at: now
401
+ };
402
+ await dineway.db.insertInto("users").values(newUser).execute();
403
+ user = await adapter.getUserByEmail(authResult.email);
404
+ console.log(`[external-auth] Provisioned user: ${authResult.email} (role: ${role}, first: ${isFirstUser})`);
405
+ } else {
406
+ const updates = {};
407
+ let newName;
408
+ let newRole;
409
+ if (authResult.name && user.name !== authResult.name) {
410
+ newName = authResult.name;
411
+ updates.name = newName;
412
+ }
413
+ if (externalConfig.syncRoles && user.role !== authResult.role) {
414
+ newRole = authResult.role;
415
+ updates.role = newRole;
416
+ }
417
+ if (Object.keys(updates).length > 0) {
418
+ updates.updated_at = (/* @__PURE__ */ new Date()).toISOString();
419
+ await dineway.db.updateTable("users").set(updates).where("id", "=", user.id).execute();
420
+ user = {
421
+ ...user,
422
+ ...newName ? { name: newName } : {},
423
+ ...newRole ? { role: newRole } : {}
424
+ };
425
+ console.log(`[external-auth] Updated user ${authResult.email}:`, Object.keys(updates).filter((k) => k !== "updated_at"));
426
+ }
427
+ }
428
+ if (!user) return new Response("Failed to provision user", {
429
+ status: 500,
430
+ headers: {
431
+ "Content-Type": "text/plain",
432
+ ...MW_CACHE_HEADERS
433
+ }
434
+ });
435
+ if (user.disabled) return new Response("Account disabled", {
436
+ status: 403,
437
+ headers: {
438
+ "Content-Type": "text/plain",
439
+ ...MW_CACHE_HEADERS
440
+ }
441
+ });
442
+ locals.user = user;
443
+ const { session } = context;
444
+ session?.set("user", { id: user.id });
445
+ return next();
446
+ } catch (error) {
447
+ console.error("[external-auth] Auth error:", error);
448
+ return new Response("Authentication failed", {
449
+ status: 401,
450
+ headers: {
451
+ "Content-Type": "text/plain",
452
+ ...MW_CACHE_HEADERS
453
+ }
454
+ });
455
+ }
456
+ }
457
+ /**
458
+ * Try to authenticate via Bearer token (API token or OAuth token).
459
+ *
460
+ * Returns:
461
+ * - "authenticated" if token is valid and user is resolved
462
+ * - "invalid" if a token was provided but is invalid/expired
463
+ * - "none" if no Bearer token was provided
464
+ */
465
+ async function handleBearerAuth(context) {
466
+ const authHeader = context.request.headers.get("Authorization");
467
+ if (!authHeader?.startsWith("Bearer ")) return "none";
468
+ const token = authHeader.slice(7);
469
+ if (!token) return "none";
470
+ const { locals } = context;
471
+ const { dineway } = locals;
472
+ if (!dineway?.db) return "none";
473
+ let resolved = null;
474
+ if (token.startsWith("ec_pat_")) resolved = await resolveApiToken(dineway.db, token);
475
+ else if (token.startsWith("ec_oat_")) resolved = await resolveOAuthToken(dineway.db, token);
476
+ else return "invalid";
477
+ if (!resolved) return "invalid";
478
+ const user = await createKyselyAdapter(dineway.db).getUserById(resolved.userId);
479
+ if (!user || user.disabled) return "invalid";
480
+ locals.user = user;
481
+ locals.tokenScopes = resolved.scopes;
482
+ return "authenticated";
483
+ }
484
+ /**
485
+ * Handle passkey (session-based) authentication
486
+ */
487
+ async function handlePasskeyAuth(context, next, isApiRoute) {
488
+ const { url, locals, session } = context;
489
+ const { dineway } = locals;
490
+ try {
491
+ const sessionUser = await session?.get("user");
492
+ if (!sessionUser?.id) {
493
+ if (isApiRoute) {
494
+ const headers = { ...MW_CACHE_HEADERS };
495
+ if (url.pathname === "/_dineway/api/mcp") headers["WWW-Authenticate"] = `Bearer resource_metadata="${getPublicOrigin(url, dineway?.config)}/.well-known/oauth-protected-resource"`;
496
+ return Response.json({ error: {
497
+ code: "NOT_AUTHENTICATED",
498
+ message: "Not authenticated"
499
+ } }, {
500
+ status: 401,
501
+ headers
502
+ });
503
+ }
504
+ const loginUrl = new URL("/_dineway/admin/login", getPublicOrigin(url, dineway?.config));
505
+ loginUrl.searchParams.set("redirect", url.pathname);
506
+ return context.redirect(loginUrl.toString());
507
+ }
508
+ const user = await createKyselyAdapter(dineway.db).getUserById(sessionUser.id);
509
+ if (!user) {
510
+ session?.destroy();
511
+ if (isApiRoute) return Response.json({ error: {
512
+ code: "NOT_FOUND",
513
+ message: "User not found"
514
+ } }, {
515
+ status: 401,
516
+ headers: MW_CACHE_HEADERS
517
+ });
518
+ const loginUrl = new URL("/_dineway/admin/login", getPublicOrigin(url, dineway?.config));
519
+ return context.redirect(loginUrl.toString());
520
+ }
521
+ if (user.disabled) {
522
+ session?.destroy();
523
+ if (isApiRoute) return apiError("ACCOUNT_DISABLED", "Account disabled", 403);
524
+ const loginUrl = new URL("/_dineway/admin/login", getPublicOrigin(url, dineway?.config));
525
+ loginUrl.searchParams.set("error", "account_disabled");
526
+ return context.redirect(loginUrl.toString());
527
+ }
528
+ locals.user = user;
529
+ } catch (error) {
530
+ console.error("Auth middleware error:", error);
531
+ return context.redirect("/_dineway/admin/login");
532
+ }
533
+ return next();
534
+ }
535
+ /**
536
+ * Scope rules: ordered list of (pathPrefix, method, requiredScope) tuples.
537
+ * First matching rule wins. Methods: "*" = any, "WRITE" = POST/PUT/PATCH/DELETE.
538
+ *
539
+ * Routes not matched by any rule default to "admin" scope (fail-closed).
540
+ */
541
+ const SCOPE_RULES = [
542
+ [
543
+ "/_dineway/api/content",
544
+ "GET",
545
+ "content:read"
546
+ ],
547
+ [
548
+ "/_dineway/api/content",
549
+ "WRITE",
550
+ "content:write"
551
+ ],
552
+ [
553
+ "/_dineway/api/media/file",
554
+ "*",
555
+ "media:read"
556
+ ],
557
+ [
558
+ "/_dineway/api/media",
559
+ "GET",
560
+ "media:read"
561
+ ],
562
+ [
563
+ "/_dineway/api/media",
564
+ "WRITE",
565
+ "media:write"
566
+ ],
567
+ [
568
+ "/_dineway/api/schema",
569
+ "GET",
570
+ "schema:read"
571
+ ],
572
+ [
573
+ "/_dineway/api/schema",
574
+ "WRITE",
575
+ "schema:write"
576
+ ],
577
+ [
578
+ "/_dineway/api/taxonomies",
579
+ "GET",
580
+ "content:read"
581
+ ],
582
+ [
583
+ "/_dineway/api/taxonomies",
584
+ "WRITE",
585
+ "content:write"
586
+ ],
587
+ [
588
+ "/_dineway/api/menus",
589
+ "GET",
590
+ "content:read"
591
+ ],
592
+ [
593
+ "/_dineway/api/menus",
594
+ "WRITE",
595
+ "content:write"
596
+ ],
597
+ [
598
+ "/_dineway/api/sections",
599
+ "GET",
600
+ "content:read"
601
+ ],
602
+ [
603
+ "/_dineway/api/sections",
604
+ "WRITE",
605
+ "content:write"
606
+ ],
607
+ [
608
+ "/_dineway/api/widget-areas",
609
+ "GET",
610
+ "content:read"
611
+ ],
612
+ [
613
+ "/_dineway/api/widget-areas",
614
+ "WRITE",
615
+ "content:write"
616
+ ],
617
+ [
618
+ "/_dineway/api/revisions",
619
+ "GET",
620
+ "content:read"
621
+ ],
622
+ [
623
+ "/_dineway/api/revisions",
624
+ "WRITE",
625
+ "content:write"
626
+ ],
627
+ [
628
+ "/_dineway/api/search",
629
+ "GET",
630
+ "content:read"
631
+ ],
632
+ [
633
+ "/_dineway/api/search",
634
+ "WRITE",
635
+ "admin"
636
+ ],
637
+ [
638
+ "/_dineway/api/import",
639
+ "*",
640
+ "admin"
641
+ ],
642
+ [
643
+ "/_dineway/api/admin",
644
+ "*",
645
+ "admin"
646
+ ],
647
+ [
648
+ "/_dineway/api/settings",
649
+ "*",
650
+ "admin"
651
+ ],
652
+ [
653
+ "/_dineway/api/plugins",
654
+ "*",
655
+ "admin"
656
+ ],
657
+ [
658
+ "/_dineway/api/mcp",
659
+ "*",
660
+ "content:read"
661
+ ]
662
+ ];
663
+ const WRITE_METHODS = new Set([
664
+ "POST",
665
+ "PUT",
666
+ "PATCH",
667
+ "DELETE"
668
+ ]);
669
+ /**
670
+ * Enforce API token scopes based on the request URL and HTTP method.
671
+ * Returns a 403 Response if the scope is insufficient, or null if allowed.
672
+ *
673
+ * Session-authenticated requests (tokenScopes === undefined) are never checked.
674
+ */
675
+ function enforceTokenScope(pathname, method, tokenScopes) {
676
+ if (!tokenScopes) return null;
677
+ const isWrite = WRITE_METHODS.has(method);
678
+ for (const [prefix, ruleMethod, scope] of SCOPE_RULES) {
679
+ if (pathname !== prefix && !pathname.startsWith(prefix + "/")) continue;
680
+ if (ruleMethod === "*" || ruleMethod === "WRITE" && isWrite || ruleMethod === method) {
681
+ if (hasScope(tokenScopes, scope)) return null;
682
+ return new Response(JSON.stringify({ error: {
683
+ code: "INSUFFICIENT_SCOPE",
684
+ message: `Token lacks required scope: ${scope}`
685
+ } }), {
686
+ status: 403,
687
+ headers: {
688
+ "Content-Type": "application/json",
689
+ ...MW_CACHE_HEADERS
690
+ }
691
+ });
692
+ }
693
+ }
694
+ if (hasScope(tokenScopes, "admin")) return null;
695
+ return new Response(JSON.stringify({ error: {
696
+ code: "INSUFFICIENT_SCOPE",
697
+ message: "Token lacks required scope: admin"
698
+ } }), {
699
+ status: 403,
700
+ headers: {
701
+ "Content-Type": "application/json",
702
+ ...MW_CACHE_HEADERS
703
+ }
704
+ });
705
+ }
706
+
707
+ //#endregion
708
+ export { onRequest };