@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.
- package/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- 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,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 };
|