@nonext/husk 0.2.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 (89) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +166 -0
  3. package/README.md +140 -0
  4. package/api/public-api.md +1070 -0
  5. package/dist/adapter-C_0mhENy.d.ts +320 -0
  6. package/dist/admin/admin.css +5 -0
  7. package/dist/admin/index.d.ts +265 -0
  8. package/dist/admin/index.js +393 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/admin/tokens.css +597 -0
  11. package/dist/admin/ui.d.ts +3013 -0
  12. package/dist/admin/ui.js +9783 -0
  13. package/dist/admin/ui.js.map +1 -0
  14. package/dist/auth/index.d.ts +4 -0
  15. package/dist/auth/index.js +9 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth-B2gBtHl5.d.ts +28 -0
  18. package/dist/can-Chy7zmx7.d.ts +69 -0
  19. package/dist/chunk-3I6337WJ.js +83 -0
  20. package/dist/chunk-3I6337WJ.js.map +1 -0
  21. package/dist/chunk-4Z6P3XCE.js +62 -0
  22. package/dist/chunk-4Z6P3XCE.js.map +1 -0
  23. package/dist/chunk-6E3ZS5Z5.js +1007 -0
  24. package/dist/chunk-6E3ZS5Z5.js.map +1 -0
  25. package/dist/chunk-77JDRXXU.js +3 -0
  26. package/dist/chunk-77JDRXXU.js.map +1 -0
  27. package/dist/chunk-FHYWJCG6.js +171 -0
  28. package/dist/chunk-FHYWJCG6.js.map +1 -0
  29. package/dist/chunk-JAE7HHMM.js +506 -0
  30. package/dist/chunk-JAE7HHMM.js.map +1 -0
  31. package/dist/chunk-NPDBQBLW.js +68 -0
  32. package/dist/chunk-NPDBQBLW.js.map +1 -0
  33. package/dist/chunk-ORMYARXD.js +6 -0
  34. package/dist/chunk-ORMYARXD.js.map +1 -0
  35. package/dist/chunk-R4O7APIX.js +51 -0
  36. package/dist/chunk-R4O7APIX.js.map +1 -0
  37. package/dist/chunk-ROLKRJWI.js +187 -0
  38. package/dist/chunk-ROLKRJWI.js.map +1 -0
  39. package/dist/chunk-SOTQ3WP5.js +31 -0
  40. package/dist/chunk-SOTQ3WP5.js.map +1 -0
  41. package/dist/chunk-TCFFHTY3.js +97 -0
  42. package/dist/chunk-TCFFHTY3.js.map +1 -0
  43. package/dist/chunk-TS7Y7GEZ.js +7 -0
  44. package/dist/chunk-TS7Y7GEZ.js.map +1 -0
  45. package/dist/chunk-VLGR5PZX.js +3 -0
  46. package/dist/chunk-VLGR5PZX.js.map +1 -0
  47. package/dist/chunk-ZW2PUSVS.js +40 -0
  48. package/dist/chunk-ZW2PUSVS.js.map +1 -0
  49. package/dist/chunk-ZYNBMYJ6.js +392 -0
  50. package/dist/chunk-ZYNBMYJ6.js.map +1 -0
  51. package/dist/cli/nonext-husk.js +3360 -0
  52. package/dist/cli/nonext-husk.js.map +1 -0
  53. package/dist/cli/templates/firestore.rules +393 -0
  54. package/dist/cli/templates/storage.rules +165 -0
  55. package/dist/client/index.d.ts +27 -0
  56. package/dist/client/index.js +15 -0
  57. package/dist/client/index.js.map +1 -0
  58. package/dist/cms-9fSA3uQV.d.ts +158 -0
  59. package/dist/core/index.d.ts +1267 -0
  60. package/dist/core/index.js +12 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/entry-D3kiE8T1.d.ts +163 -0
  63. package/dist/field-Diyf5siF.d.ts +158 -0
  64. package/dist/field-type-D25Vojb0.d.ts +157 -0
  65. package/dist/firebase/index.d.ts +251 -0
  66. package/dist/firebase/index.js +991 -0
  67. package/dist/firebase/index.js.map +1 -0
  68. package/dist/index-5ylaMBUH.d.ts +155 -0
  69. package/dist/index.d.ts +14 -0
  70. package/dist/index.js +17 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/permissions/index.d.ts +101 -0
  73. package/dist/permissions/index.js +7 -0
  74. package/dist/permissions/index.js.map +1 -0
  75. package/dist/registry-CUC25qay.d.ts +59 -0
  76. package/dist/richtext/index.d.ts +85 -0
  77. package/dist/richtext/index.js +127 -0
  78. package/dist/richtext/index.js.map +1 -0
  79. package/dist/schema/index.d.ts +50 -0
  80. package/dist/schema/index.js +5 -0
  81. package/dist/schema/index.js.map +1 -0
  82. package/dist/server/index.d.ts +39 -0
  83. package/dist/server/index.js +25 -0
  84. package/dist/server/index.js.map +1 -0
  85. package/dist/types/index.d.ts +36 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/write-limits-Djmm6L3R.d.ts +215 -0
  89. package/package.json +136 -0
@@ -0,0 +1,393 @@
1
+ import { can } from '../chunk-NPDBQBLW.js';
2
+ import { ROLES } from '../chunk-TS7Y7GEZ.js';
3
+
4
+ // src/admin/session/cookie.ts
5
+ var SESSION_COOKIE = "husk_session";
6
+ function serializeSessionCookie(token, options) {
7
+ return [
8
+ `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
9
+ "Path=/",
10
+ "HttpOnly",
11
+ "SameSite=Lax",
12
+ `Max-Age=${Math.max(0, Math.floor(options.maxAge))}`,
13
+ options.secure ? "Secure" : ""
14
+ ].filter(Boolean).join("; ");
15
+ }
16
+ function clearedSessionCookie(options) {
17
+ return serializeSessionCookie("", { maxAge: 0, secure: options.secure });
18
+ }
19
+ function readSessionCookie(header) {
20
+ if (header === null) return void 0;
21
+ for (const part of header.split(";")) {
22
+ const [name, ...rest] = part.trim().split("=");
23
+ if (name === SESSION_COOKIE) return decodeURIComponent(rest.join("="));
24
+ }
25
+ return void 0;
26
+ }
27
+
28
+ // src/admin/session/token.ts
29
+ var TokenVerificationError = class extends Error {
30
+ name = "TokenVerificationError";
31
+ code;
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.code = code;
35
+ }
36
+ };
37
+ function decodeSegment(segment) {
38
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
39
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
40
+ const binary = atob(padded);
41
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
42
+ return JSON.parse(new TextDecoder().decode(bytes));
43
+ }
44
+ function isRole(value) {
45
+ return typeof value === "string" && ROLES.includes(value);
46
+ }
47
+ function stringOrNull(value) {
48
+ return typeof value === "string" && value.length > 0 ? value : null;
49
+ }
50
+ function decodeIdTokenPayload(token) {
51
+ const parts = token.split(".");
52
+ if (parts.length !== 3 || parts[1] === void 0 || parts[1].length === 0) {
53
+ throw new TokenVerificationError("malformed", "Session token is not a JWT.");
54
+ }
55
+ let payload;
56
+ try {
57
+ payload = decodeSegment(parts[1]);
58
+ } catch {
59
+ throw new TokenVerificationError("malformed", "Session token payload is not JSON.");
60
+ }
61
+ if (typeof payload !== "object" || payload === null) {
62
+ throw new TokenVerificationError(
63
+ "malformed",
64
+ "Session token payload is not an object."
65
+ );
66
+ }
67
+ return payload;
68
+ }
69
+ var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/;
70
+ function createEmulatorTokenVerifier(options) {
71
+ const host = process.env["FIREBASE_AUTH_EMULATOR_HOST"] ?? "";
72
+ if (!LOOPBACK.test(host)) {
73
+ throw new Error(
74
+ "createEmulatorTokenVerifier requires FIREBASE_AUTH_EMULATOR_HOST on a loopback address. Outside the emulator, verify tokens with a privileged verifier instead."
75
+ );
76
+ }
77
+ const now = options.now ?? (() => Math.floor(Date.now() / 1e3));
78
+ const issuer = `https://securetoken.google.com/${options.projectId}`;
79
+ return {
80
+ // async, so a malformed token rejects the promise instead of throwing at the call
81
+ // site: every caller of a verifier awaits it and nobody wraps the call in a try.
82
+ async verify(token) {
83
+ const payload = decodeIdTokenPayload(token);
84
+ if (payload["iss"] !== issuer || payload["aud"] !== options.projectId) {
85
+ throw new TokenVerificationError(
86
+ "untrusted",
87
+ "Session token was issued for a different project."
88
+ );
89
+ }
90
+ const uid = stringOrNull(payload["user_id"] ?? payload["sub"]);
91
+ if (uid === null) {
92
+ throw new TokenVerificationError("malformed", "Session token carries no subject.");
93
+ }
94
+ const expiresAt = payload["exp"];
95
+ if (typeof expiresAt !== "number") {
96
+ throw new TokenVerificationError("malformed", "Session token carries no expiry.");
97
+ }
98
+ if (expiresAt <= now()) {
99
+ throw new TokenVerificationError("expired", "Session has expired.");
100
+ }
101
+ const role = payload["role"];
102
+ if (!isRole(role)) {
103
+ throw new TokenVerificationError(
104
+ "untrusted",
105
+ "Session token carries no Husk role claim."
106
+ );
107
+ }
108
+ return {
109
+ uid,
110
+ email: stringOrNull(payload["email"]),
111
+ displayName: stringOrNull(payload["name"]),
112
+ role,
113
+ disabled: payload["disabled"] === true,
114
+ expiresAt
115
+ };
116
+ }
117
+ };
118
+ }
119
+ function createUnavailableTokenVerifier() {
120
+ return {
121
+ verify() {
122
+ return Promise.reject(
123
+ new TokenVerificationError(
124
+ "untrusted",
125
+ "No session token verifier is configured. Husk will not trust a token it cannot verify."
126
+ )
127
+ );
128
+ }
129
+ };
130
+ }
131
+ function toSessionUser(claims) {
132
+ return {
133
+ uid: claims.uid,
134
+ email: claims.email ?? "",
135
+ displayName: claims.displayName,
136
+ role: claims.role,
137
+ disabled: claims.disabled
138
+ };
139
+ }
140
+
141
+ // src/admin/session/guard.ts
142
+ var DEFAULT_LOGIN_PATH = "/admin/login";
143
+ function resolveAdminAccess(options) {
144
+ const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH;
145
+ if (options.session.status !== "authenticated") {
146
+ const reason = options.session.status === "expired" ? "expired" : "required";
147
+ return {
148
+ outcome: "sign-in",
149
+ location: signInLocation(loginPath, options.pathname, reason),
150
+ reason
151
+ };
152
+ }
153
+ const { user } = options.session;
154
+ if (user.disabled) {
155
+ return {
156
+ outcome: "sign-in",
157
+ location: signInLocation(loginPath, options.pathname, "required"),
158
+ reason: "required"
159
+ };
160
+ }
161
+ const action = options.action;
162
+ if (action !== void 0 && !can(user, action, options.context)) {
163
+ return { outcome: "unauthorized", user, action };
164
+ }
165
+ return { outcome: "allow", user };
166
+ }
167
+ function signInLocation(loginPath, pathname, reason) {
168
+ const params = new URLSearchParams({ next: pathname });
169
+ if (reason === "expired") params.set("reason", "expired");
170
+ return `${loginPath}?${params.toString()}`;
171
+ }
172
+ function safeReturnPath(next, adminBasePath = "/admin") {
173
+ if (typeof next !== "string" || next.length === 0) return adminBasePath;
174
+ if (!next.startsWith(`${adminBasePath}/`) && next !== adminBasePath)
175
+ return adminBasePath;
176
+ if (next.startsWith("//") || next.includes("\\")) return adminBasePath;
177
+ return next;
178
+ }
179
+ async function readSession(cookies, verifier, cookieName) {
180
+ const token = cookies.get(cookieName)?.value;
181
+ if (token === void 0 || token.length === 0) return { status: "anonymous" };
182
+ try {
183
+ return { status: "authenticated", user: toSessionUser(await verifier.verify(token)) };
184
+ } catch (thrown) {
185
+ if (thrown instanceof TokenVerificationError && thrown.code === "expired") {
186
+ return { status: "expired" };
187
+ }
188
+ return {
189
+ status: "invalid",
190
+ reason: thrown instanceof Error ? thrown.message : "Session token was rejected."
191
+ };
192
+ }
193
+ }
194
+
195
+ // src/admin/session/routes.ts
196
+ function createSessionRoutes(options) {
197
+ const now = options.now ?? (() => Math.floor(Date.now() / 1e3));
198
+ return {
199
+ async POST(request) {
200
+ let idToken;
201
+ try {
202
+ idToken = (await request.json()).idToken;
203
+ } catch {
204
+ return problem(400, "Request body must be JSON.");
205
+ }
206
+ if (typeof idToken !== "string" || idToken.length === 0) {
207
+ return problem(400, "Request body must carry an idToken.");
208
+ }
209
+ try {
210
+ const claims = await options.verifier.verify(idToken);
211
+ const cookie = serializeSessionCookie(idToken, {
212
+ maxAge: claims.expiresAt - now(),
213
+ secure: options.secure
214
+ });
215
+ return new Response(null, { status: 204, headers: { "Set-Cookie": cookie } });
216
+ } catch {
217
+ return problem(401, "Session token was rejected.");
218
+ }
219
+ },
220
+ DELETE() {
221
+ return Promise.resolve(
222
+ new Response(null, {
223
+ status: 204,
224
+ headers: { "Set-Cookie": clearedSessionCookie({ secure: options.secure }) }
225
+ })
226
+ );
227
+ }
228
+ };
229
+ }
230
+ function problem(status, message) {
231
+ return Response.json({ error: message }, { status });
232
+ }
233
+
234
+ // src/admin/components/icon-names.ts
235
+ var ICON_NAMES = [
236
+ "brand",
237
+ "create",
238
+ "edit",
239
+ "delete",
240
+ "save",
241
+ "close",
242
+ "search",
243
+ "filter",
244
+ "sort-asc",
245
+ "sort-desc",
246
+ "navigate-into",
247
+ "page-previous",
248
+ "page-next",
249
+ "expand",
250
+ "collapse",
251
+ "selected",
252
+ "indeterminate",
253
+ "more-actions",
254
+ "drag-handle",
255
+ "locked",
256
+ "dashboard",
257
+ "collection",
258
+ "singleton",
259
+ "media",
260
+ "settings",
261
+ "users",
262
+ "permissions",
263
+ "content-types",
264
+ "system",
265
+ "success",
266
+ "warning",
267
+ "error",
268
+ "info",
269
+ "external-link",
270
+ "upload",
271
+ "copy",
272
+ "retry",
273
+ "open-navigation",
274
+ "bold",
275
+ "italic",
276
+ "strikethrough",
277
+ "link",
278
+ "unlink",
279
+ "bullet-list",
280
+ "numbered-list",
281
+ "quote",
282
+ "undo",
283
+ "redo"
284
+ ];
285
+ function isIconName(value) {
286
+ return ICON_NAMES.includes(value);
287
+ }
288
+
289
+ // src/admin/navigation/navigation.ts
290
+ var KIND_ICON = {
291
+ collection: "collection",
292
+ singleton: "singleton"
293
+ };
294
+ function contentTypeIcon(type) {
295
+ if (type.icon !== void 0 && isIconName(type.icon)) return type.icon;
296
+ return KIND_ICON[type.kind];
297
+ }
298
+ function contentTypeLabel(type) {
299
+ if (type.kind === "singleton") return type.label;
300
+ return type.labelPlural ?? type.label;
301
+ }
302
+ var DEVELOPER_ITEMS = [
303
+ {
304
+ key: "content-types",
305
+ label: "Content Types",
306
+ icon: "content-types",
307
+ path: "/developer/content-types",
308
+ action: "manage-schema"
309
+ },
310
+ {
311
+ key: "users",
312
+ label: "Users",
313
+ icon: "users",
314
+ path: "/developer/users",
315
+ action: "manage-users"
316
+ },
317
+ {
318
+ key: "permissions",
319
+ label: "Permissions",
320
+ icon: "permissions",
321
+ path: "/developer/permissions",
322
+ action: "manage-users"
323
+ },
324
+ {
325
+ key: "system",
326
+ label: "System",
327
+ icon: "system",
328
+ path: "/developer/system",
329
+ action: "manage-schema"
330
+ }
331
+ ];
332
+ function buildAdminNavigation({
333
+ registry,
334
+ user,
335
+ basePath = "/admin"
336
+ }) {
337
+ const href = (path) => `${basePath}${path}`;
338
+ const content = registry.types.filter((entry) => can(user, "read", { type: entry.type })).map((entry) => ({
339
+ key: `content:${entry.type.slug}`,
340
+ label: contentTypeLabel(entry.type),
341
+ href: href(`/content/${entry.type.slug}`),
342
+ icon: contentTypeIcon(entry.type)
343
+ }));
344
+ const developer = DEVELOPER_ITEMS.filter(
345
+ (item) => can(user, item.action)
346
+ ).map(({ key, label, icon, path }) => ({ key, label, icon, href: href(path) }));
347
+ const groups = [
348
+ {
349
+ key: "primary",
350
+ items: [
351
+ {
352
+ key: "dashboard",
353
+ label: "Dashboard",
354
+ href: href(""),
355
+ icon: "dashboard",
356
+ exact: true
357
+ }
358
+ ]
359
+ },
360
+ { key: "content", label: "Content", items: content },
361
+ {
362
+ key: "library",
363
+ items: [
364
+ { key: "media", label: "Media", href: href("/media"), icon: "media" },
365
+ { key: "settings", label: "Settings", href: href("/settings"), icon: "settings" }
366
+ ]
367
+ },
368
+ { key: "developer", label: "Developer", items: developer }
369
+ ];
370
+ return groups.filter((group) => group.items.length > 0);
371
+ }
372
+ var ADMIN_TITLE = "Husk";
373
+ function navItemForPath(navigation, pathname) {
374
+ let best;
375
+ for (const group of navigation) {
376
+ for (const item of group.items) {
377
+ if (!isNavItemActive(item, pathname)) continue;
378
+ if (best === void 0 || item.href.length > best.href.length) best = item;
379
+ }
380
+ }
381
+ return best;
382
+ }
383
+ function adminPageTitle(navigation, pathname) {
384
+ return navItemForPath(navigation, pathname)?.label;
385
+ }
386
+ function isNavItemActive(item, pathname) {
387
+ if (item.exact === true) return pathname === item.href;
388
+ return pathname === item.href || pathname.startsWith(`${item.href}/`);
389
+ }
390
+
391
+ export { ADMIN_TITLE, DEFAULT_LOGIN_PATH, SESSION_COOKIE, TokenVerificationError, adminPageTitle, buildAdminNavigation, clearedSessionCookie, contentTypeIcon, contentTypeLabel, createEmulatorTokenVerifier, createSessionRoutes, createUnavailableTokenVerifier, decodeIdTokenPayload, isNavItemActive, navItemForPath, readSession, readSessionCookie, resolveAdminAccess, safeReturnPath, serializeSessionCookie, toSessionUser };
392
+ //# sourceMappingURL=index.js.map
393
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/admin/session/cookie.ts","../../src/admin/session/token.ts","../../src/admin/session/guard.ts","../../src/admin/session/routes.ts","../../src/admin/components/icon-names.ts","../../src/admin/navigation/navigation.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The session cookie.\n *\n * `httpOnly` because no admin screen has any business reading the token from script: the\n * browser holds a Firebase session of its own for writes, and the cookie exists purely so\n * the server can answer \"who is this\" before it renders.\n */\nexport const SESSION_COOKIE = \"husk_session\"\n\nexport interface SessionCookieOptions {\n /** Seconds. Matched to the token expiry, so the cookie dies when the token does. */\n maxAge: number\n /** Off only for plain-HTTP localhost; anything else must be https. */\n secure: boolean\n}\n\nexport function serializeSessionCookie(\n token: string,\n options: SessionCookieOptions,\n): string {\n return [\n `${SESSION_COOKIE}=${encodeURIComponent(token)}`,\n \"Path=/\",\n \"HttpOnly\",\n \"SameSite=Lax\",\n `Max-Age=${Math.max(0, Math.floor(options.maxAge))}`,\n options.secure ? \"Secure\" : \"\",\n ]\n .filter(Boolean)\n .join(\"; \")\n}\n\nexport function clearedSessionCookie(options: { secure: boolean }): string {\n return serializeSessionCookie(\"\", { maxAge: 0, secure: options.secure })\n}\n\n/** Reads the cookie out of a raw `Cookie` header, for the route handlers. */\nexport function readSessionCookie(header: string | null): string | undefined {\n if (header === null) return undefined\n\n for (const part of header.split(\";\")) {\n const [name, ...rest] = part.trim().split(\"=\")\n if (name === SESSION_COOKIE) return decodeURIComponent(rest.join(\"=\"))\n }\n\n return undefined\n}\n","import type { AuthUser, Role } from \"../../types/auth.js\"\nimport { ROLES } from \"../../types/auth.js\"\n\n/**\n * Turning a Firebase ID token into a server-side session.\n *\n * Firebase client auth state lives in the browser, so a Next server component learns who\n * the visitor is from a cookie and nothing else. That cookie carries an ID token, and an\n * ID token is only worth what its verification is worth.\n *\n * Verification is a seam, exactly like `ClaimSync` in src/firebase/claims.ts, because this\n * repository has no Admin SDK and no service account:\n *\n * - `createEmulatorTokenVerifier` checks issuer, audience, subject, expiry and the role\n * claim, and does NOT check the signature. Emulator tokens are unsigned (`alg: none`),\n * so there is no signature to check. It refuses to run outside an emulator setup.\n * - `createUnavailableTokenVerifier` refuses every token, loudly. It is what a deployment\n * gets until it plugs in a real verifier.\n *\n * What the emulator verifier does not cover, stated plainly: anyone who can set the cookie\n * can mint a session, because an unsigned token is forgeable by hand. It is a development\n * path, not a security boundary. See .docs/decisions/2026-09-18-server-session-seam.md.\n */\n\nexport type TokenErrorCode = \"malformed\" | \"expired\" | \"untrusted\"\n\nexport class TokenVerificationError extends Error {\n override readonly name = \"TokenVerificationError\"\n readonly code: TokenErrorCode\n\n constructor(code: TokenErrorCode, message: string) {\n super(message)\n this.code = code\n }\n}\n\n/** The claims a Husk session needs, already narrowed to the shapes the product uses. */\nexport interface SessionClaims {\n uid: string\n email: string | null\n displayName: string | null\n role: Role\n disabled: boolean\n /** Seconds since the epoch, as the token states it. */\n expiresAt: number\n}\n\nexport interface TokenVerifier {\n verify(token: string): Promise<SessionClaims>\n}\n\nfunction decodeSegment(segment: string): unknown {\n // base64url, and JWT segments drop the padding. Decoded through TextDecoder rather than\n // atob alone, because a display name is routinely not ASCII.\n const base64 = segment.replace(/-/g, \"+\").replace(/_/g, \"/\")\n const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), \"=\")\n const binary = atob(padded)\n const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))\n return JSON.parse(new TextDecoder().decode(bytes))\n}\n\nfunction isRole(value: unknown): value is Role {\n return typeof value === \"string\" && (ROLES as readonly string[]).includes(value)\n}\n\nfunction stringOrNull(value: unknown): string | null {\n return typeof value === \"string\" && value.length > 0 ? value : null\n}\n\n/**\n * Reads the payload without checking anything about its authenticity.\n *\n * Exported because the verifiers and the tests both need it, and because naming it this\n * bluntly is the point: a decoded token is a claim, not a fact.\n */\nexport function decodeIdTokenPayload(token: string): Record<string, unknown> {\n const parts = token.split(\".\")\n if (parts.length !== 3 || parts[1] === undefined || parts[1].length === 0) {\n throw new TokenVerificationError(\"malformed\", \"Session token is not a JWT.\")\n }\n\n let payload: unknown\n try {\n payload = decodeSegment(parts[1])\n } catch {\n throw new TokenVerificationError(\"malformed\", \"Session token payload is not JSON.\")\n }\n\n if (typeof payload !== \"object\" || payload === null) {\n throw new TokenVerificationError(\n \"malformed\",\n \"Session token payload is not an object.\",\n )\n }\n\n return payload as Record<string, unknown>\n}\n\nexport interface EmulatorTokenVerifierOptions {\n projectId: string\n /** Seconds since the epoch. Injected so expiry is testable without waiting. */\n now?: () => number\n}\n\nconst LOOPBACK = /^(localhost|127\\.0\\.0\\.1|\\[::1\\]|::1)(:\\d+)?$/\n\n/**\n * Refuses to exist outside an emulator setup.\n *\n * The check is the same one the seed script makes: if `FIREBASE_AUTH_EMULATOR_HOST` does\n * not point at loopback, this process is talking to a real project and an unsigned-token\n * verifier must not be part of it.\n */\nexport function createEmulatorTokenVerifier(\n options: EmulatorTokenVerifierOptions,\n): TokenVerifier {\n const host = process.env[\"FIREBASE_AUTH_EMULATOR_HOST\"] ?? \"\"\n if (!LOOPBACK.test(host)) {\n throw new Error(\n \"createEmulatorTokenVerifier requires FIREBASE_AUTH_EMULATOR_HOST on a loopback \" +\n \"address. Outside the emulator, verify tokens with a privileged verifier instead.\",\n )\n }\n\n const now = options.now ?? (() => Math.floor(Date.now() / 1000))\n const issuer = `https://securetoken.google.com/${options.projectId}`\n\n return {\n // async, so a malformed token rejects the promise instead of throwing at the call\n // site: every caller of a verifier awaits it and nobody wraps the call in a try.\n async verify(token) {\n const payload = decodeIdTokenPayload(token)\n\n if (payload[\"iss\"] !== issuer || payload[\"aud\"] !== options.projectId) {\n throw new TokenVerificationError(\n \"untrusted\",\n \"Session token was issued for a different project.\",\n )\n }\n\n const uid = stringOrNull(payload[\"user_id\"] ?? payload[\"sub\"])\n if (uid === null) {\n throw new TokenVerificationError(\"malformed\", \"Session token carries no subject.\")\n }\n\n const expiresAt = payload[\"exp\"]\n if (typeof expiresAt !== \"number\") {\n throw new TokenVerificationError(\"malformed\", \"Session token carries no expiry.\")\n }\n if (expiresAt <= now()) {\n throw new TokenVerificationError(\"expired\", \"Session has expired.\")\n }\n\n // A session without a role claim can read nothing: firestore.rules requires a known\n // role, so accepting one here would render an admin the database refuses to serve.\n const role = payload[\"role\"]\n if (!isRole(role)) {\n throw new TokenVerificationError(\n \"untrusted\",\n \"Session token carries no Husk role claim.\",\n )\n }\n\n return {\n uid,\n email: stringOrNull(payload[\"email\"]),\n displayName: stringOrNull(payload[\"name\"]),\n role,\n disabled: payload[\"disabled\"] === true,\n expiresAt,\n }\n },\n }\n}\n\n/** The honest default: no verifier configured means no session is accepted. */\nexport function createUnavailableTokenVerifier(): TokenVerifier {\n return {\n verify() {\n return Promise.reject(\n new TokenVerificationError(\n \"untrusted\",\n \"No session token verifier is configured. Husk will not trust a token it \" +\n \"cannot verify.\",\n ),\n )\n },\n }\n}\n\nexport function toSessionUser(claims: SessionClaims): AuthUser {\n return {\n uid: claims.uid,\n email: claims.email ?? \"\",\n displayName: claims.displayName,\n role: claims.role,\n disabled: claims.disabled,\n }\n}\n","import { can } from \"../../permissions/can.js\"\nimport type { PermissionContext } from \"../../permissions/can.js\"\nimport type { Action, AuthUser } from \"../../types/auth.js\"\n\nimport { TokenVerificationError, toSessionUser } from \"./token.js\"\nimport type { TokenVerifier } from \"./token.js\"\n\n/**\n * The `/admin` route guard, as a pure function.\n *\n * It returns a decision instead of calling `redirect()` so it can be tested without a\n * Next runtime, and so the host app stays the only place that knows how its framework\n * navigates. The caller is expected to act on every outcome; a decision that is read and\n * ignored is a guard that does not exist.\n *\n * This is a presentation guard. The adapter and the security rules refuse the same\n * actions independently, which is what actually protects the data.\n */\nexport type SessionState =\n | { status: \"authenticated\"; user: AuthUser }\n | { status: \"anonymous\" }\n | { status: \"expired\" }\n | { status: \"invalid\"; reason: string }\n\nexport type AdminAccess =\n | { outcome: \"allow\"; user: AuthUser }\n | { outcome: \"sign-in\"; location: string; reason: \"required\" | \"expired\" }\n | { outcome: \"unauthorized\"; user: AuthUser; action: Action }\n\nexport const DEFAULT_LOGIN_PATH = \"/admin/login\"\n\nexport interface ResolveAccessOptions {\n session: SessionState\n /** Where the visitor was going, so the login screen can send them back. */\n pathname: string\n /** The permission the route needs. Omitted means \"any signed-in user may be here\". */\n action?: Action\n context?: PermissionContext\n loginPath?: string\n}\n\nexport function resolveAdminAccess(options: ResolveAccessOptions): AdminAccess {\n const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH\n\n if (options.session.status !== \"authenticated\") {\n // An expired session and a forged one both end at the login screen, but only expiry\n // is worth explaining: \"invalid\" would be telling an attacker how far they got.\n const reason = options.session.status === \"expired\" ? \"expired\" : \"required\"\n return {\n outcome: \"sign-in\",\n location: signInLocation(loginPath, options.pathname, reason),\n reason,\n }\n }\n\n const { user } = options.session\n\n if (user.disabled) {\n return {\n outcome: \"sign-in\",\n location: signInLocation(loginPath, options.pathname, \"required\"),\n reason: \"required\",\n }\n }\n\n const action = options.action\n if (action !== undefined && !can(user, action, options.context)) {\n return { outcome: \"unauthorized\", user, action }\n }\n\n return { outcome: \"allow\", user }\n}\n\nfunction signInLocation(\n loginPath: string,\n pathname: string,\n reason: \"required\" | \"expired\",\n): string {\n const params = new URLSearchParams({ next: pathname })\n if (reason === \"expired\") params.set(\"reason\", \"expired\")\n return `${loginPath}?${params.toString()}`\n}\n\n/**\n * Where to go after signing in.\n *\n * Only a path inside the admin is accepted. An open redirect here would turn the login\n * screen into a way to bounce a trusted user anywhere, so anything with a scheme, a host,\n * a backslash, or a different prefix falls back to the admin root.\n */\nexport function safeReturnPath(\n next: string | null | undefined,\n adminBasePath = \"/admin\",\n): string {\n if (typeof next !== \"string\" || next.length === 0) return adminBasePath\n if (!next.startsWith(`${adminBasePath}/`) && next !== adminBasePath)\n return adminBasePath\n if (next.startsWith(\"//\") || next.includes(\"\\\\\")) return adminBasePath\n return next\n}\n\n/** Minimal cookie reader, satisfied by the object Next's `cookies()` returns. */\nexport interface CookieReader {\n get(name: string): { value: string } | undefined\n}\n\n/**\n * Turns the cookie into a session state, never throwing: a bad token is a signed-out\n * visitor, not a 500.\n */\nexport async function readSession(\n cookies: CookieReader,\n verifier: TokenVerifier,\n cookieName: string,\n): Promise<SessionState> {\n const token = cookies.get(cookieName)?.value\n if (token === undefined || token.length === 0) return { status: \"anonymous\" }\n\n try {\n return { status: \"authenticated\", user: toSessionUser(await verifier.verify(token)) }\n } catch (thrown) {\n if (thrown instanceof TokenVerificationError && thrown.code === \"expired\") {\n return { status: \"expired\" }\n }\n return {\n status: \"invalid\",\n reason: thrown instanceof Error ? thrown.message : \"Session token was rejected.\",\n }\n }\n}\n","import { clearedSessionCookie, serializeSessionCookie } from \"./cookie.js\"\nimport type { TokenVerifier } from \"./token.js\"\n\n/**\n * The two endpoints that move a browser session to the server and back.\n *\n * They are plain `Request` to `Response` handlers, not Next route handlers, so the package\n * keeps its framework surface at zero and the host app exports them in three lines.\n *\n * POST: the browser has just signed in with the Firebase SDK and posts the fresh ID token.\n * The token is verified before it is stored, so an unverifiable token never becomes a\n * cookie that later code treats as proof.\n *\n * DELETE: sign-out. The cookie is cleared unconditionally, including for a token that no\n * longer verifies, because refusing to sign someone out is the one failure mode worse\n * than signing them out too eagerly.\n */\nexport interface SessionRouteOptions {\n verifier: TokenVerifier\n /** Off only for plain-HTTP localhost. */\n secure: boolean\n now?: () => number\n}\n\nexport interface SessionRoutes {\n POST(request: Request): Promise<Response>\n DELETE(): Promise<Response>\n}\n\nexport function createSessionRoutes(options: SessionRouteOptions): SessionRoutes {\n const now = options.now ?? (() => Math.floor(Date.now() / 1000))\n\n return {\n async POST(request) {\n let idToken: unknown\n try {\n idToken = ((await request.json()) as { idToken?: unknown }).idToken\n } catch {\n return problem(400, \"Request body must be JSON.\")\n }\n\n if (typeof idToken !== \"string\" || idToken.length === 0) {\n return problem(400, \"Request body must carry an idToken.\")\n }\n\n try {\n const claims = await options.verifier.verify(idToken)\n const cookie = serializeSessionCookie(idToken, {\n maxAge: claims.expiresAt - now(),\n secure: options.secure,\n })\n return new Response(null, { status: 204, headers: { \"Set-Cookie\": cookie } })\n } catch {\n // Deliberately unspecific: the browser already knows what it sent.\n return problem(401, \"Session token was rejected.\")\n }\n },\n\n DELETE() {\n return Promise.resolve(\n new Response(null, {\n status: 204,\n headers: { \"Set-Cookie\": clearedSessionCookie({ secure: options.secure }) },\n }),\n )\n },\n }\n}\n\nfunction problem(status: number, message: string): Response {\n return Response.json({ error: message }, { status })\n}\n","/**\n * The icon vocabulary as plain data, with no Lucide import.\n *\n * `icon.tsx` maps every name here to a glyph, and the admin only ever asks for a name. The\n * list lives in its own module because things that are not components need it: the\n * navigation builder resolves a content type's declared icon against this vocabulary, and\n * it runs on the server, where dragging a React component module in would put the icon set\n * into a bundle that never renders one.\n *\n * Canonical source: .docs/styleguide/05-icons.md section 1.3.\n */\nexport const ICON_NAMES = [\n \"brand\",\n \"create\",\n \"edit\",\n \"delete\",\n \"save\",\n \"close\",\n \"search\",\n \"filter\",\n \"sort-asc\",\n \"sort-desc\",\n \"navigate-into\",\n \"page-previous\",\n \"page-next\",\n \"expand\",\n \"collapse\",\n \"selected\",\n \"indeterminate\",\n \"more-actions\",\n \"drag-handle\",\n \"locked\",\n \"dashboard\",\n \"collection\",\n \"singleton\",\n \"media\",\n \"settings\",\n \"users\",\n \"permissions\",\n \"content-types\",\n \"system\",\n \"success\",\n \"warning\",\n \"error\",\n \"info\",\n \"external-link\",\n \"upload\",\n \"copy\",\n \"retry\",\n \"open-navigation\",\n \"bold\",\n \"italic\",\n \"strikethrough\",\n \"link\",\n \"unlink\",\n \"bullet-list\",\n \"numbered-list\",\n \"quote\",\n \"undo\",\n \"redo\",\n] as const\n\nexport type IconName = (typeof ICON_NAMES)[number]\n\n/**\n * Whether a string names an icon in the vocabulary.\n *\n * A database-defined content type carries whatever icon name an editor typed into the\n * Schema Builder, so the name is untrusted input and a lookup against it has to be able to\n * fail without taking the sidebar down.\n */\nexport function isIconName(value: string): value is IconName {\n return (ICON_NAMES as readonly string[]).includes(value)\n}\n","import type { Registry } from \"../../core/registry.js\"\nimport { can } from \"../../permissions/can.js\"\nimport type { Action, AuthUser } from \"../../types/auth.js\"\nimport type { ContentTypeKind, ContentTypeLike } from \"../../types/content-type.js\"\nimport { isIconName } from \"../components/icon-names.js\"\nimport type { IconName } from \"../components/icon-names.js\"\n\n/**\n * The sidebar as data. Canonical source: .docs/styleguide/03-layout.md section 2.1.\n *\n * The whole point of the registry is that nothing in the interface knows the name of a\n * content type. This builder is the only place navigation is decided, it reads the\n * registry and `can()`, and it returns plain serializable data so a server component can\n * compute it and hand it to the client shell. Adding a content type therefore changes\n * data and nothing else: no route table, no switch, no new file.\n *\n * It is deliberately free of React, so the server entry can export it without pulling a\n * component into a module graph that never renders one.\n */\n\nexport interface AdminNavItem {\n /** Stable across renders and unique inside the whole navigation. */\n key: string\n label: string\n href: string\n icon: IconName\n /**\n * Active on an exact path match only. The dashboard owns the admin root, so every\n * deeper route would otherwise light it up as well.\n */\n exact?: boolean\n}\n\nexport interface AdminNavGroup {\n key: string\n /** Absent for the leading group and the static group, which carry no heading. */\n label?: string\n items: readonly AdminNavItem[]\n}\n\nexport interface BuildNavigationOptions {\n registry: Registry\n user: AuthUser\n /** Where the admin is mounted in the host app. */\n basePath?: string\n}\n\nconst KIND_ICON: Record<ContentTypeKind, IconName> = {\n collection: \"collection\",\n singleton: \"singleton\",\n}\n\n/**\n * The icon a content type renders with.\n *\n * `type.icon` is untrusted: a database-defined type carries whatever an editor typed into\n * the Schema Builder, and a code-defined one carries whatever the customer wrote. An\n * unknown name falls back to the icon for the kind, so the row still reads as a collection\n * or a singleton. Neither a crash nor a blank gap is an acceptable answer to a typo.\n */\nexport function contentTypeIcon(type: ContentTypeLike): IconName {\n if (type.icon !== undefined && isIconName(type.icon)) return type.icon\n return KIND_ICON[type.kind]\n}\n\n/** A collection is listed by its plural; a singleton is one document and has no plural. */\nexport function contentTypeLabel(type: ContentTypeLike): string {\n if (type.kind === \"singleton\") return type.label\n return type.labelPlural ?? type.label\n}\n\n/** The Developer area, item by item, each with the permission that reveals it. */\nconst DEVELOPER_ITEMS: readonly (Omit<AdminNavItem, \"href\"> & {\n path: string\n action: Action\n})[] = [\n {\n key: \"content-types\",\n label: \"Content Types\",\n icon: \"content-types\",\n path: \"/developer/content-types\",\n action: \"manage-schema\",\n },\n {\n key: \"users\",\n label: \"Users\",\n icon: \"users\",\n path: \"/developer/users\",\n action: \"manage-users\",\n },\n {\n key: \"permissions\",\n label: \"Permissions\",\n icon: \"permissions\",\n path: \"/developer/permissions\",\n action: \"manage-users\",\n },\n {\n key: \"system\",\n label: \"System\",\n icon: \"system\",\n path: \"/developer/system\",\n action: \"manage-schema\",\n },\n]\n\nexport function buildAdminNavigation({\n registry,\n user,\n basePath = \"/admin\",\n}: BuildNavigationOptions): readonly AdminNavGroup[] {\n const href = (path: string) => `${basePath}${path}`\n\n const content: AdminNavItem[] = registry.types\n // A per-type permission matrix can take read away from a role, and a row that leads to\n // a screen the adapter will refuse is worse than no row.\n .filter((entry) => can(user, \"read\", { type: entry.type }))\n .map((entry) => ({\n key: `content:${entry.type.slug}`,\n label: contentTypeLabel(entry.type),\n href: href(`/content/${entry.type.slug}`),\n icon: contentTypeIcon(entry.type),\n }))\n\n const developer: AdminNavItem[] = DEVELOPER_ITEMS.filter((item) =>\n can(user, item.action),\n ).map(({ key, label, icon, path }) => ({ key, label, icon, href: href(path) }))\n\n const groups: AdminNavGroup[] = [\n {\n key: \"primary\",\n items: [\n {\n key: \"dashboard\",\n label: \"Dashboard\",\n href: href(\"\"),\n icon: \"dashboard\",\n exact: true,\n },\n ],\n },\n { key: \"content\", label: \"Content\", items: content },\n {\n key: \"library\",\n items: [\n { key: \"media\", label: \"Media\", href: href(\"/media\"), icon: \"media\" },\n { key: \"settings\", label: \"Settings\", href: href(\"/settings\"), icon: \"settings\" },\n ],\n },\n { key: \"developer\", label: \"Developer\", items: developer },\n ]\n\n // An empty group would render a heading over nothing. The Developer group disappearing\n // for an editor is the visible consequence; hiding it is presentation only, and the\n // adapter and the security rules refuse the same actions on their own.\n return groups.filter((group) => group.items.length > 0)\n}\n\n/** The product name, and the suffix every admin document title carries. */\nexport const ADMIN_TITLE = \"Husk\"\n\n/**\n * The navigation item a route belongs to, or undefined outside the navigation.\n *\n * The longest matching href wins, so a nested route resolves to the deepest item that\n * claims it rather than to whichever one the groups happened to list first.\n */\nexport function navItemForPath(\n navigation: readonly AdminNavGroup[],\n pathname: string,\n): AdminNavItem | undefined {\n let best: AdminNavItem | undefined\n\n for (const group of navigation) {\n for (const item of group.items) {\n if (!isNavItemActive(item, pathname)) continue\n if (best === undefined || item.href.length > best.href.length) best = item\n }\n }\n\n return best\n}\n\n/**\n * The document title for an admin route, taken from the same data that drew the sidebar.\n *\n * Titles are generated for the same reason screens are: a content type that appears in the\n * navigation gets a correct browser tab without anybody writing a route-specific string,\n * and a renamed type renames its tab. Returns undefined outside the navigation, which is\n * the caller's cue to fall back to the layout's default title.\n */\nexport function adminPageTitle(\n navigation: readonly AdminNavGroup[],\n pathname: string,\n): string | undefined {\n return navItemForPath(navigation, pathname)?.label\n}\n\n/** Whether a navigation item matches the route the browser is on. */\nexport function isNavItemActive(item: AdminNavItem, pathname: string): boolean {\n if (item.exact === true) return pathname === item.href\n return pathname === item.href || pathname.startsWith(`${item.href}/`)\n}\n"]}