@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,320 @@
1
+ import { i as EntryStatus, f as Entry, c as ContentTypeLike } from './entry-D3kiE8T1.js';
2
+ import { M as MediaItem } from './field-Diyf5siF.js';
3
+
4
+ /**
5
+ * The seam between the CMS and its storage.
6
+ *
7
+ * Everything above this interface (the admin, the SDK, the registry, the validator) is
8
+ * written against `CmsAdapter` and knows nothing about Firestore. Everything below it lives
9
+ * under `src/firebase/**`, which is the only place the Firebase SDK may be imported. If a
10
+ * screen or an SDK method needs something this interface does not offer, the interface
11
+ * grows; it is never bypassed.
12
+ *
13
+ * Values crossing this boundary are already in their read shape: timestamps are ISO 8601
14
+ * strings, references are ids, and field values match what `FieldValues` describes. The
15
+ * converters on the Firestore side own the translation in both directions.
16
+ */
17
+ type FilterOperator = "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not-in" | "array-contains" | "array-contains-any";
18
+ interface EntryFilter {
19
+ field: string;
20
+ op: FilterOperator;
21
+ value: unknown;
22
+ }
23
+ interface SortSpec {
24
+ field: string;
25
+ direction: "asc" | "desc";
26
+ }
27
+ interface ListQuery {
28
+ filters?: readonly EntryFilter[];
29
+ sort?: SortSpec;
30
+ /**
31
+ * Free text matched against the content type's `titleField`, as a prefix.
32
+ *
33
+ * What it does, exactly, because a search box that promises more than it delivers is
34
+ * worse than one that says what it is: the adapter turns the term into a range scan
35
+ * (`>= term`, `<= term + U+F8FF`) over the title field. That matches from the beginning
36
+ * of the title and it is case sensitive, because Firestore compares strings by their
37
+ * UTF-8 bytes and offers no substring, no fuzzy and no case-insensitive operator.
38
+ * "Portfolio" is found by "Port", not by "port" and not by "folio".
39
+ *
40
+ * Two consequences a caller has to know about:
41
+ *
42
+ * - A range filter forces the first ordering onto the field it ranges over, so a search
43
+ * is always ordered by title ascending and `sort` is ignored while one is active.
44
+ * - A content type with no `titleField` has nothing to match against, so the adapter
45
+ * rejects the query instead of silently returning everything. Ask `canSearch()` in
46
+ * core/list-view.ts before offering the box.
47
+ */
48
+ search?: string;
49
+ /** Page size. The adapter caps it; see DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE. */
50
+ limit?: number;
51
+ /** Opaque cursor from a previous page. Never constructed by a caller. */
52
+ cursor?: string | null;
53
+ /**
54
+ * `"any"` reads every status and is the admin default. The public SDK passes
55
+ * `"published"`, which is what keeps drafts off a customer's website.
56
+ */
57
+ status?: EntryStatus | "any";
58
+ }
59
+ interface Page<TItem> {
60
+ items: readonly TItem[];
61
+ /** Pass back as `ListQuery.cursor` to read the next page. Null when the page is the last. */
62
+ cursor: string | null;
63
+ hasMore: boolean;
64
+ }
65
+ interface MediaQuery {
66
+ limit?: number;
67
+ cursor?: string | null;
68
+ /** MIME prefix, for example `image/`. */
69
+ type?: string;
70
+ }
71
+ interface MediaInput {
72
+ name: string;
73
+ type: string;
74
+ size: number;
75
+ url: string;
76
+ path: string;
77
+ alt?: string | null;
78
+ width?: number | null;
79
+ height?: number | null;
80
+ }
81
+ /**
82
+ * What the media library occupies, summed from the metadata documents.
83
+ *
84
+ * `bytes` is the sum of the `size` recorded on each media document, not a reading of the
85
+ * Storage bucket itself. Everything Husk uploads records its size, so the two agree; an
86
+ * object placed in the bucket by something other than Husk is not counted, because Husk
87
+ * has no document for it. Stated rather than hidden: a dashboard figure that silently
88
+ * means something narrower than its label is worse than no figure.
89
+ */
90
+ interface MediaUsage {
91
+ /** Media documents. */
92
+ count: number;
93
+ /** Total bytes across those documents. */
94
+ bytes: number;
95
+ }
96
+ /**
97
+ * One entry that references a media item.
98
+ *
99
+ * `title` is what the warning prints. It is null when the entry's content type declares no
100
+ * title field, or when the value is empty: the dialog then prints the id, which is the one
101
+ * name that is always correct.
102
+ */
103
+ interface MediaUsageReference {
104
+ contentType: string;
105
+ entryId: string;
106
+ title: string | null;
107
+ }
108
+ interface MediaUsageQuery {
109
+ /** How many referencing entries to name. Defaults to MEDIA_USAGE_LIMIT. */
110
+ limit?: number;
111
+ }
112
+ /**
113
+ * How many referencing entries a usage lookup names.
114
+ *
115
+ * A delete warning exists to be read. Twenty-five names already say "this is used a lot",
116
+ * and the caller compares the length against this number to know the list was cut rather
117
+ * than complete.
118
+ */
119
+ declare const MEDIA_USAGE_LIMIT = 25;
120
+ /** What a reference refresh managed to rewrite, and what it did not. */
121
+ interface MediaRefreshResult {
122
+ updated: number;
123
+ /** Entries whose denormalized url could not be rewritten. Named, never swallowed. */
124
+ failed: readonly MediaUsageReference[];
125
+ }
126
+ interface MediaAdapter {
127
+ list(query?: MediaQuery): Promise<Page<MediaItem>>;
128
+ getById(mediaId: string): Promise<MediaItem | null>;
129
+ getMany(mediaIds: readonly string[]): Promise<readonly MediaItem[]>;
130
+ /**
131
+ * Allocates a media id without writing anything.
132
+ *
133
+ * The upload path needs the id before the document exists, because the Storage object
134
+ * path contains it and the bytes are written first. Nothing is reserved on the server:
135
+ * the id is generated locally, and an id that is never used costs nothing.
136
+ */
137
+ reserveId(): string;
138
+ /**
139
+ * Writes the metadata document. With `mediaId`, it is written at that id, which is how
140
+ * an upload's bytes and its document end up naming each other.
141
+ */
142
+ create(input: MediaInput, mediaId?: string): Promise<MediaItem>;
143
+ update(mediaId: string, patch: Partial<MediaInput>): Promise<MediaItem>;
144
+ delete(mediaId: string): Promise<void>;
145
+ /**
146
+ * Entries referencing this media item, so a delete can warn instead of breaking pages.
147
+ *
148
+ * Answered from the `mediaRefs` reverse index each entry write maintains, not from a
149
+ * scan: see core/media-references.ts for what that costs and where it is blind.
150
+ */
151
+ usages(mediaId: string, query?: MediaUsageQuery): Promise<readonly MediaUsageReference[]>;
152
+ /**
153
+ * Rewrites the denormalized `url` on every entry that references this media item.
154
+ *
155
+ * Only replace needs it. Overwriting the bytes of an existing item mints a new download
156
+ * URL, and the copy of the old one denormalized into each entry would otherwise stay
157
+ * until that entry is next saved. `mediaId` remains the source of truth either way, so a
158
+ * refresh that fails degrades a stale convenience rather than breaking a reference.
159
+ */
160
+ refreshReferences(mediaId: string, url: string): Promise<MediaRefreshResult>;
161
+ /**
162
+ * Count and total size of the whole library, for the dashboard's storage figure.
163
+ * One aggregate read, not a walk over every page of media.
164
+ */
165
+ usage(): Promise<MediaUsage>;
166
+ }
167
+ /**
168
+ * Schema operations act on `schemas/`, never on entries. A locked or code-defined type is
169
+ * rejected here as well as in the UI, so bypassing the Schema Builder changes nothing.
170
+ */
171
+ interface SchemaAdapter {
172
+ list(): Promise<readonly ContentTypeLike[]>;
173
+ get(slug: string): Promise<ContentTypeLike | null>;
174
+ create(type: ContentTypeLike): Promise<ContentTypeLike>;
175
+ update(slug: string, type: ContentTypeLike): Promise<ContentTypeLike>;
176
+ delete(slug: string): Promise<void>;
177
+ /**
178
+ * Live view of every database-defined type, so the sidebar and the routes follow a
179
+ * structure change without a rebuild and without a reload.
180
+ *
181
+ * One listener for the whole collection, feeding `RegistryStore.setDatabaseTypes`. What
182
+ * it costs is written down on that interface. `onError` is called rather than thrown,
183
+ * because a listener that dies silently leaves an admin quietly stale.
184
+ */
185
+ watch(listener: (types: readonly ContentTypeLike[]) => void, onError?: (error: Error) => void): () => void;
186
+ }
187
+ interface FieldPurgeOptions {
188
+ /** How many entries to clear in one call. Capped at MAX_FIELD_PURGE. */
189
+ limit?: number;
190
+ }
191
+ interface FieldPurgeResult {
192
+ /** Entries whose stored value was deleted by this call. */
193
+ purged: number;
194
+ /** True when the cap was hit and another call is needed to finish. */
195
+ remaining: boolean;
196
+ }
197
+ /** One call clears at most this many entries, so a purge cannot become an unbounded write. */
198
+ declare const MAX_FIELD_PURGE = 250;
199
+ /**
200
+ * The stored values of a **removed** field, which is the only data the Schema Builder is
201
+ * ever allowed to reach.
202
+ *
203
+ * It is a separate interface, and not two more methods on `SchemaAdapter`, so the M8
204
+ * guardrail is structural rather than a comment: `SchemaAdapter` has no method that can
205
+ * touch an entry, and this one refuses any key the content type still declares. The Schema
206
+ * Builder writes schemas; purging is a different, separately named, separately confirmed
207
+ * operation that happens to be reachable from the same screen.
208
+ */
209
+ interface FieldDataAdapter {
210
+ /**
211
+ * How many entries of this content type hold a non-null value for this top-level field
212
+ * key, so the removal warning can say "14 entries" rather than "some entries".
213
+ *
214
+ * One count aggregation, not a walk: `where(key, "!=", null)` plus `count()`. Firestore
215
+ * indexes every top-level field automatically, so no composite index is needed, and an
216
+ * aggregation is billed per thousand matched index entries rather than per document.
217
+ *
218
+ * Two limits, stated rather than hidden. A key nested inside a repeater row is not a
219
+ * top-level field and cannot be counted this way, so `count` rejects one instead of
220
+ * answering zero. And an entry written before the field existed simply has no such key,
221
+ * which Firestore treats as not matching, which is the answer the warning wants.
222
+ */
223
+ count(slug: string, key: string): Promise<number>;
224
+ /**
225
+ * Deletes the stored values of a removed field. The only destructive schema operation.
226
+ *
227
+ * Refused unless the content type lists the key under `removedFields`: a key that is
228
+ * still live, or that the type has never heard of, is never purged. Admin only, and
229
+ * bounded per call.
230
+ */
231
+ purge(slug: string, key: string, options?: FieldPurgeOptions): Promise<FieldPurgeResult>;
232
+ }
233
+ /**
234
+ * One stored entry as the migration runner sees it: its id, the version it is stamped at,
235
+ * and its values in their stored form.
236
+ *
237
+ * Deliberately not an `Entry`. Reading an entry runs it through the content type's field
238
+ * specs, and the whole point of a migration is that the stored document does not match
239
+ * those specs yet, so converting it first would either fail or quietly drop the values the
240
+ * migration was written to move.
241
+ */
242
+ interface StoredEntryRecord {
243
+ id: string;
244
+ schemaVersion: number;
245
+ values: Record<string, unknown>;
246
+ }
247
+ interface MigrationWrite {
248
+ /** The stored version the transform read. The write is refused if it has moved. */
249
+ expectedVersion: number;
250
+ nextVersion: number;
251
+ }
252
+ /**
253
+ * The storage half of the migration runner. See core/migrations.ts for the runner itself.
254
+ *
255
+ * Kept off `SchemaAdapter` for the same reason `FieldDataAdapter` is: a schema operation
256
+ * must not be able to reach an entry. A migration reaches every entry of a type, which is
257
+ * precisely why it is a separately named, separately permissioned port rather than two
258
+ * more methods on the interface the Schema Builder holds.
259
+ */
260
+ interface MigrationAdapter {
261
+ /** How many entries are stamped below the target version. One aggregation. */
262
+ countBehind(slug: string, targetVersion: number): Promise<number>;
263
+ /** A page of entries stamped below the target version, oldest version first. */
264
+ listBehind(slug: string, targetVersion: number, query?: {
265
+ limit?: number;
266
+ }): Promise<Page<StoredEntryRecord>>;
267
+ /**
268
+ * Writes migrated values and restamps the entry, but only while the stored version is
269
+ * still the one the transform read.
270
+ *
271
+ * Returns false when it has moved, which is a second runner having got there first and
272
+ * is not an error. That conditional is what makes a migration safe to run twice and safe
273
+ * to run from two places at once.
274
+ */
275
+ applyMigration(slug: string, entryId: string, values: Record<string, unknown>, write: MigrationWrite): Promise<boolean>;
276
+ }
277
+ interface SettingsAdapter {
278
+ get(key: string): Promise<Record<string, unknown> | null>;
279
+ set(key: string, value: Record<string, unknown>): Promise<void>;
280
+ }
281
+ interface EntryInputData {
282
+ /** Field values, already validated by the caller and validated again by the adapter. */
283
+ values: Record<string, unknown>;
284
+ status?: EntryStatus;
285
+ }
286
+ interface CmsAdapter {
287
+ list(slug: string, query?: ListQuery): Promise<Page<Entry>>;
288
+ get(slug: string, entryId: string): Promise<Entry | null>;
289
+ /**
290
+ * Looks an entry up by the value of the content type's `slug` field, not by document id.
291
+ *
292
+ * `status` narrows the lookup at the storage layer rather than after it, and on a public
293
+ * website that is not an optimization. A visitor's read is unauthenticated, and the
294
+ * security rules only let an unauthenticated query through when the query itself says
295
+ * `status == "published"`; a lookup that asked for any status and filtered afterwards
296
+ * would be refused outright. Defaults to `"any"`, which is the admin's uniqueness check.
297
+ */
298
+ getBySlug(slug: string, value: string, options?: Pick<ListQuery, "status">): Promise<Entry | null>;
299
+ /** Reads the one entry of a singleton. Rejects a collection. */
300
+ getSingle(slug: string): Promise<Entry | null>;
301
+ create(slug: string, input: EntryInputData): Promise<Entry>;
302
+ update(slug: string, entryId: string, input: EntryInputData): Promise<Entry>;
303
+ delete(slug: string, entryId: string): Promise<void>;
304
+ /**
305
+ * How many entries the same narrowing would return. Takes `search` as well, so the
306
+ * list view's "1-25 of 248" counts the rows the reader is actually paging through.
307
+ */
308
+ count(slug: string, query?: Pick<ListQuery, "filters" | "status" | "search">): Promise<number>;
309
+ readonly media: MediaAdapter;
310
+ readonly schemas: SchemaAdapter;
311
+ readonly fieldData: FieldDataAdapter;
312
+ readonly migrations: MigrationAdapter;
313
+ readonly settings: SettingsAdapter;
314
+ }
315
+ declare const DEFAULT_PAGE_SIZE = 25;
316
+ /** Above this, a list view is the wrong tool and a query is a denial-of-service risk. */
317
+ declare const MAX_PAGE_SIZE = 100;
318
+ declare function normalizeLimit(limit: number | undefined): number;
319
+
320
+ export { type CmsAdapter as C, DEFAULT_PAGE_SIZE as D, type EntryFilter as E, type FieldDataAdapter as F, type ListQuery as L, MAX_FIELD_PURGE as M, type Page as P, type SchemaAdapter as S, type EntryInputData as a, type FieldPurgeOptions as b, type FieldPurgeResult as c, type FilterOperator as d, MAX_PAGE_SIZE as e, MEDIA_USAGE_LIMIT as f, type MediaAdapter as g, type MediaInput as h, type MediaQuery as i, type MediaRefreshResult as j, type MediaUsage as k, type MediaUsageQuery as l, type MediaUsageReference as m, type MigrationAdapter as n, type MigrationWrite as o, type SettingsAdapter as p, type SortSpec as q, type StoredEntryRecord as r, normalizeLimit as s };
@@ -0,0 +1,5 @@
1
+ /* Husk admin stylesheet. Host apps import this once, from "@nonext/husk/admin.css". */
2
+ @import "tailwindcss";
3
+ @import "./tokens.css";
4
+
5
+ @source "../";
@@ -0,0 +1,265 @@
1
+ import { b as PermissionContext } from '../can-Chy7zmx7.js';
2
+ import { e as Role, b as AuthUser, A as Action } from '../auth-B2gBtHl5.js';
3
+ import { R as Registry } from '../registry-CUC25qay.js';
4
+ import { c as ContentTypeLike } from '../entry-D3kiE8T1.js';
5
+ import '../field-Diyf5siF.js';
6
+
7
+ /**
8
+ * The session cookie.
9
+ *
10
+ * `httpOnly` because no admin screen has any business reading the token from script: the
11
+ * browser holds a Firebase session of its own for writes, and the cookie exists purely so
12
+ * the server can answer "who is this" before it renders.
13
+ */
14
+ declare const SESSION_COOKIE = "husk_session";
15
+ interface SessionCookieOptions {
16
+ /** Seconds. Matched to the token expiry, so the cookie dies when the token does. */
17
+ maxAge: number;
18
+ /** Off only for plain-HTTP localhost; anything else must be https. */
19
+ secure: boolean;
20
+ }
21
+ declare function serializeSessionCookie(token: string, options: SessionCookieOptions): string;
22
+ declare function clearedSessionCookie(options: {
23
+ secure: boolean;
24
+ }): string;
25
+ /** Reads the cookie out of a raw `Cookie` header, for the route handlers. */
26
+ declare function readSessionCookie(header: string | null): string | undefined;
27
+
28
+ /**
29
+ * Turning a Firebase ID token into a server-side session.
30
+ *
31
+ * Firebase client auth state lives in the browser, so a Next server component learns who
32
+ * the visitor is from a cookie and nothing else. That cookie carries an ID token, and an
33
+ * ID token is only worth what its verification is worth.
34
+ *
35
+ * Verification is a seam, exactly like `ClaimSync` in src/firebase/claims.ts, because this
36
+ * repository has no Admin SDK and no service account:
37
+ *
38
+ * - `createEmulatorTokenVerifier` checks issuer, audience, subject, expiry and the role
39
+ * claim, and does NOT check the signature. Emulator tokens are unsigned (`alg: none`),
40
+ * so there is no signature to check. It refuses to run outside an emulator setup.
41
+ * - `createUnavailableTokenVerifier` refuses every token, loudly. It is what a deployment
42
+ * gets until it plugs in a real verifier.
43
+ *
44
+ * What the emulator verifier does not cover, stated plainly: anyone who can set the cookie
45
+ * can mint a session, because an unsigned token is forgeable by hand. It is a development
46
+ * path, not a security boundary. See .docs/decisions/2026-09-18-server-session-seam.md.
47
+ */
48
+ type TokenErrorCode = "malformed" | "expired" | "untrusted";
49
+ declare class TokenVerificationError extends Error {
50
+ readonly name = "TokenVerificationError";
51
+ readonly code: TokenErrorCode;
52
+ constructor(code: TokenErrorCode, message: string);
53
+ }
54
+ /** The claims a Husk session needs, already narrowed to the shapes the product uses. */
55
+ interface SessionClaims {
56
+ uid: string;
57
+ email: string | null;
58
+ displayName: string | null;
59
+ role: Role;
60
+ disabled: boolean;
61
+ /** Seconds since the epoch, as the token states it. */
62
+ expiresAt: number;
63
+ }
64
+ interface TokenVerifier {
65
+ verify(token: string): Promise<SessionClaims>;
66
+ }
67
+ /**
68
+ * Reads the payload without checking anything about its authenticity.
69
+ *
70
+ * Exported because the verifiers and the tests both need it, and because naming it this
71
+ * bluntly is the point: a decoded token is a claim, not a fact.
72
+ */
73
+ declare function decodeIdTokenPayload(token: string): Record<string, unknown>;
74
+ interface EmulatorTokenVerifierOptions {
75
+ projectId: string;
76
+ /** Seconds since the epoch. Injected so expiry is testable without waiting. */
77
+ now?: () => number;
78
+ }
79
+ /**
80
+ * Refuses to exist outside an emulator setup.
81
+ *
82
+ * The check is the same one the seed script makes: if `FIREBASE_AUTH_EMULATOR_HOST` does
83
+ * not point at loopback, this process is talking to a real project and an unsigned-token
84
+ * verifier must not be part of it.
85
+ */
86
+ declare function createEmulatorTokenVerifier(options: EmulatorTokenVerifierOptions): TokenVerifier;
87
+ /** The honest default: no verifier configured means no session is accepted. */
88
+ declare function createUnavailableTokenVerifier(): TokenVerifier;
89
+ declare function toSessionUser(claims: SessionClaims): AuthUser;
90
+
91
+ /**
92
+ * The `/admin` route guard, as a pure function.
93
+ *
94
+ * It returns a decision instead of calling `redirect()` so it can be tested without a
95
+ * Next runtime, and so the host app stays the only place that knows how its framework
96
+ * navigates. The caller is expected to act on every outcome; a decision that is read and
97
+ * ignored is a guard that does not exist.
98
+ *
99
+ * This is a presentation guard. The adapter and the security rules refuse the same
100
+ * actions independently, which is what actually protects the data.
101
+ */
102
+ type SessionState = {
103
+ status: "authenticated";
104
+ user: AuthUser;
105
+ } | {
106
+ status: "anonymous";
107
+ } | {
108
+ status: "expired";
109
+ } | {
110
+ status: "invalid";
111
+ reason: string;
112
+ };
113
+ type AdminAccess = {
114
+ outcome: "allow";
115
+ user: AuthUser;
116
+ } | {
117
+ outcome: "sign-in";
118
+ location: string;
119
+ reason: "required" | "expired";
120
+ } | {
121
+ outcome: "unauthorized";
122
+ user: AuthUser;
123
+ action: Action;
124
+ };
125
+ declare const DEFAULT_LOGIN_PATH = "/admin/login";
126
+ interface ResolveAccessOptions {
127
+ session: SessionState;
128
+ /** Where the visitor was going, so the login screen can send them back. */
129
+ pathname: string;
130
+ /** The permission the route needs. Omitted means "any signed-in user may be here". */
131
+ action?: Action;
132
+ context?: PermissionContext;
133
+ loginPath?: string;
134
+ }
135
+ declare function resolveAdminAccess(options: ResolveAccessOptions): AdminAccess;
136
+ /**
137
+ * Where to go after signing in.
138
+ *
139
+ * Only a path inside the admin is accepted. An open redirect here would turn the login
140
+ * screen into a way to bounce a trusted user anywhere, so anything with a scheme, a host,
141
+ * a backslash, or a different prefix falls back to the admin root.
142
+ */
143
+ declare function safeReturnPath(next: string | null | undefined, adminBasePath?: string): string;
144
+ /** Minimal cookie reader, satisfied by the object Next's `cookies()` returns. */
145
+ interface CookieReader {
146
+ get(name: string): {
147
+ value: string;
148
+ } | undefined;
149
+ }
150
+ /**
151
+ * Turns the cookie into a session state, never throwing: a bad token is a signed-out
152
+ * visitor, not a 500.
153
+ */
154
+ declare function readSession(cookies: CookieReader, verifier: TokenVerifier, cookieName: string): Promise<SessionState>;
155
+
156
+ /**
157
+ * The two endpoints that move a browser session to the server and back.
158
+ *
159
+ * They are plain `Request` to `Response` handlers, not Next route handlers, so the package
160
+ * keeps its framework surface at zero and the host app exports them in three lines.
161
+ *
162
+ * POST: the browser has just signed in with the Firebase SDK and posts the fresh ID token.
163
+ * The token is verified before it is stored, so an unverifiable token never becomes a
164
+ * cookie that later code treats as proof.
165
+ *
166
+ * DELETE: sign-out. The cookie is cleared unconditionally, including for a token that no
167
+ * longer verifies, because refusing to sign someone out is the one failure mode worse
168
+ * than signing them out too eagerly.
169
+ */
170
+ interface SessionRouteOptions {
171
+ verifier: TokenVerifier;
172
+ /** Off only for plain-HTTP localhost. */
173
+ secure: boolean;
174
+ now?: () => number;
175
+ }
176
+ interface SessionRoutes {
177
+ POST(request: Request): Promise<Response>;
178
+ DELETE(): Promise<Response>;
179
+ }
180
+ declare function createSessionRoutes(options: SessionRouteOptions): SessionRoutes;
181
+
182
+ /**
183
+ * The icon vocabulary as plain data, with no Lucide import.
184
+ *
185
+ * `icon.tsx` maps every name here to a glyph, and the admin only ever asks for a name. The
186
+ * list lives in its own module because things that are not components need it: the
187
+ * navigation builder resolves a content type's declared icon against this vocabulary, and
188
+ * it runs on the server, where dragging a React component module in would put the icon set
189
+ * into a bundle that never renders one.
190
+ *
191
+ * Canonical source: .docs/styleguide/05-icons.md section 1.3.
192
+ */
193
+ declare const ICON_NAMES: readonly ["brand", "create", "edit", "delete", "save", "close", "search", "filter", "sort-asc", "sort-desc", "navigate-into", "page-previous", "page-next", "expand", "collapse", "selected", "indeterminate", "more-actions", "drag-handle", "locked", "dashboard", "collection", "singleton", "media", "settings", "users", "permissions", "content-types", "system", "success", "warning", "error", "info", "external-link", "upload", "copy", "retry", "open-navigation", "bold", "italic", "strikethrough", "link", "unlink", "bullet-list", "numbered-list", "quote", "undo", "redo"];
194
+ type IconName = (typeof ICON_NAMES)[number];
195
+
196
+ /**
197
+ * The sidebar as data. Canonical source: .docs/styleguide/03-layout.md section 2.1.
198
+ *
199
+ * The whole point of the registry is that nothing in the interface knows the name of a
200
+ * content type. This builder is the only place navigation is decided, it reads the
201
+ * registry and `can()`, and it returns plain serializable data so a server component can
202
+ * compute it and hand it to the client shell. Adding a content type therefore changes
203
+ * data and nothing else: no route table, no switch, no new file.
204
+ *
205
+ * It is deliberately free of React, so the server entry can export it without pulling a
206
+ * component into a module graph that never renders one.
207
+ */
208
+ interface AdminNavItem {
209
+ /** Stable across renders and unique inside the whole navigation. */
210
+ key: string;
211
+ label: string;
212
+ href: string;
213
+ icon: IconName;
214
+ /**
215
+ * Active on an exact path match only. The dashboard owns the admin root, so every
216
+ * deeper route would otherwise light it up as well.
217
+ */
218
+ exact?: boolean;
219
+ }
220
+ interface AdminNavGroup {
221
+ key: string;
222
+ /** Absent for the leading group and the static group, which carry no heading. */
223
+ label?: string;
224
+ items: readonly AdminNavItem[];
225
+ }
226
+ interface BuildNavigationOptions {
227
+ registry: Registry;
228
+ user: AuthUser;
229
+ /** Where the admin is mounted in the host app. */
230
+ basePath?: string;
231
+ }
232
+ /**
233
+ * The icon a content type renders with.
234
+ *
235
+ * `type.icon` is untrusted: a database-defined type carries whatever an editor typed into
236
+ * the Schema Builder, and a code-defined one carries whatever the customer wrote. An
237
+ * unknown name falls back to the icon for the kind, so the row still reads as a collection
238
+ * or a singleton. Neither a crash nor a blank gap is an acceptable answer to a typo.
239
+ */
240
+ declare function contentTypeIcon(type: ContentTypeLike): IconName;
241
+ /** A collection is listed by its plural; a singleton is one document and has no plural. */
242
+ declare function contentTypeLabel(type: ContentTypeLike): string;
243
+ declare function buildAdminNavigation({ registry, user, basePath, }: BuildNavigationOptions): readonly AdminNavGroup[];
244
+ /** The product name, and the suffix every admin document title carries. */
245
+ declare const ADMIN_TITLE = "Husk";
246
+ /**
247
+ * The navigation item a route belongs to, or undefined outside the navigation.
248
+ *
249
+ * The longest matching href wins, so a nested route resolves to the deepest item that
250
+ * claims it rather than to whichever one the groups happened to list first.
251
+ */
252
+ declare function navItemForPath(navigation: readonly AdminNavGroup[], pathname: string): AdminNavItem | undefined;
253
+ /**
254
+ * The document title for an admin route, taken from the same data that drew the sidebar.
255
+ *
256
+ * Titles are generated for the same reason screens are: a content type that appears in the
257
+ * navigation gets a correct browser tab without anybody writing a route-specific string,
258
+ * and a renamed type renames its tab. Returns undefined outside the navigation, which is
259
+ * the caller's cue to fall back to the layout's default title.
260
+ */
261
+ declare function adminPageTitle(navigation: readonly AdminNavGroup[], pathname: string): string | undefined;
262
+ /** Whether a navigation item matches the route the browser is on. */
263
+ declare function isNavItemActive(item: AdminNavItem, pathname: string): boolean;
264
+
265
+ export { ADMIN_TITLE, type AdminAccess, type AdminNavGroup, type AdminNavItem, type BuildNavigationOptions, type CookieReader, DEFAULT_LOGIN_PATH, type EmulatorTokenVerifierOptions, type ResolveAccessOptions, SESSION_COOKIE, type SessionClaims, type SessionCookieOptions, type SessionRouteOptions, type SessionRoutes, type SessionState, type TokenErrorCode, TokenVerificationError, type TokenVerifier, adminPageTitle, buildAdminNavigation, clearedSessionCookie, contentTypeIcon, contentTypeLabel, createEmulatorTokenVerifier, createSessionRoutes, createUnavailableTokenVerifier, decodeIdTokenPayload, isNavItemActive, navItemForPath, readSession, readSessionCookie, resolveAdminAccess, safeReturnPath, serializeSessionCookie, toSessionUser };