@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.
Files changed (154) hide show
  1. package/dist/agents/client-registry.d.ts +22 -0
  2. package/dist/agents/client-registry.js +41 -0
  3. package/dist/agents/connect-targets.d.ts +51 -6
  4. package/dist/agents/connect-targets.js +93 -28
  5. package/dist/brand.d.ts +0 -11
  6. package/dist/brand.js +0 -20
  7. package/dist/config/domains.d.ts +15 -6
  8. package/dist/config/domains.js +24 -10
  9. package/dist/config/postgres-budget.d.ts +3 -0
  10. package/dist/config/postgres-budget.js +6 -1
  11. package/dist/config/postgres-budget.json +2 -1
  12. package/dist/contracts/abuse.js +1 -1
  13. package/dist/contracts/access.d.ts +149 -44
  14. package/dist/contracts/access.js +94 -8
  15. package/dist/contracts/activity.js +5 -1
  16. package/dist/contracts/api-keys.js +9 -9
  17. package/dist/contracts/archives.js +11 -11
  18. package/dist/contracts/beta.js +1 -1
  19. package/dist/contracts/builds.js +14 -14
  20. package/dist/contracts/channels.js +2 -2
  21. package/dist/contracts/comments.d.ts +86 -15
  22. package/dist/contracts/comments.js +116 -20
  23. package/dist/contracts/common.d.ts +12 -12
  24. package/dist/contracts/common.js +33 -30
  25. package/dist/contracts/continuation.js +5 -5
  26. package/dist/contracts/data-source-routes.js +1 -1
  27. package/dist/contracts/device-auth.d.ts +6 -6
  28. package/dist/contracts/device-auth.js +12 -12
  29. package/dist/contracts/docs.d.ts +10 -10
  30. package/dist/contracts/domains.js +39 -33
  31. package/dist/contracts/enums.d.ts +2 -10
  32. package/dist/contracts/enums.js +0 -2
  33. package/dist/contracts/error-code-meta.d.ts +116 -47
  34. package/dist/contracts/error-code-meta.js +37 -16
  35. package/dist/contracts/error-codes.d.ts +7 -4
  36. package/dist/contracts/error-codes.js +46 -21
  37. package/dist/contracts/events.d.ts +10 -3
  38. package/dist/contracts/events.js +33 -5
  39. package/dist/contracts/execution.d.ts +26 -0
  40. package/dist/contracts/execution.js +21 -0
  41. package/dist/contracts/feature-lifecycle.d.ts +21 -24
  42. package/dist/contracts/feature-lifecycle.js +28 -26
  43. package/dist/contracts/features.js +4 -4
  44. package/dist/contracts/functions.d.ts +8 -140
  45. package/dist/contracts/functions.js +9 -19
  46. package/dist/contracts/git.d.ts +1 -1
  47. package/dist/contracts/grants.d.ts +96 -79
  48. package/dist/contracts/grants.js +130 -43
  49. package/dist/contracts/ids.d.ts +2 -2
  50. package/dist/contracts/ids.js +2 -4
  51. package/dist/contracts/internal.js +2 -2
  52. package/dist/contracts/mcp.d.ts +17 -556
  53. package/dist/contracts/mcp.js +9 -103
  54. package/dist/contracts/me.js +3 -4
  55. package/dist/contracts/notifications.d.ts +108 -0
  56. package/dist/contracts/notifications.js +54 -0
  57. package/dist/contracts/oauth-resources.d.ts +18 -1
  58. package/dist/contracts/oauth-resources.js +22 -2
  59. package/dist/contracts/operations.d.ts +2 -50
  60. package/dist/contracts/operations.js +0 -19
  61. package/dist/contracts/pages.d.ts +8 -4
  62. package/dist/contracts/pages.js +16 -3
  63. package/dist/contracts/platform.js +10 -8
  64. package/dist/contracts/problem-document.d.ts +14 -0
  65. package/dist/contracts/problem-document.js +35 -0
  66. package/dist/contracts/push-new.d.ts +21 -6
  67. package/dist/contracts/push-new.js +3 -7
  68. package/dist/contracts/resources.d.ts +8 -7
  69. package/dist/contracts/resources.js +9 -6
  70. package/dist/contracts/runtime-api.d.ts +162 -92
  71. package/dist/contracts/runtime-api.js +99 -27
  72. package/dist/contracts/runtime-app.d.ts +41 -11
  73. package/dist/contracts/runtime-app.js +22 -1
  74. package/dist/contracts/runtime-db.d.ts +424 -0
  75. package/dist/contracts/runtime-db.js +108 -0
  76. package/dist/contracts/runtime-storage.d.ts +120 -0
  77. package/dist/contracts/runtime-storage.js +40 -0
  78. package/dist/contracts/sf-config-v1.d.ts +49 -1
  79. package/dist/contracts/sf-config-v1.js +2 -2
  80. package/dist/contracts/sites.d.ts +1 -0
  81. package/dist/contracts/sites.js +20 -12
  82. package/dist/contracts/space-config.d.ts +62 -22
  83. package/dist/contracts/space-config.js +173 -47
  84. package/dist/contracts/spaces.d.ts +79 -45
  85. package/dist/contracts/spaces.js +66 -50
  86. package/dist/contracts/superadmin-spaces.d.ts +13 -2
  87. package/dist/contracts/superadmin-spaces.js +15 -0
  88. package/dist/contracts/superadmin-tenants.js +4 -4
  89. package/dist/contracts/superadmin.d.ts +4 -0
  90. package/dist/contracts/superadmin.js +14 -1
  91. package/dist/contracts/tags.d.ts +17 -17
  92. package/dist/contracts/tags.js +1 -1
  93. package/dist/contracts/teams.js +25 -16
  94. package/dist/contracts/theme-json.js +1 -1
  95. package/dist/contracts/transfers.js +4 -4
  96. package/dist/contracts/variables.js +3 -3
  97. package/dist/contracts/zero.d.ts +204 -338
  98. package/dist/contracts/zero.js +94 -98
  99. package/dist/dashboard-paths/index.d.ts +16 -1
  100. package/dist/dashboard-paths/index.js +18 -0
  101. package/dist/docs/agent-handoff-document.d.ts +2 -2
  102. package/dist/docs/agent-handoff-document.js +8 -7
  103. package/dist/docs/agent-prose.d.ts +22 -3
  104. package/dist/docs/agent-prose.js +221 -51
  105. package/dist/docs/agent-setup.d.ts +13 -10
  106. package/dist/docs/agent-setup.js +279 -53
  107. package/dist/docs/agent-solutions.d.ts +6 -6
  108. package/dist/docs/agent-solutions.js +23 -21
  109. package/dist/docs/catalog.d.ts +29 -74
  110. package/dist/docs/catalog.js +65 -92
  111. package/dist/docs/error-docs.js +293 -221
  112. package/dist/docs/skill-distribution.d.ts +2 -0
  113. package/dist/docs/skill-distribution.js +7 -4
  114. package/dist/docs/skills.d.ts +546 -0
  115. package/dist/docs/skills.js +340 -0
  116. package/dist/utils/browser-credential.d.ts +19 -0
  117. package/dist/utils/browser-credential.js +62 -0
  118. package/dist/utils/comment-avatars.d.ts +7 -0
  119. package/dist/utils/comment-avatars.js +42 -0
  120. package/dist/utils/content-type.d.ts +3 -3
  121. package/dist/utils/content-type.js +38 -61
  122. package/dist/utils/domain-diagnostics.d.ts +5 -5
  123. package/dist/utils/domain-diagnostics.js +21 -10
  124. package/dist/utils/local-space-state.d.ts +78 -4
  125. package/dist/utils/local-space-state.js +383 -82
  126. package/dist/utils/one-shot-replay.d.ts +14 -0
  127. package/dist/utils/one-shot-replay.js +21 -0
  128. package/dist/utils/page-preview.d.ts +7 -0
  129. package/dist/utils/page-preview.js +9 -0
  130. package/dist/utils/pages.d.ts +2 -11
  131. package/dist/utils/pages.js +58 -14
  132. package/dist/utils/publish-form-data.d.ts +22 -0
  133. package/dist/utils/publish-form-data.js +26 -0
  134. package/dist/utils/publish-policy.js +20 -4
  135. package/dist/utils/query-keys.d.ts +7 -4
  136. package/dist/utils/query-keys.js +13 -4
  137. package/dist/utils/runtime-paths.d.ts +3 -0
  138. package/dist/utils/runtime-paths.js +7 -0
  139. package/dist/utils/secure-local-file.d.ts +73 -0
  140. package/dist/utils/secure-local-file.js +1373 -0
  141. package/dist/utils/space-config.d.ts +13 -4
  142. package/dist/utils/space-config.js +123 -15
  143. package/dist/utils/storage-policy.d.ts +20 -0
  144. package/dist/utils/storage-policy.fixtures.json +174 -0
  145. package/dist/utils/storage-policy.js +193 -0
  146. package/dist/utils/upload-session.d.ts +12 -1
  147. package/dist/utils/upload-session.js +37 -2
  148. package/dist/utils/version-file-browser.d.ts +13 -0
  149. package/dist/utils/version-file-browser.js +59 -0
  150. package/package.json +19 -1
  151. package/dist/contracts/mounts.d.ts +0 -142
  152. package/dist/contracts/mounts.js +0 -56
  153. package/dist/utils/asset-fingerprint.d.ts +0 -64
  154. package/dist/utils/asset-fingerprint.js +0 -620
@@ -1,6 +1,9 @@
1
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
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
- * so newer tools preserve unknown fields from older/newer tools.
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 localSpaceStateSchema = z
29
- .object({
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
- .loose();
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 readJsonFile(filePath) {
58
- const raw = await readFile(filePath, "utf8").catch(() => undefined);
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(localSpaceLinkPath(root)));
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(legacyLinkPath(root)));
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 state = parseState(await readJsonFile(statePath)) ??
97
- parseState(await readJsonFile(legacyStatePath(root)));
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(linkPath)) ?? parseLink(await readJsonFile(legacyLinkPath(root)));
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(filePath, value, mode) {
152
- await mkdir(path.dirname(filePath), { recursive: true });
153
- const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
154
- try {
155
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
156
- encoding: "utf8",
157
- ...(mode ? { mode } : {}),
158
- });
159
- if (mode) {
160
- await chmod(tempPath, mode).catch(() => undefined);
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
- await rename(tempPath, filePath);
163
- if (mode) {
164
- await chmod(filePath, mode).catch(() => undefined);
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
- catch (error) {
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(filePath, payload);
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
- const filePath = localSpaceStatePath(root);
211
- const payload = localSpaceStateSchema.parse(state);
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
- export async function saveLocalSpaceState(root, input) {
217
- const existing = parseState(await readJsonFile(localSpaceStatePath(root))) ??
218
- parseState(await readJsonFile(legacyStatePath(root))) ??
219
- {};
220
- const next = { ...existing };
221
- assignPresent(next, {
222
- spaceId: input.spaceId,
223
- apiUrl: input.apiUrl,
224
- teamSlug: input.teamSlug,
225
- lastVersionId: input.lastVersionId,
226
- lastVersionUrl: input.lastVersionUrl,
227
- lastSpaceUrl: input.lastSpaceUrl,
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
- if (input.accessToken) {
230
- assignDefined(next, {
231
- accessToken: input.accessToken,
232
- claimToken: undefined,
233
- claimUrl: undefined,
234
- expiresAt: undefined,
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
- else if (input.claimToken) {
238
- assignDefined(next, {
239
- claimToken: input.claimToken,
240
- claimUrl: input.claimUrl,
241
- expiresAt: input.expiresAt,
242
- accessToken: undefined,
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
- const statePath = await writeLocalSpaceState(root, next);
246
- const existingLink = await readLocalSpaceLink(root);
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
+ }
@@ -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
  };