@spacefast/common 0.0.12 → 0.0.13

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 (113) hide show
  1. package/dist/contracts/access.d.ts +386 -1201
  2. package/dist/contracts/access.js +240 -1057
  3. package/dist/contracts/activity.d.ts +1 -1
  4. package/dist/contracts/activity.js +6 -14
  5. package/dist/contracts/api-keys.d.ts +16 -0
  6. package/dist/contracts/api-keys.js +2 -0
  7. package/dist/contracts/archives.d.ts +1 -2
  8. package/dist/contracts/archives.js +4 -6
  9. package/dist/contracts/comments.d.ts +58 -85
  10. package/dist/contracts/comments.js +86 -67
  11. package/dist/contracts/countries.js +2 -7
  12. package/dist/contracts/data-source-routes.d.ts +20 -0
  13. package/dist/contracts/data-source-routes.js +15 -0
  14. package/dist/contracts/device-auth.d.ts +7 -3
  15. package/dist/contracts/enums.d.ts +2 -4
  16. package/dist/contracts/enums.js +3 -8
  17. package/dist/contracts/error-code-meta.d.ts +17 -20
  18. package/dist/contracts/error-code-meta.js +8 -5
  19. package/dist/contracts/error-codes.d.ts +1 -1
  20. package/dist/contracts/error-codes.js +4 -5
  21. package/dist/contracts/events.d.ts +13 -13
  22. package/dist/contracts/execution.d.ts +212 -0
  23. package/dist/contracts/execution.js +288 -0
  24. package/dist/contracts/feature-lifecycle.d.ts +21 -167
  25. package/dist/contracts/feature-lifecycle.js +27 -173
  26. package/dist/contracts/features.d.ts +13 -115
  27. package/dist/contracts/features.js +1 -31
  28. package/dist/contracts/functions.d.ts +729 -0
  29. package/dist/contracts/functions.js +485 -0
  30. package/dist/contracts/git.d.ts +1 -1
  31. package/dist/contracts/grants.d.ts +1158 -0
  32. package/dist/contracts/grants.js +696 -0
  33. package/dist/contracts/ids.d.ts +5 -3
  34. package/dist/contracts/ids.js +5 -3
  35. package/dist/contracts/mcp.d.ts +6 -6
  36. package/dist/contracts/oauth-resources.d.ts +5 -5
  37. package/dist/contracts/oauth-resources.js +1 -0
  38. package/dist/contracts/operations.d.ts +2 -2
  39. package/dist/contracts/pages.d.ts +6 -14
  40. package/dist/contracts/pages.js +6 -23
  41. package/dist/contracts/plan-policy.d.ts +1 -8
  42. package/dist/contracts/plan-policy.js +1 -4
  43. package/dist/contracts/platform.d.ts +78 -30
  44. package/dist/contracts/platform.js +59 -12
  45. package/dist/contracts/push-new.d.ts +26 -2
  46. package/dist/contracts/push-new.js +8 -0
  47. package/dist/contracts/quotas.d.ts +0 -1
  48. package/dist/contracts/quotas.js +0 -5
  49. package/dist/contracts/repository-connections.d.ts +1 -1
  50. package/dist/contracts/resources.d.ts +10 -66
  51. package/dist/contracts/resources.js +4 -10
  52. package/dist/contracts/runtime-api.d.ts +558 -265
  53. package/dist/contracts/runtime-api.js +178 -91
  54. package/dist/contracts/runtime-app.d.ts +138 -0
  55. package/dist/contracts/runtime-app.js +21 -0
  56. package/dist/contracts/sf-config-v1.d.ts +5 -0
  57. package/dist/contracts/sites.d.ts +5 -0
  58. package/dist/contracts/sites.js +12 -1
  59. package/dist/contracts/space-config.d.ts +46 -116
  60. package/dist/contracts/space-config.js +320 -24
  61. package/dist/contracts/spaces.d.ts +253 -158
  62. package/dist/contracts/spaces.js +68 -54
  63. package/dist/contracts/superadmin-emails.d.ts +2 -2
  64. package/dist/contracts/superadmin-spaces.d.ts +15 -130
  65. package/dist/contracts/superadmin-spaces.js +7 -0
  66. package/dist/contracts/tags.d.ts +23 -23
  67. package/dist/contracts/teams.d.ts +9 -9
  68. package/dist/contracts/transfers.d.ts +0 -1
  69. package/dist/contracts/zero.d.ts +195 -273
  70. package/dist/contracts/zero.js +78 -206
  71. package/dist/dashboard-paths/index.d.ts +2 -2
  72. package/dist/dashboard-paths/index.js +1 -0
  73. package/dist/docs/agent-prose.js +10 -8
  74. package/dist/docs/agent-setup.d.ts +2 -2
  75. package/dist/docs/agent-setup.js +11 -9
  76. package/dist/docs/agent-solutions.js +1 -1
  77. package/dist/docs/catalog.d.ts +28 -28
  78. package/dist/docs/catalog.js +29 -29
  79. package/dist/docs/error-docs.js +23 -27
  80. package/dist/utils/asset-fingerprint.d.ts +11 -0
  81. package/dist/utils/asset-fingerprint.js +46 -0
  82. package/dist/utils/auth-redirect.d.ts +1 -3
  83. package/dist/utils/auth-redirect.js +1 -21
  84. package/dist/utils/build-settings.d.ts +2 -0
  85. package/dist/utils/build-settings.js +61 -9
  86. package/dist/utils/credential-policy.d.ts +1 -1
  87. package/dist/utils/credential-policy.js +4 -0
  88. package/dist/utils/egress-policy.fixtures.json +1 -1
  89. package/dist/utils/grants.d.ts +53 -0
  90. package/dist/utils/grants.js +160 -0
  91. package/dist/utils/local-space-state.js +1 -0
  92. package/dist/utils/pages.d.ts +11 -5
  93. package/dist/utils/pages.js +19 -32
  94. package/dist/utils/publish-policy.d.ts +34 -4
  95. package/dist/utils/publish-policy.js +132 -61
  96. package/dist/utils/query-keys.d.ts +1 -6
  97. package/dist/utils/query-keys.js +1 -6
  98. package/dist/utils/space-config.d.ts +5 -1
  99. package/dist/utils/space-config.js +35 -0
  100. package/dist/utils/static-runtime-policy.d.ts +5 -0
  101. package/dist/utils/static-runtime-policy.js +54 -10
  102. package/dist/utils/upload-session.d.ts +3 -0
  103. package/dist/utils/upload-session.js +31 -0
  104. package/package.json +2 -1
  105. package/dist/utils/access-grant.d.ts +0 -14
  106. package/dist/utils/access-grant.js +0 -88
  107. package/dist/utils/access-match.d.ts +0 -46
  108. package/dist/utils/access-match.fixtures.json +0 -513
  109. package/dist/utils/access-match.js +0 -312
  110. package/dist/utils/access-verdict.fixtures.json +0 -421
  111. package/dist/utils/sf-config-v1.d.ts +0 -2
  112. package/dist/utils/sf-config-v1.fixtures.json +0 -9
  113. package/dist/utils/sf-config-v1.js +0 -551
@@ -60,6 +60,10 @@ const STATIC_EXECUTION_CONTROL_FILES = new Set([".htaccess", ".user.ini"]);
60
60
  // (`/__stattic_probe` today, more `/__stattic/*` routes planned) stays
61
61
  // reserved the same way. Mid-segment occurrences are ordinary content.
62
62
  const STATIC_RUNTIME_CONTROL_SEGMENT_PREFIXES = ["__spacefast", "__stattic"];
63
+ // Compact platform endpoints live below `/__sf`. Reserve the complete root
64
+ // segment prefix before the first endpoint ships so uploaded content can never
65
+ // shadow a later platform route. Nested `__sf*` directories remain content.
66
+ const STATIC_RUNTIME_ROOT_SEGMENT_PREFIXES = ["__sf"];
63
67
  // Both the current and the legacy control-path spellings stay denied: spaces
64
68
  // published before the `.stattic` → `.spacefast` rename still serve from the
65
69
  // legacy on-disk layout, and user archives may carry either name.
@@ -76,6 +80,24 @@ const STATIC_RUNTIME_CONTROL_PREFIXES = [
76
80
  export function normalizePublishPath(relativePath) {
77
81
  return relativePath.replace(/\\/g, "/").replace(/^\/+/g, "");
78
82
  }
83
+ /**
84
+ * The one subtree inside the runtime control prefix that a publish may write.
85
+ *
86
+ * `sf publish` compiles a version's worker into `__spacefast/functions/bundles/`
87
+ * so the bundle travels through the ordinary manifest and upload path instead of
88
+ * needing a parallel one. Keeping it under the control prefix is what makes it
89
+ * unservable as static content by default — the runtime answers it only behind a
90
+ * signature.
91
+ *
92
+ * Scoped to `bundles/` rather than the whole `__spacefast/functions/` directory
93
+ * on purpose: the sibling `config.json` holds the key those signatures are
94
+ * minted with, and a publish that could overwrite it would be a publish that
95
+ * could mint its own tokens.
96
+ */
97
+ const PUBLISHABLE_BUILD_ARTIFACT_PREFIX = "__spacefast/functions/bundles/";
98
+ function isPublishableBuildArtifactPath(lowerPath) {
99
+ return lowerPath.startsWith(PUBLISHABLE_BUILD_ARTIFACT_PREFIX);
100
+ }
79
101
  export function publishPathIgnored(relativePath) {
80
102
  const path = normalizePublishPath(relativePath);
81
103
  // The agent state dirs hold one secret (state.json) plus committable tooling
@@ -101,27 +123,27 @@ export function publishPathIsConvention(relativePath) {
101
123
  // Headers", migration diagnostics): Cloudflare Pages `_worker.js`/`_routes.json`
102
124
  // and Pages/Netlify function directories publish as inert content with a
103
125
  // warning so migrations are never silent.
126
+ function providerRuntimeFeatureForPath(path) {
127
+ if (path === "_worker.js") {
128
+ return "Cloudflare Pages _worker.js";
129
+ }
130
+ if (path === "_routes.json") {
131
+ return "Cloudflare Pages _routes.json";
132
+ }
133
+ if (path.startsWith("functions/")) {
134
+ return "Pages Functions (functions/)";
135
+ }
136
+ if (path.startsWith("netlify/functions/")) {
137
+ return "Netlify Functions (netlify/functions/)";
138
+ }
139
+ return null;
140
+ }
104
141
  export function providerRuntimeFeatureWarnings(paths) {
105
142
  const warnings = [];
106
143
  const seen = new Set();
107
- const featureForPath = (path) => {
108
- if (path === "_worker.js") {
109
- return "Cloudflare Pages _worker.js";
110
- }
111
- if (path === "_routes.json") {
112
- return "Cloudflare Pages _routes.json";
113
- }
114
- if (path.startsWith("functions/")) {
115
- return "Pages Functions (functions/)";
116
- }
117
- if (path.startsWith("netlify/functions/")) {
118
- return "Netlify Functions (netlify/functions/)";
119
- }
120
- return null;
121
- };
122
144
  for (const rawPath of paths) {
123
145
  const path = normalizePublishPath(rawPath);
124
- const feature = featureForPath(path);
146
+ const feature = providerRuntimeFeatureForPath(path);
125
147
  if (!feature || seen.has(feature)) {
126
148
  continue;
127
149
  }
@@ -179,11 +201,15 @@ export function staticUploadPathViolation(path, options = {}) {
179
201
  if (reservedRuntimeControlFiles?.has(lowerName) && lowerPath === lowerName) {
180
202
  return pathViolation("static_runtime_control_path_not_supported", "Static deploys cannot upload runtime control paths.", normalizedPath);
181
203
  }
204
+ const lowerRootSegment = segments[0]?.toLowerCase() ?? "";
205
+ if (STATIC_RUNTIME_ROOT_SEGMENT_PREFIXES.some((prefix) => lowerRootSegment.startsWith(prefix))) {
206
+ return pathViolation("static_runtime_control_path_not_supported", "Static deploys cannot upload runtime control paths.", normalizedPath);
207
+ }
182
208
  const controlSegment = segments.find((segment) => {
183
209
  const lowerSegment = segment.toLowerCase();
184
210
  return STATIC_RUNTIME_CONTROL_SEGMENT_PREFIXES.some((prefix) => lowerSegment.startsWith(prefix));
185
211
  });
186
- if (controlSegment) {
212
+ if (controlSegment && !isPublishableBuildArtifactPath(lowerPath)) {
187
213
  return pathViolation("static_runtime_control_path_not_supported", "Static deploys cannot upload runtime control paths.", normalizedPath);
188
214
  }
189
215
  if (STATIC_RUNTIME_CONTROL_PREFIXES.some((prefix) => lowerPath === prefix || lowerPath.startsWith(`${prefix}/`))) {
@@ -203,6 +229,95 @@ export function firstStaticUploadPathViolation(paths, options = {}) {
203
229
  }
204
230
  return null;
205
231
  }
232
+ /**
233
+ * Canonicalizes a route expanded from an already-canonical manifest path.
234
+ * Manifest intake has decoded percent escapes exactly once, so this must not
235
+ * decode again. It applies only the route-identity aliases shared with access.
236
+ */
237
+ export function canonicalizePublishRequestedRoute(route) {
238
+ if (!route.startsWith("/")) {
239
+ throw new TypeError("publish requested routes must be absolute");
240
+ }
241
+ const segments = route
242
+ .split("/")
243
+ .filter((segment) => segment !== "" && segment !== ".")
244
+ .map((segment) => segment.normalize("NFC"));
245
+ if (segments.at(-1) === "index.html")
246
+ segments.pop();
247
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
248
+ }
249
+ /**
250
+ * Expands artifact paths into the request routes they can serve. The runtime
251
+ * resolves literal files, directory indexes, and clean `.html` aliases; two
252
+ * different artifacts owning one request route is rejected at publish/finalize
253
+ * instead of depending on lookup order. `routeOwnership` exposes the same
254
+ * expansion in canonical access-route form so authorization and diagnostics
255
+ * never have to infer a route from an artifact path.
256
+ */
257
+ export function analyzePublishRoutes(paths, options = {}) {
258
+ const index = options.index === undefined ? "index.html" : options.index;
259
+ const cleanUrls = options.cleanUrls ?? true;
260
+ const routes = new Map();
261
+ const add = (route, path) => {
262
+ const owners = routes.get(route) ?? new Set();
263
+ owners.add(path);
264
+ routes.set(route, owners);
265
+ };
266
+ for (const path of paths) {
267
+ add(`/${path}`, path);
268
+ if (cleanUrls && path.endsWith(".html")) {
269
+ add(`/${path.slice(0, -".html".length)}`, path);
270
+ }
271
+ if (index !== false) {
272
+ if (path === index) {
273
+ add("/", path);
274
+ }
275
+ else if (path.endsWith(`/${index}`)) {
276
+ const directory = path.slice(0, -(index.length + 1));
277
+ add(`/${directory}`, path);
278
+ add(`/${directory}/`, path);
279
+ }
280
+ }
281
+ }
282
+ const collisions = [...routes.entries()]
283
+ .filter(([, owners]) => owners.size > 1)
284
+ .map(([route, owners]) => ({ route, paths: [...owners].toSorted() }))
285
+ .toSorted((left, right) => left.route.localeCompare(right.route));
286
+ const firstByFoldedRoute = new Map();
287
+ const caseCollisions = [];
288
+ for (const [route, owners] of routes) {
289
+ const folded = route.toLowerCase();
290
+ const pathsForRoute = [...owners].toSorted();
291
+ const sibling = firstByFoldedRoute.get(folded);
292
+ if (sibling && sibling.route !== route) {
293
+ caseCollisions.push({
294
+ route,
295
+ siblingRoute: sibling.route,
296
+ paths: [...new Set([...sibling.paths, ...pathsForRoute])].toSorted(),
297
+ });
298
+ }
299
+ else if (!sibling) {
300
+ firstByFoldedRoute.set(folded, { route, paths: pathsForRoute });
301
+ }
302
+ }
303
+ const canonicalOwnership = new Map();
304
+ for (const [route, owners] of routes) {
305
+ const canonicalRoute = canonicalizePublishRequestedRoute(route);
306
+ const canonicalOwners = canonicalOwnership.get(canonicalRoute) ?? new Set();
307
+ for (const owner of owners)
308
+ canonicalOwners.add(owner);
309
+ canonicalOwnership.set(canonicalRoute, canonicalOwners);
310
+ }
311
+ const routeOwnership = [...canonicalOwnership.entries()]
312
+ .flatMap(([requestedRoute, owners]) => [...owners].map((artifactPath) => ({ requestedRoute, artifactPath })))
313
+ .toSorted((left, right) => left.requestedRoute.localeCompare(right.requestedRoute) ||
314
+ left.artifactPath.localeCompare(right.artifactPath));
315
+ return {
316
+ collisions,
317
+ caseCollisions: caseCollisions.toSorted((left, right) => left.route.localeCompare(right.route)),
318
+ routeOwnership,
319
+ };
320
+ }
206
321
  export function resolveManifestFileCap(maxFiles) {
207
322
  return Math.min(MANIFEST_MAX_FILES_CEILING, maxFiles && maxFiles > 0 ? maxFiles : MANIFEST_MAX_FILES_CEILING);
208
323
  }
@@ -374,8 +489,6 @@ export const ANONYMOUS_SERVE_CONTENT_TYPES = [
374
489
  "application/rss+xml",
375
490
  "application/atom+xml",
376
491
  ];
377
- const LINE_SPLIT_PATTERN = /\r?\n/;
378
- const BASIC_AUTH_HEADER_PATTERN = /^basic-auth\s*:/i;
379
492
  function extensionForPath(filePath) {
380
493
  const normalized = normalizePublishPath(filePath).toLowerCase();
381
494
  const fileName = normalized.split("/").at(-1) ?? normalized;
@@ -449,45 +562,3 @@ export function filterFreePlanManifest(files, options) {
449
562
  }
450
563
  return { allowed, ignored };
451
564
  }
452
- // Free-plan `_headers` compile policy (spec "Plans And Limits", amended
453
- // 2026-06-10): custom response headers stay FREE; the Basic-Auth header
454
- // MECHANISM is the paid feature. Basic-Auth operations are dropped from free
455
- // compiles with a `free_headers_basic_auth_disabled` warning; every other
456
- // header operation passes through untouched.
457
- export function filterFreePlanHeaders(content) {
458
- const output = [];
459
- let droppedBasicAuthCount = 0;
460
- let pendingMatcher = null;
461
- let matcherEmitted = false;
462
- const emitOperation = (rawLine) => {
463
- if (pendingMatcher !== null && !matcherEmitted) {
464
- output.push(pendingMatcher);
465
- matcherEmitted = true;
466
- }
467
- output.push(rawLine);
468
- };
469
- for (const rawLine of content.split(LINE_SPLIT_PATTERN)) {
470
- const trimmed = rawLine.trim();
471
- if (!trimmed || trimmed.startsWith("#")) {
472
- continue;
473
- }
474
- if (!rawLine.startsWith(" ") && !rawLine.startsWith("\t")) {
475
- pendingMatcher = trimmed;
476
- matcherEmitted = false;
477
- continue;
478
- }
479
- if (pendingMatcher === null) {
480
- continue;
481
- }
482
- if (BASIC_AUTH_HEADER_PATTERN.test(trimmed)) {
483
- droppedBasicAuthCount += 1;
484
- }
485
- else {
486
- emitOperation(rawLine);
487
- }
488
- }
489
- return {
490
- headers: output.length > 0 ? `${output.join("\n")}\n` : undefined,
491
- droppedBasicAuthCount,
492
- };
493
- }
@@ -63,20 +63,15 @@ export declare const qk: {
63
63
  spaceDomains: (id: string) => readonly ["spaces", string, "domains"];
64
64
  spaceRuntimeStatus: (id: string) => readonly ["spaces", string, "runtime"];
65
65
  spaceApplyState: (id: string) => readonly ["spaces", string, "apply-state"];
66
- spaceZero: (id: string) => readonly ["spaces", string, "zero"];
67
66
  spaceZeroDb: (id: string) => readonly ["spaces", string, "zero", "db"];
68
- spaceZeroLogs: (id: string, limit: number) => readonly ["spaces", string, "zero", "logs", number];
67
+ spaceRuntimeLogs: (id: string, limit: number) => readonly ["spaces", string, "logs", "runtime", number];
69
68
  spaceZeroRealtimeEvents: (id: string, limit: number) => readonly ["spaces", string, "zero", "realtime", "events", number];
70
69
  spaceVariables: (id: string) => readonly ["spaces", string, "variables"];
71
70
  spaceCertificates: (id: string) => readonly ["spaces", string, "runtime", "certificates"];
72
71
  spaceLogs: (id: string, kind: "build" | "access" | "runtime") => readonly ["spaces", string, "logs", "build" | "runtime" | "access"];
73
72
  spaceAnalytics: (id: string, window: string) => readonly ["spaces", string, "analytics", string];
74
- spaceFirewall: (id: string) => readonly ["spaces", string, "access", "firewall"];
75
- spaceAccessViews: (id: string, since?: string) => readonly ["spaces", string, "access", "views", string];
76
- teamServiceTokens: (teamId: string) => readonly ["teams", string, "access", "service-tokens"];
77
73
  teamAgentHandoffs: (teamId: string) => readonly ["teams", string, "agent-handoffs"];
78
74
  teamAccessDefaults: (teamId: string) => readonly ["teams", string, "access", "defaults"];
79
- teamAccessConnections: (teamId: string) => readonly ["teams", string, "access", "connections"];
80
75
  domain: (teamId: string, ref: string) => readonly ["teams", string, "domains", string];
81
76
  domainDiagnostics: (teamId: string, domainId: string) => readonly ["teams", string, "domains", string, "diagnostics"];
82
77
  domainDnsCapabilities: (domainId: string) => readonly ["domains", string, "dns", "capabilities"];
@@ -81,20 +81,15 @@ export const qk = {
81
81
  spaceDomains: (id) => ["spaces", id, "domains"],
82
82
  spaceRuntimeStatus: (id) => ["spaces", id, "runtime"],
83
83
  spaceApplyState: (id) => ["spaces", id, "apply-state"],
84
- spaceZero: (id) => ["spaces", id, "zero"],
85
84
  spaceZeroDb: (id) => ["spaces", id, "zero", "db"],
86
- spaceZeroLogs: (id, limit) => ["spaces", id, "zero", "logs", limit],
85
+ spaceRuntimeLogs: (id, limit) => ["spaces", id, "logs", "runtime", limit],
87
86
  spaceZeroRealtimeEvents: (id, limit) => ["spaces", id, "zero", "realtime", "events", limit],
88
87
  spaceVariables: (id) => ["spaces", id, "variables"],
89
88
  spaceCertificates: (id) => ["spaces", id, "runtime", "certificates"],
90
89
  spaceLogs: (id, kind) => ["spaces", id, "logs", kind],
91
90
  spaceAnalytics: (id, window) => ["spaces", id, "analytics", window],
92
- spaceFirewall: (id) => ["spaces", id, "access", "firewall"],
93
- spaceAccessViews: (id, since) => ["spaces", id, "access", "views", since ?? ""],
94
- teamServiceTokens: (teamId) => ["teams", teamId, "access", "service-tokens"],
95
91
  teamAgentHandoffs: (teamId) => ["teams", teamId, "agent-handoffs"],
96
92
  teamAccessDefaults: (teamId) => ["teams", teamId, "access", "defaults"],
97
- teamAccessConnections: (teamId) => ["teams", teamId, "access", "connections"],
98
93
  domain: (teamId, ref) => ["teams", teamId, "domains", ref],
99
94
  domainDiagnostics: (teamId, domainId) => ["teams", teamId, "domains", domainId, "diagnostics"],
100
95
  domainDnsCapabilities: (domainId) => ["domains", domainId, "dns", "capabilities"],
@@ -1,3 +1,4 @@
1
+ import { type Grant } from "../contracts/grants.js";
1
2
  import { type SpaceConfig, type SpaceConfigFile } from "../contracts/space-config.js";
2
3
  export type SpaceConfigBlockingIssueCode = "config_file_too_large" | "config_templates_over_limit" | "config_meta_too_long" | "config_invalid" | "inject_snippet_too_large" | "inject_invalid";
3
4
  export type SpaceConfigIssue = {
@@ -8,6 +9,9 @@ export type SpaceConfigIssue = {
8
9
  path?: string;
9
10
  details?: Record<string, unknown>;
10
11
  };
12
+ export declare const CONFIG_PUBLIC_GRANT_COMPILED_ID = "config:access.public";
13
+ /** Compiles the file-only access declaration; activation decides which Version is live. */
14
+ export declare function compileSpaceConfigPublicGrant(fileConfig: Pick<SpaceConfigFile, "access"> | null | undefined): Grant | null;
11
15
  type SpaceConfigBlockingIssue = SpaceConfigIssue & {
12
16
  code: SpaceConfigBlockingIssueCode;
13
17
  };
@@ -16,7 +20,7 @@ type SpaceConfigBlockingIssue = SpaceConfigIssue & {
16
20
  * `config`): the 16 KB compiled-overlay cap plus the shared shape caps.
17
21
  * Returns the first blocking issue, or null when the overlay is acceptable.
18
22
  */
19
- export declare function spaceConfigOverlayIssue(config: SpaceConfig): SpaceConfigBlockingIssue | null;
23
+ export declare function spaceConfigOverlayIssue(config: SpaceConfig | Record<string, unknown>): SpaceConfigBlockingIssue | null;
20
24
  export declare function stripJsonComments(raw: string): string;
21
25
  export type ParsedSpaceConfigFile = {
22
26
  /** Parsed config (cap- and shape-valid keys only); null when unusable. */
@@ -1,8 +1,35 @@
1
+ import { CONFIG_PUBLIC_GRANT_SOURCE_REFERENCE, grantSchema, } from "../contracts/grants.js";
1
2
  import { SPACE_CONFIG_CAPS, SPACE_CONFIG_CANONICAL_FILE, SPACE_CONFIG_PRIVATE_FILES, spaceConfigFileSchema, spaceConfigSchema, } from "../contracts/space-config.js";
2
3
  const TEXT_BYTES = new TextEncoder();
4
+ export const CONFIG_PUBLIC_GRANT_COMPILED_ID = "config:access.public";
3
5
  function byteLength(value) {
4
6
  return TEXT_BYTES.encode(value).length;
5
7
  }
8
+ /** Compiles the file-only access declaration; activation decides which Version is live. */
9
+ export function compileSpaceConfigPublicGrant(fileConfig) {
10
+ const patterns = fileConfig?.access?.public ?? [];
11
+ const include = patterns.filter((pattern) => !pattern.startsWith("!"));
12
+ if (include.length === 0)
13
+ return null;
14
+ return grantSchema.parse({
15
+ id: CONFIG_PUBLIC_GRANT_COMPILED_ID,
16
+ name: "Public paths from sf.jsonc",
17
+ audience: { kind: "public" },
18
+ resources: {
19
+ include,
20
+ exclude: patterns
21
+ .filter((pattern) => pattern.startsWith("!"))
22
+ .map((pattern) => pattern.slice(1)),
23
+ },
24
+ capabilities: ["page.view"],
25
+ constraints: {},
26
+ target: { kind: "live" },
27
+ source: {
28
+ kind: "config",
29
+ reference: CONFIG_PUBLIC_GRANT_SOURCE_REFERENCE,
30
+ },
31
+ });
32
+ }
6
33
  function capIssues(candidate) {
7
34
  const issues = [];
8
35
  const templates = candidate.templates;
@@ -102,6 +129,14 @@ function capIssues(candidate) {
102
129
  * Returns the first blocking issue, or null when the overlay is acceptable.
103
130
  */
104
131
  export function spaceConfigOverlayIssue(config) {
132
+ if (Object.prototype.hasOwnProperty.call(config, "access")) {
133
+ return {
134
+ severity: "error",
135
+ code: "config_invalid",
136
+ message: "access is only valid in the committed sf.jsonc file.",
137
+ path: "access",
138
+ };
139
+ }
105
140
  const encoded = JSON.stringify(config);
106
141
  if (byteLength(encoded) > SPACE_CONFIG_CAPS.overlayBytes) {
107
142
  return {
@@ -1,5 +1,10 @@
1
1
  export declare const PHP_LIKE_EXTENSIONS: string[];
2
+ export declare const INTERNAL_REDIRECT_RESPONSE_HEADERS: string[];
2
3
  export declare const PLATFORM_MANAGED_RESPONSE_HEADERS: string[];
4
+ /** Reserved response-header namespaces owned by the platform's own serving signature. */
5
+ export declare const PLATFORM_MANAGED_RESPONSE_HEADER_PREFIXES: string[];
6
+ /** True when user rules may neither set nor remove this response header. */
7
+ export declare function isPlatformManagedResponseHeader(name: string): boolean;
3
8
  export declare const NON_IMMUTABLE_EXTENSIONS: string[];
4
9
  export declare const GUARANTEED_IMMUTABLE_PREFIXES: string[];
5
10
  /** Lowercased extension without the leading dot, posix semantics. Dotfiles have no extension. */
@@ -7,13 +7,47 @@
7
7
  // (static-runtime-policy.fixtures.json). Do not add platform/filesystem
8
8
  // behavior here.
9
9
  export const PHP_LIKE_EXTENSIONS = ["php", "php3", "php4", "php5", "php7", "php8", "phtml", "phar"];
10
+ // The internal-redirect/sendfile family: headers that do not describe a response, they
11
+ // instruct the web server to produce a different one. Under nginx+php-fpm
12
+ // X-Accel-Redirect (and every vendor spelling of it) turns a response into a server-side
13
+ // read of an arbitrary internal path — a private-path disclosure primitive — while
14
+ // X-Accel-Limit-Rate pins the serving worker for the length of the transfer.
15
+ //
16
+ // These are dangerous from ANY origin we do not control, so this family is factored out
17
+ // of the platform-managed list below: a relay lane (runtime/engine/runtime/proxy.php)
18
+ // must drop exactly this set from an untrusted upstream while still relaying the
19
+ // ordinary response headers (location, content-encoding, content-range, allow) that the
20
+ // rest of the managed list contains.
21
+ export const INTERNAL_REDIRECT_RESPONSE_HEADERS = [
22
+ "x-accel-buffering",
23
+ "x-accel-charset",
24
+ "x-accel-expires",
25
+ "x-accel-limit-rate",
26
+ "x-accel-redirect",
27
+ "x-lighttpd-send-file",
28
+ "x-lighttpd-sendfile",
29
+ "x-lighttpd-sendfile2",
30
+ "x-reproxy-url",
31
+ "x-sendfile",
32
+ ];
10
33
  // Rejected/platform-managed response headers (internal-docs/platform.md "Routing And
11
- // Headers"): user `_headers` rules can never set or remove these. The list includes the
12
- // internal-redirect/sendfile family (X-Accel-Redirect, X-Sendfile, X-LIGHTTPD-send-file,
13
- // X-Accel-Buffering) because under nginx+php-fpm those headers turn a response into a
14
- // server-side file read a private-path disclosure primitive. The native Rust compiler
15
- // rejects them at publish; the PHP runtime refuses them again at apply time so a compiler
16
- // gap can never emit one.
34
+ // Headers"): user `_headers` rules can never set or remove these. The
35
+ // internal-redirect/sendfile family above is spread in, so it stays a strict subset —
36
+ // anything the proxy lane refuses to relay is also refused from `_headers`. The rest is
37
+ // the connection and transport surface: hop-by-hop names (Connection, Keep-Alive, TE,
38
+ // Trailer, Transfer-Encoding, Upgrade, Proxy-*), framing, and the edge-cache trio.
39
+ //
40
+ // `isPlatformManagedResponseHeader` — not this list alone — is the policy: the reserved
41
+ // `x-spacefast-*` / `x-stattic-*` namespaces below carry the platform's own response
42
+ // signature (X-Spacefast-Runtime, X-Spacefast-Version, X-Spacefast-Reason, …), so user
43
+ // rules can never forge or clobber them.
44
+ //
45
+ // Every enforcement point derives from here: the `_headers` compiler in
46
+ // @spacefast/routing imports it directly, the PHP runtime maps and prefixes are
47
+ // generated from it into runtime/engine/shared/safety.php, and the Rust authorities
48
+ // (crates/stattic-runtime-core policy.rs, crates/stattic-zero-runner
49
+ // response_headers.rs) are drift-guarded against it by
50
+ // apps/control-plane/src/runtime/php-policy-parity.test.ts.
17
51
  export const PLATFORM_MANAGED_RESPONSE_HEADERS = [
18
52
  "accept-ranges",
19
53
  "age",
@@ -28,21 +62,31 @@ export const PLATFORM_MANAGED_RESPONSE_HEADERS = [
28
62
  "cookie",
29
63
  "date",
30
64
  "host",
65
+ "keep-alive",
31
66
  "location",
32
67
  "netlify-cdn-cache-control",
68
+ "proxy-authenticate",
69
+ "proxy-authorization",
33
70
  "server",
34
71
  "set-cookie",
35
72
  "strict-transport-security",
36
73
  "surrogate-control",
74
+ "te",
37
75
  "trailer",
38
76
  "transfer-encoding",
39
77
  "upgrade",
40
78
  "vary",
41
- "x-accel-buffering",
42
- "x-accel-redirect",
43
- "x-lighttpd-send-file",
44
- "x-sendfile",
79
+ ...INTERNAL_REDIRECT_RESPONSE_HEADERS,
45
80
  ];
81
+ /** Reserved response-header namespaces owned by the platform's own serving signature. */
82
+ export const PLATFORM_MANAGED_RESPONSE_HEADER_PREFIXES = ["x-spacefast-", "x-stattic-"];
83
+ const PLATFORM_MANAGED_RESPONSE_HEADER_SET = new Set(PLATFORM_MANAGED_RESPONSE_HEADERS);
84
+ /** True when user rules may neither set nor remove this response header. */
85
+ export function isPlatformManagedResponseHeader(name) {
86
+ const lower = name.trim().toLowerCase();
87
+ return (PLATFORM_MANAGED_RESPONSE_HEADER_SET.has(lower) ||
88
+ PLATFORM_MANAGED_RESPONSE_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix)));
89
+ }
46
90
  export const NON_IMMUTABLE_EXTENSIONS = ["html", "htm", "php", "txt", "xml"];
47
91
  const PHP_LIKE_EXTENSION_SET = new Set(PHP_LIKE_EXTENSIONS);
48
92
  const NON_IMMUTABLE_EXTENSION_SET = new Set(NON_IMMUTABLE_EXTENSIONS);
@@ -24,6 +24,9 @@ export declare class UploadTargetError extends Error {
24
24
  readonly status: number;
25
25
  constructor(target: UploadTarget, status: number, message: string);
26
26
  }
27
+ export declare function uploadPageHasRemainder(page: UploadSession): boolean;
28
+ export declare const UPLOAD_STALLED_RESUME_MAX_ATTEMPTS = 3;
29
+ export declare function uploadQueueAdvanced(before: UploadSession, after: UploadSession | null): boolean;
27
30
  export declare function executeUploadSession(input: {
28
31
  session: UploadSession;
29
32
  targets?: readonly UploadTarget[] | undefined;
@@ -8,6 +8,37 @@ export class UploadTargetError extends Error {
8
8
  this.name = "UploadTargetError";
9
9
  }
10
10
  }
11
+ // Manifest paging policy — ONE definition, shared by every uploader (the CLI
12
+ // and the control plane's inline uploader).
13
+ //
14
+ // `targets` is a single instruction page, bounded by the instruction payload
15
+ // budget; `summary.upload` counts the whole declared manifest. A short page
16
+ // therefore means a remainder that only `resume` hands out, and an uploader
17
+ // that stops at the first page finalizes a version the runtime rejects with
18
+ // `version_upload_incomplete`. A complete page means there is nothing left to
19
+ // ask for — don't re-resume to double-check the uploads, since the runtime
20
+ // verifies size and sha256 on every PUT and finalize independently enumerates
21
+ // anything still missing.
22
+ export function uploadPageHasRemainder(page) {
23
+ return page.summary.upload > page.targets.length;
24
+ }
25
+ // Resume mints the next page from runtime session truth, so a converging
26
+ // session always hands back different work. A page that repeats the previous
27
+ // one is a session that is not draining; retry it a bounded number of times,
28
+ // then fail loudly rather than page forever.
29
+ export const UPLOAD_STALLED_RESUME_MAX_ATTEMPTS = 3;
30
+ export function uploadQueueAdvanced(before, after) {
31
+ if (!after || after.targets.length === 0) {
32
+ return true;
33
+ }
34
+ if (after.targets.length < before.targets.length) {
35
+ return true;
36
+ }
37
+ if (after.targets.length > before.targets.length) {
38
+ return false;
39
+ }
40
+ return after.targets.some((target, index) => target.path !== before.targets[index]?.path);
41
+ }
11
42
  async function runConcurrent(items, concurrency, fn) {
12
43
  let nextIndex = 0;
13
44
  const runWorker = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spacefast/common",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "description": "Shared TypeScript contracts and utilities for Spacefast packages.",
6
6
  "repository": {
@@ -93,6 +93,7 @@
93
93
  "dependencies": {
94
94
  "@tanstack/react-query": "^5.101.4",
95
95
  "culori": "^4.0.2",
96
+ "js-yaml": "^4.1.1",
96
97
  "smol-toml": "^1.7.0",
97
98
  "zod": "^4.4.3"
98
99
  }
@@ -1,14 +0,0 @@
1
- import type { RuntimeRule } from "../contracts/access.js";
2
- export declare const ACCESS_GRANT_GATE_RULE_ID = "access-grant-gate";
3
- export declare const ACCESS_GRANT_ADMISSION_RULE_ID = "access-grant-admission";
4
- export type AccessGrantExposure = {
5
- anonymousAccess: "gated" | "gated_with_exceptions" | "partly_public" | "shadowed";
6
- earlierAnonymousAllowRules: number;
7
- earlierManagedAccessRules: number;
8
- precedingTerminalRules: number;
9
- };
10
- export declare function withAccessGrantGate(rules: RuntimeRule[], grant: string): RuntimeRule[];
11
- export declare function withoutAccessGrantRule(rules: RuntimeRule[], selectedIndex: number): RuntimeRule[];
12
- export declare function isAccessGrantAdmissionRule(rule: RuntimeRule): boolean;
13
- export declare function isAccessGrantFallbackRule(rule: RuntimeRule): boolean;
14
- export declare function accessGrantExposure(rules: RuntimeRule[]): AccessGrantExposure;
@@ -1,88 +0,0 @@
1
- // `sf access grant` and the dashboard's Grant access card share one canonical
2
- // pair: an authenticated admission allow plus an anonymous fallback challenge.
3
- // Repeated grants widen both rules' OR-list. The split is essential under
4
- // first-match evaluation: the admission can pass before managed challenges,
5
- // while the fallback closes otherwise-public traffic after those challenges.
6
- export const ACCESS_GRANT_GATE_RULE_ID = "access-grant-gate";
7
- export const ACCESS_GRANT_ADMISSION_RULE_ID = "access-grant-admission";
8
- export function withAccessGrantGate(rules, grant) {
9
- // Raw access writes replace only the unmanaged lane. GET/PUT responses may
10
- // include server-owned managed blocks, but sending those blocks back would
11
- // be rejected and could let a client accidentally claim their ownership.
12
- // Reserved access-grant rules are rebuilt as one canonical pair below.
13
- const unmanagedRules = rules.filter((rule) => rule.managedBy === undefined &&
14
- rule.id !== ACCESS_GRANT_ADMISSION_RULE_ID &&
15
- rule.id !== ACCESS_GRANT_GATE_RULE_ID);
16
- const existingRequiredGrants = rules.find((rule) => rule.id === ACCESS_GRANT_ADMISSION_RULE_ID)
17
- ?.auth?.requiredGrants;
18
- const fallbackRequiredGrants = rules.find((rule) => rule.id === ACCESS_GRANT_GATE_RULE_ID)?.auth
19
- ?.requiredGrants;
20
- const requiredGrants = [
21
- ...new Set([...(existingRequiredGrants ?? fallbackRequiredGrants ?? []), grant]),
22
- ];
23
- return [
24
- ...unmanagedRules,
25
- {
26
- id: ACCESS_GRANT_ADMISSION_RULE_ID,
27
- match: {},
28
- effect: "allow",
29
- auth: { requiredGrants },
30
- reasonCode: "identity_grant_admitted",
31
- },
32
- {
33
- id: ACCESS_GRANT_GATE_RULE_ID,
34
- match: {},
35
- effect: "challenge",
36
- auth: { requiredGrants },
37
- reasonCode: "identity_grant_required",
38
- message: "Sign in with an approved identity to continue.",
39
- },
40
- ];
41
- }
42
- export function withoutAccessGrantRule(rules, selectedIndex) {
43
- const selectedRule = rules[selectedIndex];
44
- const removesAccessGrant = selectedRule?.id === ACCESS_GRANT_ADMISSION_RULE_ID ||
45
- selectedRule?.id === ACCESS_GRANT_GATE_RULE_ID;
46
- return rules.filter((rule, index) => rule.managedBy === undefined &&
47
- (removesAccessGrant
48
- ? rule.id !== ACCESS_GRANT_ADMISSION_RULE_ID && rule.id !== ACCESS_GRANT_GATE_RULE_ID
49
- : index !== selectedIndex));
50
- }
51
- export function isAccessGrantAdmissionRule(rule) {
52
- return (rule.id === ACCESS_GRANT_ADMISSION_RULE_ID &&
53
- rule.effect === "allow" &&
54
- Object.keys(rule.match).length === 0 &&
55
- rule.auth !== undefined);
56
- }
57
- export function isAccessGrantFallbackRule(rule) {
58
- return (rule.id === ACCESS_GRANT_GATE_RULE_ID &&
59
- rule.effect === "challenge" &&
60
- Object.keys(rule.match).length === 0 &&
61
- rule.auth !== undefined);
62
- }
63
- // The admission allow protects approved identities without preempting the
64
- // existing password/share/service challenge. The final fallback gate protects
65
- // public fallthrough. A terminal rule from an earlier effective lane can still
66
- // stop evaluation before admission; report that explicitly instead of claiming
67
- // the space is simply gated.
68
- export function accessGrantExposure(rules) {
69
- const gateIndex = rules.findIndex(isAccessGrantFallbackRule);
70
- const admissionIndex = rules.findIndex(isAccessGrantAdmissionRule);
71
- const earlierRules = gateIndex === -1 ? rules : rules.slice(0, gateIndex);
72
- const rulesBeforeAdmission = admissionIndex === -1 ? earlierRules : rules.slice(0, admissionIndex);
73
- const precedingTerminalRules = rulesBeforeAdmission.filter((rule) => rule.effect === "challenge" || (rule.effect === "deny" && rule.auth === undefined)).length;
74
- const earlierAnonymousAllowRules = earlierRules.filter((rule) => rule.effect === "allow" && !rule.auth).length;
75
- const earlierManagedAccessRules = earlierRules.filter((rule) => rule.managedBy !== undefined && rule.effect === "allow").length;
76
- return {
77
- anonymousAccess: precedingTerminalRules > 0
78
- ? "shadowed"
79
- : earlierAnonymousAllowRules > 0
80
- ? "partly_public"
81
- : earlierManagedAccessRules > 0
82
- ? "gated_with_exceptions"
83
- : "gated",
84
- earlierAnonymousAllowRules,
85
- earlierManagedAccessRules,
86
- precedingTerminalRules,
87
- };
88
- }