@spacefast/common 0.0.13 → 0.0.17
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/dist/agents/client-registry.d.ts +22 -0
- package/dist/agents/client-registry.js +41 -0
- package/dist/agents/connect-targets.d.ts +51 -6
- package/dist/agents/connect-targets.js +93 -28
- package/dist/brand.d.ts +0 -11
- package/dist/brand.js +0 -20
- package/dist/config/domains.d.ts +15 -6
- package/dist/config/domains.js +24 -10
- package/dist/config/postgres-budget.d.ts +3 -0
- package/dist/config/postgres-budget.js +6 -1
- package/dist/config/postgres-budget.json +2 -1
- package/dist/contracts/abuse.js +1 -1
- package/dist/contracts/access.d.ts +149 -44
- package/dist/contracts/access.js +94 -8
- package/dist/contracts/activity.js +5 -1
- package/dist/contracts/api-keys.js +9 -9
- package/dist/contracts/archives.js +11 -11
- package/dist/contracts/beta.js +1 -1
- package/dist/contracts/builds.js +14 -14
- package/dist/contracts/channels.js +2 -2
- package/dist/contracts/comments.d.ts +86 -15
- package/dist/contracts/comments.js +116 -20
- package/dist/contracts/common.d.ts +12 -12
- package/dist/contracts/common.js +33 -30
- package/dist/contracts/continuation.js +5 -5
- package/dist/contracts/data-source-routes.js +1 -1
- package/dist/contracts/device-auth.d.ts +6 -6
- package/dist/contracts/device-auth.js +12 -12
- package/dist/contracts/docs.d.ts +10 -10
- package/dist/contracts/domains.js +39 -33
- package/dist/contracts/enums.d.ts +2 -10
- package/dist/contracts/enums.js +0 -2
- package/dist/contracts/error-code-meta.d.ts +116 -47
- package/dist/contracts/error-code-meta.js +37 -16
- package/dist/contracts/error-codes.d.ts +7 -4
- package/dist/contracts/error-codes.js +46 -21
- package/dist/contracts/events.d.ts +10 -3
- package/dist/contracts/events.js +33 -5
- package/dist/contracts/execution.d.ts +26 -0
- package/dist/contracts/execution.js +21 -0
- package/dist/contracts/feature-lifecycle.d.ts +21 -24
- package/dist/contracts/feature-lifecycle.js +28 -26
- package/dist/contracts/features.js +4 -4
- package/dist/contracts/functions.d.ts +8 -140
- package/dist/contracts/functions.js +9 -19
- package/dist/contracts/git.d.ts +1 -1
- package/dist/contracts/grants.d.ts +96 -79
- package/dist/contracts/grants.js +130 -43
- package/dist/contracts/ids.d.ts +2 -2
- package/dist/contracts/ids.js +2 -4
- package/dist/contracts/internal.js +2 -2
- package/dist/contracts/mcp.d.ts +17 -556
- package/dist/contracts/mcp.js +9 -103
- package/dist/contracts/me.js +3 -4
- package/dist/contracts/notifications.d.ts +108 -0
- package/dist/contracts/notifications.js +54 -0
- package/dist/contracts/oauth-resources.d.ts +18 -1
- package/dist/contracts/oauth-resources.js +22 -2
- package/dist/contracts/operations.d.ts +2 -50
- package/dist/contracts/operations.js +0 -19
- package/dist/contracts/pages.d.ts +8 -4
- package/dist/contracts/pages.js +16 -3
- package/dist/contracts/platform.js +10 -8
- package/dist/contracts/problem-document.d.ts +14 -0
- package/dist/contracts/problem-document.js +35 -0
- package/dist/contracts/push-new.d.ts +21 -6
- package/dist/contracts/push-new.js +3 -7
- package/dist/contracts/resources.d.ts +8 -7
- package/dist/contracts/resources.js +9 -6
- package/dist/contracts/runtime-api.d.ts +162 -92
- package/dist/contracts/runtime-api.js +99 -27
- package/dist/contracts/runtime-app.d.ts +41 -11
- package/dist/contracts/runtime-app.js +22 -1
- package/dist/contracts/runtime-db.d.ts +424 -0
- package/dist/contracts/runtime-db.js +108 -0
- package/dist/contracts/runtime-storage.d.ts +120 -0
- package/dist/contracts/runtime-storage.js +40 -0
- package/dist/contracts/sf-config-v1.d.ts +49 -1
- package/dist/contracts/sf-config-v1.js +2 -2
- package/dist/contracts/sites.d.ts +1 -0
- package/dist/contracts/sites.js +20 -12
- package/dist/contracts/space-config.d.ts +62 -22
- package/dist/contracts/space-config.js +173 -47
- package/dist/contracts/spaces.d.ts +79 -45
- package/dist/contracts/spaces.js +66 -50
- package/dist/contracts/superadmin-spaces.d.ts +13 -2
- package/dist/contracts/superadmin-spaces.js +15 -0
- package/dist/contracts/superadmin-tenants.js +4 -4
- package/dist/contracts/superadmin.d.ts +4 -0
- package/dist/contracts/superadmin.js +14 -1
- package/dist/contracts/tags.d.ts +17 -17
- package/dist/contracts/tags.js +1 -1
- package/dist/contracts/teams.js +25 -16
- package/dist/contracts/theme-json.js +1 -1
- package/dist/contracts/transfers.js +4 -4
- package/dist/contracts/variables.js +3 -3
- package/dist/contracts/zero.d.ts +204 -338
- package/dist/contracts/zero.js +94 -98
- package/dist/dashboard-paths/index.d.ts +16 -1
- package/dist/dashboard-paths/index.js +18 -0
- package/dist/docs/agent-handoff-document.d.ts +2 -2
- package/dist/docs/agent-handoff-document.js +8 -7
- package/dist/docs/agent-prose.d.ts +22 -3
- package/dist/docs/agent-prose.js +221 -51
- package/dist/docs/agent-setup.d.ts +13 -10
- package/dist/docs/agent-setup.js +279 -53
- package/dist/docs/agent-solutions.d.ts +6 -6
- package/dist/docs/agent-solutions.js +23 -21
- package/dist/docs/catalog.d.ts +29 -74
- package/dist/docs/catalog.js +65 -92
- package/dist/docs/error-docs.js +293 -221
- package/dist/docs/skill-distribution.d.ts +2 -0
- package/dist/docs/skill-distribution.js +7 -4
- package/dist/docs/skills.d.ts +546 -0
- package/dist/docs/skills.js +340 -0
- package/dist/utils/browser-credential.d.ts +19 -0
- package/dist/utils/browser-credential.js +62 -0
- package/dist/utils/comment-avatars.d.ts +7 -0
- package/dist/utils/comment-avatars.js +42 -0
- package/dist/utils/content-type.d.ts +3 -3
- package/dist/utils/content-type.js +38 -61
- package/dist/utils/domain-diagnostics.d.ts +5 -5
- package/dist/utils/domain-diagnostics.js +21 -10
- package/dist/utils/local-space-state.d.ts +78 -4
- package/dist/utils/local-space-state.js +383 -82
- package/dist/utils/one-shot-replay.d.ts +14 -0
- package/dist/utils/one-shot-replay.js +21 -0
- package/dist/utils/page-preview.d.ts +7 -0
- package/dist/utils/page-preview.js +9 -0
- package/dist/utils/pages.d.ts +2 -11
- package/dist/utils/pages.js +58 -14
- package/dist/utils/publish-form-data.d.ts +22 -0
- package/dist/utils/publish-form-data.js +26 -0
- package/dist/utils/publish-policy.js +20 -4
- package/dist/utils/query-keys.d.ts +7 -4
- package/dist/utils/query-keys.js +13 -4
- package/dist/utils/runtime-paths.d.ts +3 -0
- package/dist/utils/runtime-paths.js +7 -0
- package/dist/utils/secure-local-file.d.ts +73 -0
- package/dist/utils/secure-local-file.js +1373 -0
- package/dist/utils/space-config.d.ts +13 -4
- package/dist/utils/space-config.js +123 -15
- package/dist/utils/storage-policy.d.ts +20 -0
- package/dist/utils/storage-policy.fixtures.json +174 -0
- package/dist/utils/storage-policy.js +193 -0
- package/dist/utils/upload-session.d.ts +12 -1
- package/dist/utils/upload-session.js +37 -2
- package/dist/utils/version-file-browser.d.ts +13 -0
- package/dist/utils/version-file-browser.js +59 -0
- package/package.json +19 -1
- package/dist/contracts/mounts.d.ts +0 -142
- package/dist/contracts/mounts.js +0 -56
- package/dist/utils/asset-fingerprint.d.ts +0 -64
- package/dist/utils/asset-fingerprint.js +0 -620
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
4
|
import { z } from "zod";
|
|
5
|
+
import { createOneShotReplayIdentity } from "./one-shot-replay.js";
|
|
6
|
+
import { SecureLocalFileConflictError, secureReadFile, secureWriteFile, } from "./secure-local-file.js";
|
|
4
7
|
export const LOCAL_SPACE_STATE_DIR_NAME = ".spacefast";
|
|
5
8
|
export const LEGACY_LOCAL_SPACE_STATE_DIR_NAME = ".stattic";
|
|
6
9
|
export const LOCAL_SPACE_LINK_FILE_NAME = "space.json";
|
|
@@ -15,8 +18,9 @@ export const LOCAL_SPACE_STATE_FILE_NAME = "state.json";
|
|
|
15
18
|
* `.spacefast/state.json` is the local credential/cache axis: it records the
|
|
16
19
|
* resolved `spaceId`, provider `apiUrl`, anonymous `claimToken` before claim,
|
|
17
20
|
* durable `accessToken` after continuation, and small publish caches such as
|
|
18
|
-
* `lastVersionId`. It is secret, machine-written, gitignored, and merge-written
|
|
19
|
-
*
|
|
21
|
+
* `lastVersionId`. It is secret, machine-written, gitignored, and merge-written.
|
|
22
|
+
* The strict versioned schema fails closed instead of letting an older tool
|
|
23
|
+
* reinterpret fields written under a contract it does not understand.
|
|
20
24
|
*/
|
|
21
25
|
export const localSpaceLinkSchema = z
|
|
22
26
|
.object({
|
|
@@ -25,8 +29,9 @@ export const localSpaceLinkSchema = z
|
|
|
25
29
|
apiBaseUrl: z.string().min(1).optional(),
|
|
26
30
|
})
|
|
27
31
|
.loose();
|
|
28
|
-
export const
|
|
29
|
-
|
|
32
|
+
export const LOCAL_SPACE_STATE_SCHEMA_VERSION = 1;
|
|
33
|
+
export const localSpaceStateSchema = z.strictObject({
|
|
34
|
+
schemaVersion: z.literal(LOCAL_SPACE_STATE_SCHEMA_VERSION),
|
|
30
35
|
spaceId: z.string().min(1).optional(),
|
|
31
36
|
apiUrl: z.string().min(1).optional(),
|
|
32
37
|
teamSlug: z.string().min(1).optional(),
|
|
@@ -35,10 +40,56 @@ export const localSpaceStateSchema = z
|
|
|
35
40
|
expiresAt: z.string().min(1).optional(),
|
|
36
41
|
accessToken: z.string().min(1).optional(),
|
|
37
42
|
lastVersionId: z.string().min(1).optional(),
|
|
43
|
+
pendingOperationId: z.string().min(1).optional(),
|
|
38
44
|
lastVersionUrl: z.string().min(1).optional(),
|
|
39
45
|
lastSpaceUrl: z.string().min(1).optional(),
|
|
40
|
-
|
|
41
|
-
.
|
|
46
|
+
/** Secret per-checkout entropy used only to namespace replay-safe first publishes. */
|
|
47
|
+
anonymousPublishNonce: z.string().min(32).optional(),
|
|
48
|
+
/** Unsettled first-publish mutation. Retries must reuse this exact key. */
|
|
49
|
+
pendingPublish: z
|
|
50
|
+
.object({
|
|
51
|
+
key: z.string().min(1),
|
|
52
|
+
principal: z.string().regex(/^[0-9a-f]{64}$/),
|
|
53
|
+
kind: z.enum(["anonymous", "authenticated"]),
|
|
54
|
+
intent: z.string().min(1).optional(),
|
|
55
|
+
createdAt: z.string().datetime(),
|
|
56
|
+
})
|
|
57
|
+
.strict()
|
|
58
|
+
.optional(),
|
|
59
|
+
/** Settled creation receipt identity retained for invocations that began before its commit. */
|
|
60
|
+
creationReplay: z
|
|
61
|
+
.object({
|
|
62
|
+
key: z.string().min(1),
|
|
63
|
+
principal: z.string().regex(/^[0-9a-f]{64}$/),
|
|
64
|
+
kind: z.enum(["anonymous", "authenticated"]),
|
|
65
|
+
intent: z.string().min(1).optional(),
|
|
66
|
+
createdAt: z.string().datetime(),
|
|
67
|
+
})
|
|
68
|
+
.strict()
|
|
69
|
+
.optional(),
|
|
70
|
+
/** Random replay capability reserved before consuming a claim credential. */
|
|
71
|
+
pendingCredentialReplay: z
|
|
72
|
+
.object({
|
|
73
|
+
operation: z.enum(["claim-rotate", "claim-exchange"]),
|
|
74
|
+
credentialFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
|
75
|
+
key: z.string().min(1),
|
|
76
|
+
principal: z.string().regex(/^[0-9a-f]{64}$/),
|
|
77
|
+
createdAt: z.string().datetime(),
|
|
78
|
+
})
|
|
79
|
+
.strict()
|
|
80
|
+
.optional(),
|
|
81
|
+
});
|
|
82
|
+
export class LocalSpaceStateConflictError extends Error {
|
|
83
|
+
name = "LocalSpaceStateConflictError";
|
|
84
|
+
}
|
|
85
|
+
export class LocalSpaceStateError extends Error {
|
|
86
|
+
path;
|
|
87
|
+
constructor(filePath, cause) {
|
|
88
|
+
super(`Checkout state at ${filePath} is invalid or unreadable. Spacefast refuses to guess. Fix or remove the file, then re-link with \`sf link\`.${cause instanceof Error ? ` ${cause.message}` : ""}`, cause === undefined ? undefined : { cause });
|
|
89
|
+
this.name = "LocalSpaceStateError";
|
|
90
|
+
this.path = filePath;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
42
93
|
export function localSpaceStateDir(root) {
|
|
43
94
|
return path.join(root, LOCAL_SPACE_STATE_DIR_NAME);
|
|
44
95
|
}
|
|
@@ -48,27 +99,53 @@ export function localSpaceStatePath(root) {
|
|
|
48
99
|
export function localSpaceLinkPath(root) {
|
|
49
100
|
return path.join(localSpaceStateDir(root), LOCAL_SPACE_LINK_FILE_NAME);
|
|
50
101
|
}
|
|
51
|
-
function legacyStatePath(root) {
|
|
52
|
-
return path.join(root, LEGACY_LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_STATE_FILE_NAME);
|
|
53
|
-
}
|
|
54
102
|
function legacyLinkPath(root) {
|
|
55
103
|
return path.join(root, LEGACY_LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME);
|
|
56
104
|
}
|
|
57
|
-
async function
|
|
58
|
-
const raw = await
|
|
105
|
+
async function readJsonFileWithRaw(root, relativePath) {
|
|
106
|
+
const raw = await secureReadFile({ root, relativePath, encoding: "utf8" }).catch((error) => {
|
|
107
|
+
if (error.code === "ENOENT")
|
|
108
|
+
return undefined;
|
|
109
|
+
throw error;
|
|
110
|
+
});
|
|
59
111
|
if (raw === undefined)
|
|
60
112
|
return undefined;
|
|
61
113
|
try {
|
|
62
|
-
return JSON.parse(raw);
|
|
114
|
+
return { raw, value: JSON.parse(raw) };
|
|
63
115
|
}
|
|
64
116
|
catch {
|
|
65
|
-
return undefined;
|
|
117
|
+
return { raw, value: undefined };
|
|
66
118
|
}
|
|
67
119
|
}
|
|
120
|
+
async function readJsonFile(root, relativePath) {
|
|
121
|
+
return (await readJsonFileWithRaw(root, relativePath))?.value;
|
|
122
|
+
}
|
|
68
123
|
function parseState(value) {
|
|
69
124
|
const parsed = localSpaceStateSchema.safeParse(value);
|
|
70
125
|
return parsed.success ? parsed.data : null;
|
|
71
126
|
}
|
|
127
|
+
async function readStateFileAttempt(root, relativePath, attempt) {
|
|
128
|
+
let file;
|
|
129
|
+
try {
|
|
130
|
+
file = await readJsonFileWithRaw(root, relativePath);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof SecureLocalFileConflictError && attempt < LOCAL_STATE_MUTATION_ATTEMPTS) {
|
|
134
|
+
await waitForMutationRetry(attempt);
|
|
135
|
+
return readStateFileAttempt(root, relativePath, attempt + 1);
|
|
136
|
+
}
|
|
137
|
+
throw new LocalSpaceStateError(path.join(root, relativePath), error);
|
|
138
|
+
}
|
|
139
|
+
if (!file)
|
|
140
|
+
return undefined;
|
|
141
|
+
const state = parseState(file.value);
|
|
142
|
+
if (!state)
|
|
143
|
+
throw new LocalSpaceStateError(path.join(root, relativePath));
|
|
144
|
+
return { raw: file.raw, state };
|
|
145
|
+
}
|
|
146
|
+
async function readStateFile(root, relativePath) {
|
|
147
|
+
return readStateFileAttempt(root, relativePath, 1);
|
|
148
|
+
}
|
|
72
149
|
function parseLink(value) {
|
|
73
150
|
const parsed = localSpaceLinkSchema.safeParse(value);
|
|
74
151
|
return parsed.success ? parsed.data : null;
|
|
@@ -84,21 +161,29 @@ export async function readLocalSpaceLink(root) {
|
|
|
84
161
|
* legacy `.stattic/space.json` path to users so migrations stay visible.
|
|
85
162
|
*/
|
|
86
163
|
export async function readLocalSpaceLinkWithPath(root) {
|
|
87
|
-
const primary = parseLink(await readJsonFile(
|
|
164
|
+
const primary = parseLink(await readJsonFile(root, path.join(LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME)));
|
|
88
165
|
if (primary) {
|
|
89
166
|
return { link: primary, path: localSpaceLinkPath(root) };
|
|
90
167
|
}
|
|
91
|
-
const legacy = parseLink(await readJsonFile(
|
|
168
|
+
const legacy = parseLink(await readJsonFile(root, path.join(LEGACY_LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME)));
|
|
92
169
|
return legacy ? { link: legacy, path: legacyLinkPath(root) } : null;
|
|
93
170
|
}
|
|
94
171
|
export async function readLocalSpaceStateAt(root) {
|
|
95
172
|
const statePath = localSpaceStatePath(root);
|
|
96
|
-
const
|
|
97
|
-
|
|
173
|
+
const primaryState = await readStateFile(root, path.join(LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_STATE_FILE_NAME));
|
|
174
|
+
const legacyState = primaryState
|
|
175
|
+
? undefined
|
|
176
|
+
: await readStateFile(root, path.join(LEGACY_LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_STATE_FILE_NAME));
|
|
177
|
+
const state = primaryState?.state ?? legacyState?.state ?? null;
|
|
98
178
|
const linkPath = localSpaceLinkPath(root);
|
|
99
|
-
const link = parseLink(await readJsonFile(
|
|
179
|
+
const link = parseLink(await readJsonFile(root, path.join(LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME))) ??
|
|
180
|
+
parseLink(await readJsonFile(root, path.join(LEGACY_LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME)));
|
|
100
181
|
if (!state && !link)
|
|
101
182
|
return null;
|
|
183
|
+
const committedSpaceId = linkSpaceId(link);
|
|
184
|
+
if (state?.spaceId && committedSpaceId && state.spaceId !== committedSpaceId) {
|
|
185
|
+
throw new LocalSpaceStateError(statePath, new Error(`The committed link selects ${committedSpaceId}, but secret state belongs to ${state.spaceId}.`));
|
|
186
|
+
}
|
|
102
187
|
return {
|
|
103
188
|
root,
|
|
104
189
|
statePath: state ? statePath : null,
|
|
@@ -112,6 +197,7 @@ export async function readLocalSpaceStateAt(root) {
|
|
|
112
197
|
claimUrl: state?.claimUrl,
|
|
113
198
|
expiresAt: state?.expiresAt,
|
|
114
199
|
lastVersionId: state?.lastVersionId,
|
|
200
|
+
pendingOperationId: state?.pendingOperationId,
|
|
115
201
|
};
|
|
116
202
|
}
|
|
117
203
|
function isWithinBoundary(current, boundary) {
|
|
@@ -148,36 +234,68 @@ export async function findUpward(probe, startDir = process.cwd(), boundary) {
|
|
|
148
234
|
export async function discoverLocalSpaceState(startDir = process.cwd(), boundary) {
|
|
149
235
|
return findUpward(readLocalSpaceStateAt, startDir, boundary);
|
|
150
236
|
}
|
|
151
|
-
async function writeJsonAtomic(
|
|
152
|
-
await
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
|
|
237
|
+
async function writeJsonAtomic(input) {
|
|
238
|
+
await secureWriteFile({
|
|
239
|
+
root: input.root,
|
|
240
|
+
relativePath: input.relativePath,
|
|
241
|
+
data: `${JSON.stringify(input.value, null, 2)}\n`,
|
|
242
|
+
overwrite: true,
|
|
243
|
+
mode: input.mode ?? 0o644,
|
|
244
|
+
...(input.expectedContent === undefined ? {} : { expectedContent: input.expectedContent }),
|
|
245
|
+
...(input.beforeCommit ? { beforeCommit: input.beforeCommit } : {}),
|
|
246
|
+
...(input.afterBackup ? { afterBackup: input.afterBackup } : {}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const LOCAL_STATE_MUTATION_ATTEMPTS = 250;
|
|
250
|
+
async function waitForMutationRetry(attempt) {
|
|
251
|
+
await delay(Math.min(10, Math.max(1, attempt)));
|
|
252
|
+
}
|
|
253
|
+
async function ensureStateGitignored(root, onConflict) {
|
|
254
|
+
const entries = [
|
|
255
|
+
`${LOCAL_SPACE_STATE_DIR_NAME}/${LOCAL_SPACE_STATE_FILE_NAME}`,
|
|
256
|
+
`/.spacefast-*`,
|
|
257
|
+
`${LOCAL_SPACE_STATE_DIR_NAME}/.spacefast-*`,
|
|
258
|
+
`${LOCAL_SPACE_STATE_DIR_NAME}/.space-link-*.tmp`,
|
|
259
|
+
];
|
|
260
|
+
/* eslint-disable no-await-in-loop -- each optimistic write retry must re-read the latest file before deciding whether another attempt is safe. */
|
|
261
|
+
for (let attempt = 1; attempt <= LOCAL_STATE_MUTATION_ATTEMPTS; attempt += 1) {
|
|
262
|
+
try {
|
|
263
|
+
const existing = await secureReadFile({
|
|
264
|
+
root,
|
|
265
|
+
relativePath: ".gitignore",
|
|
266
|
+
encoding: "utf8",
|
|
267
|
+
}).catch((error) => {
|
|
268
|
+
if (error.code === "ENOENT")
|
|
269
|
+
return null;
|
|
270
|
+
throw error;
|
|
271
|
+
});
|
|
272
|
+
const raw = existing ?? "";
|
|
273
|
+
const lines = raw.split(/\r?\n/);
|
|
274
|
+
const missing = entries.filter((entry) => !lines.includes(entry));
|
|
275
|
+
if (missing.length === 0)
|
|
276
|
+
return;
|
|
277
|
+
const prefix = raw.length > 0 && !raw.endsWith("\n") ? "\n" : "";
|
|
278
|
+
await secureWriteFile({
|
|
279
|
+
root,
|
|
280
|
+
relativePath: ".gitignore",
|
|
281
|
+
data: `${raw}${prefix}${missing.join("\n")}\n`,
|
|
282
|
+
overwrite: existing !== null,
|
|
283
|
+
mode: 0o644,
|
|
284
|
+
expectedContent: existing,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
161
287
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error instanceof SecureLocalFileConflictError)
|
|
290
|
+
await onConflict?.();
|
|
291
|
+
if (!(error instanceof SecureLocalFileConflictError) ||
|
|
292
|
+
attempt === LOCAL_STATE_MUTATION_ATTEMPTS) {
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
await waitForMutationRetry(attempt);
|
|
165
296
|
}
|
|
166
297
|
}
|
|
167
|
-
|
|
168
|
-
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
169
|
-
throw error;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
async function ensureStateGitignored(root) {
|
|
173
|
-
const gitignorePath = path.join(root, ".gitignore");
|
|
174
|
-
const entry = `${LOCAL_SPACE_STATE_DIR_NAME}/${LOCAL_SPACE_STATE_FILE_NAME}`;
|
|
175
|
-
const raw = await readFile(gitignorePath, "utf8").catch(() => "");
|
|
176
|
-
const lines = raw.split(/\r?\n/);
|
|
177
|
-
if (lines.includes(entry))
|
|
178
|
-
return;
|
|
179
|
-
const prefix = raw.length > 0 && !raw.endsWith("\n") ? "\n" : "";
|
|
180
|
-
await writeFile(gitignorePath, `${raw}${prefix}${entry}\n`, "utf8");
|
|
298
|
+
/* eslint-enable no-await-in-loop */
|
|
181
299
|
}
|
|
182
300
|
function assignDefined(target, input) {
|
|
183
301
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -203,50 +321,233 @@ export async function writeLocalSpaceLink(root, link) {
|
|
|
203
321
|
...(link.team ? { team: link.team } : {}),
|
|
204
322
|
...(link.apiBaseUrl ? { apiBaseUrl: link.apiBaseUrl } : {}),
|
|
205
323
|
});
|
|
206
|
-
await writeJsonAtomic(
|
|
324
|
+
await writeJsonAtomic({
|
|
325
|
+
root,
|
|
326
|
+
relativePath: path.join(LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME),
|
|
327
|
+
value: payload,
|
|
328
|
+
});
|
|
207
329
|
return filePath;
|
|
208
330
|
}
|
|
331
|
+
export async function mutateLocalSpaceState(root, mutate, options = {}) {
|
|
332
|
+
const stateRelativePath = path.join(LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_STATE_FILE_NAME);
|
|
333
|
+
await ensureStateGitignored(root, options.onConflict);
|
|
334
|
+
/* eslint-disable no-await-in-loop -- compare-and-swap retries are intentionally serialized against the latest committed state. */
|
|
335
|
+
for (let attempt = 1; attempt <= LOCAL_STATE_MUTATION_ATTEMPTS; attempt += 1) {
|
|
336
|
+
try {
|
|
337
|
+
const primaryState = await readJsonFileWithRaw(root, stateRelativePath);
|
|
338
|
+
const legacyState = primaryState
|
|
339
|
+
? undefined
|
|
340
|
+
: await readJsonFileWithRaw(root, path.join(LEGACY_LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_STATE_FILE_NAME));
|
|
341
|
+
const source = primaryState ?? legacyState;
|
|
342
|
+
const parsed = source ? localSpaceStateSchema.safeParse(source.value) : null;
|
|
343
|
+
if (source && !parsed?.success) {
|
|
344
|
+
throw new Error(`Local Spacefast state at ${localSpaceStatePath(root)} is invalid.`);
|
|
345
|
+
}
|
|
346
|
+
const current = parsed?.success
|
|
347
|
+
? parsed.data
|
|
348
|
+
: { schemaVersion: LOCAL_SPACE_STATE_SCHEMA_VERSION };
|
|
349
|
+
// The callback may run again after a compare-and-swap collision. Keeping it
|
|
350
|
+
// synchronous makes remote effects and other one-shot work impossible here.
|
|
351
|
+
const payload = localSpaceStateSchema.parse({
|
|
352
|
+
...mutate(structuredClone(current)),
|
|
353
|
+
schemaVersion: LOCAL_SPACE_STATE_SCHEMA_VERSION,
|
|
354
|
+
});
|
|
355
|
+
const serialized = `${JSON.stringify(payload, null, 2)}\n`;
|
|
356
|
+
if (primaryState?.raw === serialized)
|
|
357
|
+
return payload;
|
|
358
|
+
await writeJsonAtomic({
|
|
359
|
+
root,
|
|
360
|
+
relativePath: stateRelativePath,
|
|
361
|
+
value: payload,
|
|
362
|
+
mode: 0o600,
|
|
363
|
+
expectedContent: primaryState?.raw ?? null,
|
|
364
|
+
...(options.beforeCommit ? { beforeCommit: options.beforeCommit } : {}),
|
|
365
|
+
...(options.afterBackup ? { afterBackup: options.afterBackup } : {}),
|
|
366
|
+
});
|
|
367
|
+
return payload;
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error instanceof SecureLocalFileConflictError)
|
|
371
|
+
await options.onConflict?.();
|
|
372
|
+
if (!(error instanceof SecureLocalFileConflictError) ||
|
|
373
|
+
attempt === LOCAL_STATE_MUTATION_ATTEMPTS) {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
await waitForMutationRetry(attempt);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/* eslint-enable no-await-in-loop */
|
|
380
|
+
throw new LocalSpaceStateConflictError("Local Spacefast state stayed busy for too long.");
|
|
381
|
+
}
|
|
209
382
|
export async function writeLocalSpaceState(root, state) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
await writeJsonAtomic(filePath, payload, 0o600);
|
|
213
|
-
await ensureStateGitignored(root);
|
|
214
|
-
return filePath;
|
|
383
|
+
await mutateLocalSpaceState(root, () => state);
|
|
384
|
+
return localSpaceStatePath(root);
|
|
215
385
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
386
|
+
function credentialFingerprint(credential) {
|
|
387
|
+
return createHash("sha256").update(credential).digest("hex");
|
|
388
|
+
}
|
|
389
|
+
function sameCredentialReplay(left, right) {
|
|
390
|
+
return (left?.operation === right.operation &&
|
|
391
|
+
left.credentialFingerprint === right.credentialFingerprint &&
|
|
392
|
+
left.key === right.key &&
|
|
393
|
+
left.principal === right.principal);
|
|
394
|
+
}
|
|
395
|
+
/** Reserve random replay custody before a claim token can be consumed. */
|
|
396
|
+
export async function reserveLocalCredentialReplay(root, input) {
|
|
397
|
+
const identity = createOneShotReplayIdentity(`sf-${input.operation}`);
|
|
398
|
+
const candidate = {
|
|
399
|
+
operation: input.operation,
|
|
400
|
+
credentialFingerprint: credentialFingerprint(input.claimToken),
|
|
401
|
+
...identity,
|
|
402
|
+
createdAt: new Date().toISOString(),
|
|
403
|
+
};
|
|
404
|
+
let reserved = candidate;
|
|
405
|
+
await mutateLocalSpaceState(root, (current) => {
|
|
406
|
+
if (current.claimToken !== input.claimToken) {
|
|
407
|
+
const mayReserveExplicitCredential = input.allowUnstoredClaimToken && !current.claimToken && !current.accessToken;
|
|
408
|
+
if (!mayReserveExplicitCredential) {
|
|
409
|
+
throw new LocalSpaceStateConflictError("The checkout claim credential changed before Spacefast reserved its replay identity.");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const existing = current.pendingCredentialReplay;
|
|
413
|
+
if (existing) {
|
|
414
|
+
if (existing.operation === candidate.operation &&
|
|
415
|
+
existing.credentialFingerprint === candidate.credentialFingerprint) {
|
|
416
|
+
reserved = existing;
|
|
417
|
+
return current;
|
|
418
|
+
}
|
|
419
|
+
throw new LocalSpaceStateConflictError("Another checkout credential transition is still unresolved.");
|
|
420
|
+
}
|
|
421
|
+
return { ...current, pendingCredentialReplay: candidate };
|
|
228
422
|
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
423
|
+
return reserved;
|
|
424
|
+
}
|
|
425
|
+
function credentialMatches(state, expected) {
|
|
426
|
+
return state.claimToken === expected.claimToken && state.accessToken === expected.accessToken;
|
|
427
|
+
}
|
|
428
|
+
export async function saveLocalSpaceState(root, input) {
|
|
429
|
+
await mutateLocalSpaceState(root, (existing) => {
|
|
430
|
+
if (existing.spaceId && existing.spaceId !== input.spaceId && !input.replaceSpace) {
|
|
431
|
+
throw new LocalSpaceStateConflictError(`Local Spacefast state changed from ${input.spaceId} to ${existing.spaceId}.`);
|
|
432
|
+
}
|
|
433
|
+
const replacingSpace = input.replaceSpace && existing.spaceId !== input.spaceId;
|
|
434
|
+
const credentialBase = replacingSpace
|
|
435
|
+
? { schemaVersion: LOCAL_SPACE_STATE_SCHEMA_VERSION }
|
|
436
|
+
: existing;
|
|
437
|
+
const next = { ...existing };
|
|
438
|
+
if (replacingSpace) {
|
|
439
|
+
for (const key of [
|
|
440
|
+
"spaceId",
|
|
441
|
+
"apiUrl",
|
|
442
|
+
"teamSlug",
|
|
443
|
+
"claimToken",
|
|
444
|
+
"claimUrl",
|
|
445
|
+
"expiresAt",
|
|
446
|
+
"accessToken",
|
|
447
|
+
"lastVersionId",
|
|
448
|
+
"pendingOperationId",
|
|
449
|
+
"lastVersionUrl",
|
|
450
|
+
"lastSpaceUrl",
|
|
451
|
+
"pendingPublish",
|
|
452
|
+
"pendingCredentialReplay",
|
|
453
|
+
]) {
|
|
454
|
+
delete next[key];
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
assignPresent(next, {
|
|
458
|
+
spaceId: input.spaceId,
|
|
459
|
+
apiUrl: input.apiUrl,
|
|
460
|
+
teamSlug: input.teamSlug,
|
|
461
|
+
lastVersionId: input.lastVersionId,
|
|
462
|
+
pendingOperationId: input.pendingOperationId,
|
|
463
|
+
lastVersionUrl: input.lastVersionUrl,
|
|
464
|
+
lastSpaceUrl: input.lastSpaceUrl,
|
|
235
465
|
});
|
|
466
|
+
const transitionAlreadyApplied = (input.accessToken !== undefined && credentialBase.accessToken === input.accessToken) ||
|
|
467
|
+
(input.claimToken !== undefined && credentialBase.claimToken === input.claimToken);
|
|
468
|
+
if (input.credentialReplay &&
|
|
469
|
+
!sameCredentialReplay(credentialBase.pendingCredentialReplay, input.credentialReplay) &&
|
|
470
|
+
!transitionAlreadyApplied) {
|
|
471
|
+
throw new LocalSpaceStateConflictError("The checkout replay reservation changed while Spacefast was saving its replacement.");
|
|
472
|
+
}
|
|
473
|
+
if (input.expectedCredential &&
|
|
474
|
+
!credentialMatches(credentialBase, input.expectedCredential) &&
|
|
475
|
+
!transitionAlreadyApplied) {
|
|
476
|
+
throw new LocalSpaceStateConflictError("The checkout credential changed while Spacefast was saving its replacement.");
|
|
477
|
+
}
|
|
478
|
+
const maySetCredential = input.expectedCredential
|
|
479
|
+
? credentialMatches(credentialBase, input.expectedCredential) || transitionAlreadyApplied
|
|
480
|
+
: !credentialBase.claimToken && !credentialBase.accessToken;
|
|
481
|
+
let credentialTransitionCommitted = false;
|
|
482
|
+
if (input.accessToken &&
|
|
483
|
+
(maySetCredential || credentialBase.accessToken === input.accessToken)) {
|
|
484
|
+
assignDefined(next, {
|
|
485
|
+
accessToken: input.accessToken,
|
|
486
|
+
claimToken: undefined,
|
|
487
|
+
claimUrl: undefined,
|
|
488
|
+
expiresAt: undefined,
|
|
489
|
+
});
|
|
490
|
+
credentialTransitionCommitted = true;
|
|
491
|
+
}
|
|
492
|
+
else if (input.claimToken &&
|
|
493
|
+
(maySetCredential || credentialBase.claimToken === input.claimToken)) {
|
|
494
|
+
assignDefined(next, {
|
|
495
|
+
claimToken: input.claimToken,
|
|
496
|
+
claimUrl: input.claimUrl,
|
|
497
|
+
expiresAt: input.expiresAt,
|
|
498
|
+
accessToken: undefined,
|
|
499
|
+
});
|
|
500
|
+
credentialTransitionCommitted = true;
|
|
501
|
+
}
|
|
502
|
+
if (input.credentialReplay && credentialTransitionCommitted) {
|
|
503
|
+
delete next.pendingCredentialReplay;
|
|
504
|
+
}
|
|
505
|
+
return localSpaceStateSchema.parse(next);
|
|
506
|
+
});
|
|
507
|
+
const statePath = localSpaceStatePath(root);
|
|
508
|
+
const linkRelativePath = path.join(LOCAL_SPACE_STATE_DIR_NAME, LOCAL_SPACE_LINK_FILE_NAME);
|
|
509
|
+
const desiredLink = localSpaceLinkSchema.parse({
|
|
510
|
+
space: input.spaceId,
|
|
511
|
+
apiBaseUrl: input.apiUrl,
|
|
512
|
+
...(input.teamSlug ? { team: input.teamSlug } : {}),
|
|
513
|
+
});
|
|
514
|
+
const primaryLinkFile = await readJsonFileWithRaw(root, linkRelativePath);
|
|
515
|
+
const primaryLink = primaryLinkFile ? parseLink(primaryLinkFile.value) : null;
|
|
516
|
+
if (primaryLinkFile && !primaryLink) {
|
|
517
|
+
throw new LocalSpaceStateError(localSpaceLinkPath(root));
|
|
236
518
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
519
|
+
let wroteLink = false;
|
|
520
|
+
const mustWriteLink = !primaryLink || (input.replaceSpace && primaryLink.space !== input.spaceId);
|
|
521
|
+
if (mustWriteLink) {
|
|
522
|
+
if (!input.replaceSpace && !primaryLink) {
|
|
523
|
+
const inheritedLink = await readLocalSpaceLink(root);
|
|
524
|
+
if (inheritedLink && inheritedLink.space !== input.spaceId) {
|
|
525
|
+
throw new LocalSpaceStateConflictError(`The committed checkout link selects ${inheritedLink.space}, not ${input.spaceId}.`);
|
|
526
|
+
}
|
|
527
|
+
if (inheritedLink) {
|
|
528
|
+
return { statePath, linkPath: localSpaceLinkPath(root), wroteLink };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await writeJsonAtomic({
|
|
533
|
+
root,
|
|
534
|
+
relativePath: linkRelativePath,
|
|
535
|
+
value: desiredLink,
|
|
536
|
+
expectedContent: primaryLinkFile?.raw ?? null,
|
|
537
|
+
});
|
|
538
|
+
wroteLink = true;
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (!(error instanceof SecureLocalFileConflictError))
|
|
542
|
+
throw error;
|
|
543
|
+
const winner = await readLocalSpaceLink(root);
|
|
544
|
+
if (winner?.space !== input.spaceId) {
|
|
545
|
+
throw new LocalSpaceStateConflictError(`The committed checkout link changed before Spacefast could select ${input.spaceId}.`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
244
548
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const wroteLink = !existingLink;
|
|
248
|
-
if (!existingLink) {
|
|
249
|
-
await writeLocalSpaceLink(root, { space: input.spaceId, apiBaseUrl: input.apiUrl });
|
|
549
|
+
else if (primaryLink.space !== input.spaceId) {
|
|
550
|
+
throw new LocalSpaceStateConflictError(`The committed checkout link selects ${primaryLink.space}, not ${input.spaceId}.`);
|
|
250
551
|
}
|
|
251
552
|
return { statePath, linkPath: localSpaceLinkPath(root), wroteLink };
|
|
252
553
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type OneShotReplayIdentity = {
|
|
2
|
+
key: string;
|
|
3
|
+
principal: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Creates an unguessable capability for replaying one shown-once response.
|
|
7
|
+
*
|
|
8
|
+
* Neither value is derived from the credential consumed by the mutation. A
|
|
9
|
+
* caller holding only that old credential therefore cannot locate the stored
|
|
10
|
+
* response after the credential is revoked.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createOneShotReplayIdentity(prefix: string): OneShotReplayIdentity;
|
|
13
|
+
/** Production headers for one one-shot attempt (or one device-poll sequence). */
|
|
14
|
+
export declare function oneShotReplayHeaders(identity: OneShotReplayIdentity, sequence?: number): Record<"idempotency-key" | "x-spacefast-idempotency-principal", string>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Creates an unguessable capability for replaying one shown-once response.
|
|
4
|
+
*
|
|
5
|
+
* Neither value is derived from the credential consumed by the mutation. A
|
|
6
|
+
* caller holding only that old credential therefore cannot locate the stored
|
|
7
|
+
* response after the credential is revoked.
|
|
8
|
+
*/
|
|
9
|
+
export function createOneShotReplayIdentity(prefix) {
|
|
10
|
+
return {
|
|
11
|
+
key: `${prefix}-${randomBytes(32).toString("hex")}`,
|
|
12
|
+
principal: randomBytes(32).toString("hex"),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Production headers for one one-shot attempt (or one device-poll sequence). */
|
|
16
|
+
export function oneShotReplayHeaders(identity, sequence) {
|
|
17
|
+
return {
|
|
18
|
+
"idempotency-key": sequence === undefined ? identity.key : `${identity.key}-${sequence}`,
|
|
19
|
+
"x-spacefast-idempotency-principal": identity.principal,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One URL, one representation. A file's bare path always serves its bytes;
|
|
3
|
+
* the preview page is reachable only under this query flag.
|
|
4
|
+
*/
|
|
5
|
+
export declare const PAGE_PREVIEW_QUERY = "spacefast_view";
|
|
6
|
+
/** The preview page for a file whose bytes live at `fileHref`. */
|
|
7
|
+
export declare function previewPageHref(fileHref: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One URL, one representation. A file's bare path always serves its bytes;
|
|
3
|
+
* the preview page is reachable only under this query flag.
|
|
4
|
+
*/
|
|
5
|
+
export const PAGE_PREVIEW_QUERY = "spacefast_view";
|
|
6
|
+
/** The preview page for a file whose bytes live at `fileHref`. */
|
|
7
|
+
export function previewPageHref(fileHref) {
|
|
8
|
+
return `${fileHref}${fileHref.includes("?") ? "&" : "?"}${PAGE_PREVIEW_QUERY}=1`;
|
|
9
|
+
}
|
package/dist/utils/pages.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CustomPageId, type SitePageId } from "../contracts/pages.js";
|
|
2
|
+
export { PAGE_PREVIEW_QUERY, previewPageHref } from "./page-preview.js";
|
|
2
3
|
export type PageTheme = {
|
|
3
4
|
accent?: string | undefined;
|
|
4
5
|
background?: string | undefined;
|
|
@@ -19,16 +20,6 @@ export type PagePreviewFile = {
|
|
|
19
20
|
size?: string;
|
|
20
21
|
mime?: string;
|
|
21
22
|
};
|
|
22
|
-
/**
|
|
23
|
-
* One URL, one representation. A file's bare path always serves its bytes;
|
|
24
|
-
* the preview page is reachable only under this query flag. The alternative —
|
|
25
|
-
* negotiating on `Accept` at the bare path — depends on `Vary`, which the edge
|
|
26
|
-
* ignores, so whichever client arrived first pinned that representation for
|
|
27
|
-
* everyone (an `<img>` could poison the human's link for a year).
|
|
28
|
-
*/
|
|
29
|
-
export declare const PAGE_PREVIEW_QUERY = "spacefast_view";
|
|
30
|
-
/** The preview page for a file whose bytes live at `fileHref`. */
|
|
31
|
-
export declare function previewPageHref(fileHref: string): string;
|
|
32
23
|
export type PageExpansionContext = {
|
|
33
24
|
page: SitePageId | "layout";
|
|
34
25
|
theme?: PageTheme;
|
|
@@ -44,7 +35,7 @@ export type PageExpansionContext = {
|
|
|
44
35
|
};
|
|
45
36
|
export type PageDiagnostic = {
|
|
46
37
|
severity: "warning" | "error";
|
|
47
|
-
code: "page_source_too_large" | "unknown_page_template" | "unknown_page_element" | "page_element_not_allowed" | "page_document_required" | "layout_missing_content" | "index_files_missing" | "page_indexable";
|
|
38
|
+
code: "page_source_too_large" | "unknown_page_template" | "unknown_page_element" | "page_element_not_allowed" | "page_document_required" | "layout_missing_content" | "index_files_missing" | "access_lanes_missing" | "access_csp_form_action_missing_self" | "page_indexable";
|
|
48
39
|
message: string;
|
|
49
40
|
path: string;
|
|
50
41
|
};
|