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.
- package/LICENSE +9 -0
- package/README.md +89 -0
- package/dist/adapters-BlzWJG82.d.mts +106 -0
- package/dist/apply-CAPvMfoU.mjs +1339 -0
- package/dist/astro/index.d.mts +50 -0
- package/dist/astro/index.mjs +1326 -0
- package/dist/astro/middleware/auth.d.mts +30 -0
- package/dist/astro/middleware/auth.mjs +708 -0
- package/dist/astro/middleware/redirect.d.mts +21 -0
- package/dist/astro/middleware/redirect.mjs +62 -0
- package/dist/astro/middleware/request-context.d.mts +17 -0
- package/dist/astro/middleware/request-context.mjs +1371 -0
- package/dist/astro/middleware/setup.d.mts +19 -0
- package/dist/astro/middleware/setup.mjs +46 -0
- package/dist/astro/middleware.d.mts +12 -0
- package/dist/astro/middleware.mjs +1716 -0
- package/dist/astro/types.d.mts +269 -0
- package/dist/astro/types.mjs +1 -0
- package/dist/base64-F8-DUraK.mjs +58 -0
- package/dist/byline-DeWCMU_i.mjs +234 -0
- package/dist/bylines-DyqBV9EQ.mjs +137 -0
- package/dist/chunk-ClPoSABd.mjs +21 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +3987 -0
- package/dist/client/external-auth-headers.d.mts +38 -0
- package/dist/client/external-auth-headers.mjs +101 -0
- package/dist/client/index.d.mts +397 -0
- package/dist/client/index.mjs +345 -0
- package/dist/config-Cq8H0SfX.mjs +46 -0
- package/dist/connection-C9pxzuag.mjs +52 -0
- package/dist/content-zSgdNmnt.mjs +836 -0
- package/dist/db/index.d.mts +4 -0
- package/dist/db/index.mjs +62 -0
- package/dist/db/libsql.d.mts +10 -0
- package/dist/db/libsql.mjs +21 -0
- package/dist/db/postgres.d.mts +10 -0
- package/dist/db/postgres.mjs +29 -0
- package/dist/db/sqlite.d.mts +10 -0
- package/dist/db/sqlite.mjs +15 -0
- package/dist/default-WYlzADZL.mjs +80 -0
- package/dist/dialect-helpers-B9uSp2GJ.mjs +89 -0
- package/dist/error-DrxtnGPg.mjs +26 -0
- package/dist/index-C-jx21qs.d.mts +4771 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +30 -0
- package/dist/load-C6FCD1FU.mjs +27 -0
- package/dist/loader-qKmo0wAY.mjs +446 -0
- package/dist/manifest-schema-CTSEyIJ3.mjs +186 -0
- package/dist/media/index.d.mts +25 -0
- package/dist/media/index.mjs +54 -0
- package/dist/media/local-runtime.d.mts +38 -0
- package/dist/media/local-runtime.mjs +132 -0
- package/dist/media-DMTr80Gv.mjs +199 -0
- package/dist/mode-BlyYtIFO.mjs +22 -0
- package/dist/page/index.d.mts +148 -0
- package/dist/page/index.mjs +419 -0
- package/dist/placeholder-B3knXwNc.mjs +267 -0
- package/dist/placeholder-bOx1xCTY.d.mts +283 -0
- package/dist/plugin-utils.d.mts +57 -0
- package/dist/plugin-utils.mjs +77 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts +21 -0
- package/dist/plugins/adapt-sandbox-entry.mjs +112 -0
- package/dist/query-BiaPl_g2.mjs +459 -0
- package/dist/redirect-JPqLAbxa.mjs +328 -0
- package/dist/registry-DSd1GWB8.mjs +851 -0
- package/dist/request-context.d.mts +49 -0
- package/dist/request-context.mjs +42 -0
- package/dist/runner-B5l1JfOj.d.mts +26 -0
- package/dist/runner-BGUGywgG.mjs +1529 -0
- package/dist/runtime.d.mts +25 -0
- package/dist/runtime.mjs +41 -0
- package/dist/search-BNruJHDL.mjs +11054 -0
- package/dist/seed/index.d.mts +3 -0
- package/dist/seed/index.mjs +15 -0
- package/dist/seo/index.d.mts +69 -0
- package/dist/seo/index.mjs +69 -0
- package/dist/storage/local.d.mts +38 -0
- package/dist/storage/local.mjs +165 -0
- package/dist/storage/s3.d.mts +31 -0
- package/dist/storage/s3.mjs +174 -0
- package/dist/tokens-4vgYuXsZ.mjs +170 -0
- package/dist/transport-C5FYnid7.mjs +417 -0
- package/dist/transport-gIL-e43D.d.mts +41 -0
- package/dist/types-BawVha09.mjs +30 -0
- package/dist/types-BgQeVaPj.d.mts +192 -0
- package/dist/types-CLLdsG3g.d.mts +103 -0
- package/dist/types-D38djUXv.d.mts +1196 -0
- package/dist/types-DShnjzb6.mjs +15 -0
- package/dist/types-DkvMXalq.d.mts +425 -0
- package/dist/types-DuNbGKjF.mjs +74 -0
- package/dist/types-ju-_ORz7.d.mts +182 -0
- package/dist/validate-CXnRKfJK.mjs +327 -0
- package/dist/validate-CqRJb_xU.mjs +96 -0
- package/dist/validate-DVKJJ-M_.d.mts +377 -0
- package/locals.d.ts +47 -0
- 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 };
|