@deckbuilderkit/server-mods 0.0.0-stage → 0.1.0

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/src/records.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { join } from 'node:path';
2
+ import { readJsonFile, writeJsonAtomic } from '@deckbuilderkit/platform';
3
+ import { AppError, type ImportResult, invalid, isRecord, notFound, type Routes } from '@deckbuilderkit/server-core';
4
+ import { fromPlatform } from './platform-errors.ts';
5
+
6
+ /** A stored record: the app's fields plus the ones the store owns. */
7
+ export type Rec<T> = T & { id: string; createdAt: string; updatedAt: string };
8
+
9
+ /** Where secret field values live instead of the JSON file: `secretStore` from platform, a fake in tests. */
10
+ export interface SecretBackend {
11
+ set(ref: string, value: string): Promise<void>;
12
+ get(ref: string): Promise<string | null>;
13
+ remove(ref: string): Promise<void>;
14
+ }
15
+
16
+ export interface RecordStoreOptions<T extends object> {
17
+ /** Folder of `<name>.json`, usually `appHome(APP.id)`. */
18
+ dir: string;
19
+ name: string;
20
+ /**
21
+ * Builds a fresh `T` from raw client input (never spread the input) and throws `invalid(...)`;
22
+ * `prev` is set on update, `ctx.imported` when the input comes from a backup file the user
23
+ * picked in a native dialog (`importItems`) rather than from a form.
24
+ */
25
+ validate(input: unknown, prev?: Rec<T>, ctx?: { imported?: true }): T;
26
+ /** Most records the store holds. */
27
+ max?: number;
28
+ /**
29
+ * Fields kept in `secrets` instead of the file. Input: a non-empty string sets the secret, `''`
30
+ * removes it, anything else (absent, `{ hasSecret: true }`) keeps it. Every returned record
31
+ * shows `{ hasSecret: true }` instead; app code reads the value with `secret(id, field)`.
32
+ */
33
+ secretFields?: string[];
34
+ secrets?: SecretBackend;
35
+ }
36
+
37
+ export interface RecordStore<T extends object> {
38
+ list(): Promise<Rec<T>[]>;
39
+ get(id: string): Promise<Rec<T>>;
40
+ create(input: unknown): Promise<Rec<T>>;
41
+ update(id: string, input: unknown): Promise<Rec<T>>;
42
+ remove(id: string): Promise<void>;
43
+ /** Puts the records named in `ids` first, in that order; unknown ids are ignored and records not named keep their order after them. */
44
+ reorder(ids: string[]): Promise<void>;
45
+ /** Server-only: the stored value of a secret field, null when none is set. Never return it from a route. */
46
+ secret(id: string, field: string): Promise<string | null>;
47
+ /**
48
+ * Adds or updates records by id from a backup, in one write. Imports never change secrets: an
49
+ * updated record keeps its stored ones, an added one has none. `replace` also removes the
50
+ * records whose id is not in `items`.
51
+ */
52
+ importItems(items: unknown[], mode: 'merge' | 'replace'): Promise<ImportResult>;
53
+ /** Every record without its secret fields (not even the `hasSecret` marker), for a backup. */
54
+ exportItems(): Promise<Rec<T>[]>;
55
+ }
56
+
57
+ export type { ImportResult };
58
+
59
+ interface RecordFile<T> {
60
+ version: 1;
61
+ items: Rec<T>[];
62
+ }
63
+
64
+ /** Longest secret field value, in characters; the secret store is rewritten on every set. */
65
+ const MAX_SECRET_LENGTH = 65_536;
66
+
67
+ /** What a secret field holds in the file. */
68
+ interface SecretRef {
69
+ ref: string;
70
+ }
71
+
72
+ const isRef = (v: unknown): v is SecretRef => typeof v === 'object' && v !== null && typeof (v as SecretRef).ref === 'string';
73
+
74
+ const IMPORT_ID = /^[\w-]{1,64}$/;
75
+
76
+ const isValidDate = (v: unknown): v is string => typeof v === 'string' && !Number.isNaN(Date.parse(v));
77
+
78
+ const isRecordFile = (v: unknown) =>
79
+ typeof v === 'object' && v !== null && (v as RecordFile<object>).version === 1 && Array.isArray((v as RecordFile<object>).items);
80
+
81
+ /** A JSON-file store of records with ids and timestamps; calls are serialized so concurrent ones never lose a record. */
82
+ export function recordStore<T extends object>(opts: RecordStoreOptions<T>): RecordStore<T> {
83
+ const secretFields = opts.secretFields ?? [];
84
+ const backend = opts.secrets;
85
+ if (secretFields.length && !backend) throw new Error('recordStore: secretFields need the secrets option');
86
+ const secrets = backend && {
87
+ set: (ref: string, value: string) => backend.set(ref, value).catch(fromPlatform),
88
+ get: (ref: string) => backend.get(ref).catch(fromPlatform),
89
+ remove: (ref: string) => backend.remove(ref).catch(fromPlatform),
90
+ };
91
+ const file = join(opts.dir, `${opts.name}.json`);
92
+ const max = opts.max ?? 500;
93
+ let items: Rec<T>[] | null = null;
94
+ let chain: Promise<unknown> = Promise.resolve();
95
+
96
+ const locked = <R>(job: () => Promise<R>): Promise<R> => {
97
+ const run = chain.then(job);
98
+ chain = run.catch(() => {});
99
+ return run;
100
+ };
101
+
102
+ async function load(): Promise<Rec<T>[]> {
103
+ if (!items) items = ((await readJsonFile(file, isRecordFile)) as RecordFile<T> | undefined)?.items ?? [];
104
+ return items;
105
+ }
106
+
107
+ async function commit(next: Rec<T>[]): Promise<void> {
108
+ await writeJsonAtomic(file, { version: 1, items: next } satisfies RecordFile<T>);
109
+ items = next;
110
+ }
111
+
112
+ const find = (all: Rec<T>[], id: string) => {
113
+ const rec = all.find((r) => r.id === id);
114
+ if (!rec) throw notFound('recordNotFound', 'Record not found.');
115
+ return rec;
116
+ };
117
+
118
+ const fields = (rec: Rec<T>) => rec as unknown as Record<string, unknown>;
119
+
120
+ /** A copy of `rec` as routes may show it: each set secret field is `{ hasSecret: true }`. */
121
+ const masked = (rec: Rec<T>): Rec<T> => {
122
+ const copy = fields(structuredClone(rec));
123
+ for (const field of secretFields) {
124
+ if (isRef(copy[field])) copy[field] = { hasSecret: true };
125
+ else delete copy[field];
126
+ }
127
+ return copy as unknown as Rec<T>;
128
+ };
129
+
130
+ /**
131
+ * Sets the secret fields of `rec` from raw `input` (see `secretFields`), storing new values at
132
+ * once; returns the refs to remove once the record is saved.
133
+ */
134
+ async function applySecrets(input: unknown, rec: Rec<T>, prev?: Rec<T>): Promise<string[]> {
135
+ const raw = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
136
+ for (const field of secretFields) {
137
+ const value = raw[field];
138
+ if (typeof value === 'string' && value.length > MAX_SECRET_LENGTH) throw invalid('secretTooLong', 'The secret is too long.');
139
+ }
140
+ const removals: string[] = [];
141
+ for (const field of secretFields) {
142
+ const value = raw[field];
143
+ const old = prev && fields(prev)[field];
144
+ delete fields(rec)[field];
145
+ if (typeof value === 'string' && value !== '') {
146
+ const ref = `${opts.name}:${rec.id}:${field}`;
147
+ await secrets!.set(ref, value);
148
+ fields(rec)[field] = { ref } satisfies SecretRef;
149
+ } else if (value === '') {
150
+ if (isRef(old)) removals.push(old.ref);
151
+ } else if (isRef(old)) {
152
+ fields(rec)[field] = old;
153
+ }
154
+ }
155
+ return removals;
156
+ }
157
+
158
+ const removeSecrets = async (refs: string[]) => {
159
+ for (const ref of refs) await secrets!.remove(ref);
160
+ };
161
+
162
+ const secretRefs = (rec: Rec<T>) => secretFields.map((field) => fields(rec)[field]).filter(isRef).map((s) => s.ref);
163
+
164
+ return {
165
+ list: () => locked(async () => (await load()).map(masked)),
166
+ get: (id) => locked(async () => masked(find(await load(), id))),
167
+ create: (input) =>
168
+ locked(async () => {
169
+ const all = await load();
170
+ if (all.length >= max) throw invalid('tooManyRecords', 'Too many records.');
171
+ const now = new Date().toISOString();
172
+ const rec = { ...opts.validate(input), id: crypto.randomUUID(), createdAt: now, updatedAt: now } as Rec<T>;
173
+ await applySecrets(input, rec);
174
+ await commit([...all, rec]);
175
+ return masked(rec);
176
+ }),
177
+ update: (id, input) =>
178
+ locked(async () => {
179
+ const all = await load();
180
+ const prev = find(all, id);
181
+ const rec = { ...opts.validate(input, masked(prev)), id: prev.id, createdAt: prev.createdAt, updatedAt: new Date().toISOString() } as Rec<T>;
182
+ const removals = await applySecrets(input, rec, prev);
183
+ await commit(all.map((r) => (r === prev ? rec : r)));
184
+ await removeSecrets(removals);
185
+ return masked(rec);
186
+ }),
187
+ remove: (id) =>
188
+ locked(async () => {
189
+ const all = await load();
190
+ const prev = find(all, id);
191
+ await commit(all.filter((r) => r !== prev));
192
+ await removeSecrets(secretRefs(prev));
193
+ }),
194
+ reorder: (ids) =>
195
+ locked(async () => {
196
+ const all = await load();
197
+ const byId = new Map(all.map((r) => [r.id, r]));
198
+ const first = [...new Set(ids)].flatMap((id) => byId.get(id) ?? []);
199
+ const named = new Set(first);
200
+ await commit([...first, ...all.filter((r) => !named.has(r))]);
201
+ }),
202
+ secret: (id, field) =>
203
+ locked(async () => {
204
+ if (!secretFields.includes(field)) throw new Error(`recordStore: ${field} is not a secret field`);
205
+ const stored = fields(find(await load(), id))[field];
206
+ return isRef(stored) ? secrets!.get(stored.ref) : null;
207
+ }),
208
+ importItems: (list, mode) =>
209
+ locked(async () => {
210
+ const all = await load();
211
+ const result: ImportResult = { added: 0, updated: 0, removed: 0, skipped: [] };
212
+ const skip = (id: string | undefined, code: string, message: string) => result.skipped.push({ ...(id && { id }), code, message });
213
+ const valid: Array<{ id: string; item: Record<string, unknown> }> = [];
214
+ const ids = new Set<string>();
215
+ for (const item of list) {
216
+ const id = typeof item === 'object' && item !== null ? (item as Record<string, unknown>).id : undefined;
217
+ if (typeof id !== 'string' || !IMPORT_ID.test(id)) skip(undefined, 'badRecord', 'Invalid record.');
218
+ else if (ids.has(id)) skip(id, 'badRecord', 'Invalid record.');
219
+ else {
220
+ ids.add(id);
221
+ valid.push({ id, item: item as Record<string, unknown> });
222
+ }
223
+ }
224
+ const kept = mode === 'merge' ? all : all.filter((r) => ids.has(r.id));
225
+ const updates = new Map<string, Rec<T>>();
226
+ const added: Rec<T>[] = [];
227
+ const now = new Date().toISOString();
228
+ for (const { id, item } of valid) {
229
+ const { id: _id, createdAt, updatedAt: _updatedAt, ...input } = item;
230
+ for (const field of secretFields) delete input[field];
231
+ const prev = all.find((r) => r.id === id);
232
+ if (!prev && kept.length + added.length >= max) {
233
+ skip(id, 'tooManyRecords', 'Too many records.');
234
+ continue;
235
+ }
236
+ let value: T;
237
+ try {
238
+ value = opts.validate(input, prev && masked(prev), { imported: true });
239
+ } catch (e) {
240
+ if (!(e instanceof AppError)) throw e;
241
+ skip(id, e.code, e.message);
242
+ continue;
243
+ }
244
+ if (prev) {
245
+ const rec = { ...value, id, createdAt: prev.createdAt, updatedAt: now } as Rec<T>;
246
+ await applySecrets(input, rec, prev);
247
+ updates.set(id, rec);
248
+ } else {
249
+ const rec = { ...value, id, createdAt: isValidDate(createdAt) ? new Date(createdAt).toISOString() : now, updatedAt: now } as Rec<T>;
250
+ for (const field of secretFields) delete fields(rec)[field];
251
+ added.push(rec);
252
+ }
253
+ }
254
+ const removed = all.filter((r) => !kept.includes(r));
255
+ await commit([...kept.map((r) => updates.get(r.id) ?? r), ...added]);
256
+ await removeSecrets(removed.flatMap(secretRefs));
257
+ return { ...result, added: added.length, updated: updates.size, removed: removed.length };
258
+ }),
259
+ exportItems: () =>
260
+ locked(async () =>
261
+ (await load()).map((rec) => {
262
+ const copy = fields(structuredClone(rec));
263
+ for (const field of secretFields) delete copy[field];
264
+ return copy as unknown as Rec<T>;
265
+ }),
266
+ ),
267
+ };
268
+ }
269
+
270
+ /** `GET <base>`, `GET <base>/item?id=`, `POST <base>`, `PUT <base>?id=`, `DELETE <base>?id=` over a record store. */
271
+ export function recordRoutes<T extends object>(store: RecordStore<T>, base: string): Routes {
272
+ const idOf = (query: URLSearchParams) => {
273
+ const id = query.get('id');
274
+ if (!id) throw invalid('missingId', 'Missing id.');
275
+ return id;
276
+ };
277
+ return {
278
+ [`GET ${base}`]: () => store.list(),
279
+ [`GET ${base}/item`]: ({ query }) => store.get(idOf(query)),
280
+ [`POST ${base}`]: async ({ json }) => store.create(await json()),
281
+ [`PUT ${base}`]: async ({ query, json }) => store.update(idOf(query), await json()),
282
+ [`POST ${base}/order`]: async ({ json }) => {
283
+ const body = await json();
284
+ const ids = isRecord(body) ? body.ids : undefined;
285
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) throw invalid('badOrder', 'Invalid order.');
286
+ await store.reorder(ids as string[]);
287
+ return { ok: true };
288
+ },
289
+ [`DELETE ${base}`]: async ({ query }) => {
290
+ await store.remove(idOf(query));
291
+ return { ok: true };
292
+ },
293
+ };
294
+ }
@@ -0,0 +1,51 @@
1
+ import { invalid } from '@deckbuilderkit/server-core';
2
+ import { REST_VAR_REFERENCE } from '@deckbuilderkit/server-core/protocol';
3
+
4
+ export interface VariableResolver {
5
+ /** `text` with every known `{{name}}` replaced; unknown names are collected and left as they are. */
6
+ resolve(text: string): string;
7
+ /** Unknown names seen so far, in order of first use. */
8
+ readonly missing: string[];
9
+ /** Throws `unknownVariable` (params.names = comma-separated list) when any name was unknown. */
10
+ check(): void;
11
+ }
12
+
13
+ /**
14
+ * Resolves `{{name}}` against `vars` (own keys only) and the built-ins. `$timestamp` and
15
+ * `$isoTimestamp` are fixed per resolver (one request sees one time); `$uuid` and `$randomInt`
16
+ * (0..1000) are fresh at each use.
17
+ */
18
+ export function variableResolver(vars: Record<string, string> = {}, now: () => Date = () => new Date()): VariableResolver {
19
+ const missing: string[] = [];
20
+ let at: Date | undefined;
21
+ const time = () => (at ??= now());
22
+ const builtin = (name: string): string | undefined => {
23
+ switch (name) {
24
+ case '$timestamp':
25
+ return String(Math.floor(time().getTime() / 1000));
26
+ case '$isoTimestamp':
27
+ return time().toISOString();
28
+ case '$uuid':
29
+ return crypto.randomUUID();
30
+ case '$randomInt':
31
+ return String(Math.floor(Math.random() * 1001));
32
+ default:
33
+ return undefined;
34
+ }
35
+ };
36
+ return {
37
+ resolve: (text) =>
38
+ text.replace(REST_VAR_REFERENCE, (whole, name: string) => {
39
+ const value = Object.hasOwn(vars, name) ? vars[name] : builtin(name);
40
+ if (value !== undefined) return value;
41
+ if (!missing.includes(name)) missing.push(name);
42
+ return whole;
43
+ }),
44
+ missing,
45
+ check() {
46
+ if (missing.length) throw unknownVariables(missing);
47
+ },
48
+ };
49
+ }
50
+
51
+ export const unknownVariables = (names: string[]) => invalid('unknownVariable', `Unknown variables: ${names.join(', ')}.`, { names: names.join(', ') });
@@ -0,0 +1,92 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { RestHistoryEntry } from '@deckbuilderkit/server-core/protocol';
4
+ import { withoutAuthorization } from './http.ts';
5
+
6
+ export type { RestHistoryEntry };
7
+
8
+ export interface RestHistoryOptions {
9
+ /** Folder of `rest-history.json`. */
10
+ dir: string;
11
+ /** Most entries kept; 0 turns recording off. A function is read on every append. */
12
+ max: number | (() => number);
13
+ }
14
+
15
+ export interface RestHistory {
16
+ /** Adds an entry at the top without Authorization headers (no-op while max is 0); gives the stored entry, or null when off. */
17
+ append(entry: Omit<RestHistoryEntry, 'id'>): Promise<RestHistoryEntry | null>;
18
+ /** Newest first; `q` matches the method and URL ignoring case. */
19
+ list(filter?: { q?: string }): Promise<RestHistoryEntry[]>;
20
+ clear(): Promise<void>;
21
+ setMax(max: number): void;
22
+ }
23
+
24
+ const isEntry = (e: unknown): e is RestHistoryEntry => {
25
+ const r = e as Partial<RestHistoryEntry> | null;
26
+ return (
27
+ typeof r === 'object' && r !== null && typeof r.id === 'string' && typeof r.method === 'string' && typeof r.url === 'string' &&
28
+ typeof r.at === 'number' && typeof r.status === 'number' && typeof r.ms === 'number' && typeof r.draft === 'object' && r.draft !== null &&
29
+ Array.isArray(r.draft.headers)
30
+ );
31
+ };
32
+
33
+ /** The REST request history in `<dir>/rest-history.json`: newest first, capped at `max`, written atomically. */
34
+ export function restHistory({ dir, max }: RestHistoryOptions): RestHistory {
35
+ const file = join(dir, 'rest-history.json');
36
+ let limit = max;
37
+ const maxNow = () => Math.max(0, Math.floor(typeof limit === 'function' ? limit() : limit) || 0);
38
+ let entries: Promise<RestHistoryEntry[]> | null = null;
39
+ let queue: Promise<unknown> = Promise.resolve();
40
+
41
+ function load(): Promise<RestHistoryEntry[]> {
42
+ entries ??= readFile(file, 'utf8')
43
+ .then((text) => {
44
+ const raw: unknown = JSON.parse(text);
45
+ return Array.isArray(raw) ? raw.filter(isEntry) : [];
46
+ })
47
+ .catch(() => []);
48
+ return entries;
49
+ }
50
+
51
+ function mutate<T>(change: (list: RestHistoryEntry[]) => { list: RestHistoryEntry[]; result: T; dirty: boolean }): Promise<T> {
52
+ const run = queue.then(async () => {
53
+ const { list, result, dirty } = change(await load());
54
+ entries = Promise.resolve(list);
55
+ if (dirty) {
56
+ await mkdir(dir, { recursive: true });
57
+ const tmp = `${file}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
58
+ try {
59
+ await writeFile(tmp, JSON.stringify(list));
60
+ await rename(tmp, file);
61
+ } catch (err) {
62
+ await rm(tmp, { force: true });
63
+ throw err;
64
+ }
65
+ }
66
+ return result;
67
+ });
68
+ queue = run.catch(() => {});
69
+ return run;
70
+ }
71
+
72
+ return {
73
+ append(entry) {
74
+ if (maxNow() === 0) return Promise.resolve(null);
75
+ return mutate((list) => {
76
+ const cap = maxNow();
77
+ if (cap === 0) return { list, result: null, dirty: false };
78
+ const stored: RestHistoryEntry = { id: crypto.randomUUID(), ...entry, draft: withoutAuthorization(entry.draft) };
79
+ return { list: [stored, ...list].slice(0, cap), result: stored, dirty: true };
80
+ });
81
+ },
82
+ async list({ q } = {}) {
83
+ await queue;
84
+ const needle = q?.trim().toLowerCase();
85
+ return (await load()).filter((e) => !needle || `${e.method} ${e.url}`.toLowerCase().includes(needle));
86
+ },
87
+ clear: () => mutate((list) => ({ list: [], result: undefined, dirty: list.length > 0 })),
88
+ setMax(n) {
89
+ limit = n;
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,83 @@
1
+ import { join } from 'node:path';
2
+ import { readJsonFile, writeJsonAtomic } from '@deckbuilderkit/platform';
3
+ import { invalid, isRecord, type Routes } from '@deckbuilderkit/server-core';
4
+ import type { HttpRequestDraft } from '@deckbuilderkit/server-core/protocol';
5
+ import { validateDraft, withoutAuthorization } from './http.ts';
6
+
7
+ export const REST_TABS_MAX = 30;
8
+ const ID = /^[\w-]{1,64}$/;
9
+ const MAX_TITLE = 200;
10
+
11
+ export interface RestTab {
12
+ id: string;
13
+ draft: HttpRequestDraft;
14
+ savedId?: string;
15
+ title?: string;
16
+ }
17
+
18
+ export interface RestTabsFile {
19
+ version: 1;
20
+ tabs: RestTab[];
21
+ active?: string;
22
+ }
23
+
24
+ const badTabs = () => invalid('badRestTabs', 'The open requests are not valid.');
25
+
26
+ /** The open REST tabs as a fresh object; Authorization headers are dropped, anything out of range throws `badRestTabs`. */
27
+ export function validateRestTabs(raw: unknown): RestTabsFile {
28
+ if (!isRecord(raw) || raw.version !== 1 || !Array.isArray(raw.tabs) || raw.tabs.length > REST_TABS_MAX) throw badTabs();
29
+ const seen = new Set<string>();
30
+ const tabs = raw.tabs.map((tab): RestTab => {
31
+ if (!isRecord(tab) || typeof tab.id !== 'string' || !ID.test(tab.id) || seen.has(tab.id)) throw badTabs();
32
+ seen.add(tab.id);
33
+ let draft: HttpRequestDraft;
34
+ try {
35
+ draft = withoutAuthorization(validateDraft(tab.draft));
36
+ } catch {
37
+ throw badTabs();
38
+ }
39
+ const out: RestTab = { id: tab.id, draft };
40
+ if (tab.savedId !== undefined) {
41
+ if (typeof tab.savedId !== 'string' || !ID.test(tab.savedId)) throw badTabs();
42
+ out.savedId = tab.savedId;
43
+ }
44
+ if (tab.title !== undefined) {
45
+ if (typeof tab.title !== 'string' || tab.title.length > MAX_TITLE) throw badTabs();
46
+ out.title = tab.title;
47
+ }
48
+ return out;
49
+ });
50
+ const file: RestTabsFile = { version: 1, tabs };
51
+ if (raw.active !== undefined) {
52
+ if (typeof raw.active !== 'string' || !seen.has(raw.active)) throw badTabs();
53
+ file.active = raw.active;
54
+ }
55
+ return file;
56
+ }
57
+
58
+ const isTabsFile = (v: unknown) => isRecord(v) && v.version === 1 && Array.isArray(v.tabs);
59
+
60
+ /** GET/PUT `path`: the REST screen's open tabs in `<dir>/restTabs.json` (never the Authorization value). */
61
+ export function restTabsRoutes(opts: { dir: string; path?: string }): Routes {
62
+ const file = join(opts.dir, 'restTabs.json');
63
+ const path = opts.path ?? '/api/rest-tabs';
64
+ let chain: Promise<unknown> = Promise.resolve();
65
+ return {
66
+ [`GET ${path}`]: async () => {
67
+ const stored = await readJsonFile(file, isTabsFile);
68
+ if (!stored) return { version: 1, tabs: [] } satisfies RestTabsFile;
69
+ try {
70
+ return validateRestTabs(stored);
71
+ } catch {
72
+ return { version: 1, tabs: [] } satisfies RestTabsFile;
73
+ }
74
+ },
75
+ [`PUT ${path}`]: async ({ json }) => {
76
+ const next = validateRestTabs(await json());
77
+ const run = chain.then(() => writeJsonAtomic(file, next));
78
+ chain = run.catch(() => {});
79
+ await run;
80
+ return { ok: true };
81
+ },
82
+ };
83
+ }