sporades 0.8.7 → 0.9.1

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 (118) hide show
  1. package/CHANGES.md +4 -25
  2. package/README.md +31 -0
  3. package/bin/sporades-host-helper.js +297 -11
  4. package/bin/sporades.js +3429 -471
  5. package/dist/access-key-contract.d.ts +5 -0
  6. package/dist/access-key-contract.d.ts.map +1 -0
  7. package/dist/access-key-contract.js +5 -0
  8. package/dist/access-key-contract.js.map +1 -0
  9. package/dist/access-keys-runtime.d.ts +195 -0
  10. package/dist/access-keys-runtime.d.ts.map +1 -0
  11. package/dist/access-keys-runtime.js +871 -0
  12. package/dist/access-keys-runtime.js.map +1 -0
  13. package/dist/acl-runtime.d.ts +5 -2
  14. package/dist/acl-runtime.d.ts.map +1 -1
  15. package/dist/acl-runtime.js +12 -5
  16. package/dist/acl-runtime.js.map +1 -1
  17. package/dist/auth-admission.d.ts +23 -0
  18. package/dist/auth-admission.d.ts.map +1 -0
  19. package/dist/auth-admission.js +189 -0
  20. package/dist/auth-admission.js.map +1 -0
  21. package/dist/auth-runtime.d.ts +34 -1
  22. package/dist/auth-runtime.d.ts.map +1 -1
  23. package/dist/auth-runtime.js +88 -25
  24. package/dist/auth-runtime.js.map +1 -1
  25. package/dist/bundle-pipeline.d.ts.map +1 -1
  26. package/dist/bundle-pipeline.js +7 -0
  27. package/dist/bundle-pipeline.js.map +1 -1
  28. package/dist/cli/access-key-operator-envelope.d.ts +9 -0
  29. package/dist/cli/access-key-operator-envelope.d.ts.map +1 -0
  30. package/dist/cli/access-key-operator-envelope.js +209 -0
  31. package/dist/cli/access-key-operator-envelope.js.map +1 -0
  32. package/dist/cli/cli-help.d.ts +9 -2
  33. package/dist/cli/cli-help.d.ts.map +1 -1
  34. package/dist/cli/cli-help.js +26 -0
  35. package/dist/cli/cli-help.js.map +1 -1
  36. package/dist/cli/cli-support.d.ts +1 -0
  37. package/dist/cli/cli-support.d.ts.map +1 -1
  38. package/dist/cli/cli-version.d.ts +1 -1
  39. package/dist/cli/cli-version.js +1 -1
  40. package/dist/cli/host-helper-requests.d.ts +8 -2
  41. package/dist/cli/host-helper-requests.d.ts.map +1 -1
  42. package/dist/cli/project-config.d.ts +1 -0
  43. package/dist/cli/project-config.d.ts.map +1 -1
  44. package/dist/cli/project-config.js +5 -0
  45. package/dist/cli/project-config.js.map +1 -1
  46. package/dist/cli/schedule-inspection-envelope.d.ts.map +1 -1
  47. package/dist/cli/schedule-inspection-envelope.js +46 -3
  48. package/dist/cli/schedule-inspection-envelope.js.map +1 -1
  49. package/dist/cli/sporades-host-helper.js +73 -9
  50. package/dist/cli/sporades-host-helper.js.map +1 -1
  51. package/dist/cli/sporades.js +195 -3
  52. package/dist/cli/sporades.js.map +1 -1
  53. package/dist/client-toolchain.js +11 -1
  54. package/dist/client-toolchain.js.map +1 -1
  55. package/dist/client.d.ts +1 -0
  56. package/dist/client.d.ts.map +1 -1
  57. package/dist/client.js +1 -0
  58. package/dist/client.js.map +1 -1
  59. package/dist/database-runtime.d.ts.map +1 -1
  60. package/dist/database-runtime.js +229 -0
  61. package/dist/database-runtime.js.map +1 -1
  62. package/dist/file-storage-runtime.d.ts +1 -1
  63. package/dist/file-storage-runtime.d.ts.map +1 -1
  64. package/dist/file-storage-runtime.js +4 -4
  65. package/dist/file-storage-runtime.js.map +1 -1
  66. package/dist/generated-source-manifest.json +43 -2
  67. package/dist/http-runtime.d.ts +1 -1
  68. package/dist/http-runtime.d.ts.map +1 -1
  69. package/dist/http-runtime.js +100 -13
  70. package/dist/http-runtime.js.map +1 -1
  71. package/dist/jobs-runtime.d.ts +63 -0
  72. package/dist/jobs-runtime.d.ts.map +1 -1
  73. package/dist/jobs-runtime.js +164 -4
  74. package/dist/jobs-runtime.js.map +1 -1
  75. package/dist/server-runtime-source.d.ts +56 -2
  76. package/dist/server-runtime-source.d.ts.map +1 -1
  77. package/dist/server-runtime-source.js +507 -96
  78. package/dist/server-runtime-source.js.map +1 -1
  79. package/dist/server.d.ts +28 -0
  80. package/dist/server.d.ts.map +1 -1
  81. package/dist/server.js +140 -6
  82. package/dist/server.js.map +1 -1
  83. package/dist/stripe-events-runtime.d.ts +16 -0
  84. package/dist/stripe-events-runtime.d.ts.map +1 -0
  85. package/dist/stripe-events-runtime.js +21 -0
  86. package/dist/stripe-events-runtime.js.map +1 -0
  87. package/dist/stripe-payment-config.d.ts +24 -0
  88. package/dist/stripe-payment-config.d.ts.map +1 -0
  89. package/dist/stripe-payment-config.js +143 -0
  90. package/dist/stripe-payment-config.js.map +1 -0
  91. package/dist/stripe-payment-integration.d.ts +11 -0
  92. package/dist/stripe-payment-integration.d.ts.map +1 -0
  93. package/dist/stripe-payment-integration.js +309 -0
  94. package/dist/stripe-payment-integration.js.map +1 -0
  95. package/dist/stripe-webhook-runtime.d.ts +22 -0
  96. package/dist/stripe-webhook-runtime.d.ts.map +1 -0
  97. package/dist/stripe-webhook-runtime.js +38 -0
  98. package/dist/stripe-webhook-runtime.js.map +1 -0
  99. package/dist/teams-runtime.d.ts.map +1 -1
  100. package/dist/teams-runtime.js +10 -1
  101. package/dist/teams-runtime.js.map +1 -1
  102. package/dist/templates/client-runtime-template.d.ts.map +1 -1
  103. package/dist/templates/client-runtime-template.js +44 -10
  104. package/dist/templates/client-runtime-template.js.map +1 -1
  105. package/dist/templates/scaffold-template.d.ts +3 -0
  106. package/dist/templates/scaffold-template.d.ts.map +1 -1
  107. package/dist/templates/scaffold-template.js +318 -11
  108. package/dist/templates/scaffold-template.js.map +1 -1
  109. package/dist/templates/server-bundle-entry.d.ts.map +1 -1
  110. package/dist/templates/server-bundle-entry.js +55 -7
  111. package/dist/templates/server-bundle-entry.js.map +1 -1
  112. package/dist/templates/server-bundle-module-graph.d.ts.map +1 -1
  113. package/dist/templates/server-bundle-module-graph.js +15 -4
  114. package/dist/templates/server-bundle-module-graph.js.map +1 -1
  115. package/package.json +10 -1
  116. package/src/types/client.d.ts +52 -1
  117. package/src/types/server.d.ts +176 -25
  118. package/src/types/stripe.d.ts +102 -0
package/CHANGES.md CHANGED
@@ -1,32 +1,10 @@
1
1
  # Changes
2
2
 
3
- ## Unreleased - 2026-08-18
3
+ ## Unreleased - 2026-08-24
4
4
 
5
- Changes since v0.8.6.
5
+ Changes since v0.9.0.
6
6
 
7
- ### 🚀 Features
8
-
9
- - Add transaction-bound trusted reads (9d23493).
10
-
11
- ### 🐛 Bug Fixes
12
-
13
- - Keep admission logs in transaction (5a9d012).
14
- - Revoke trusted reads at settlement (dca0fea).
15
- - Reject trusted reads from snapshots (9abe441).
16
- - Bind Join cancellation to commit (e37058d).
17
- - Close Team admission boundary races (b12dbde).
18
- - Trust Team Join admission reads (32b65ee).
19
- - Close trusted-read review gaps (da0c453).
20
-
21
- ### 📝 Documentation
22
-
23
- - Complete trusted Join admission ticket (0347c0e).
24
- - Complete trusted-read foundation ticket (2a24b7b).
25
-
26
- ### 🧪 Tests
27
-
28
- - Restore PostgreSQL SQL quoting audit (c1fcf7c).
29
- - Tidy up test (f643360).
7
+ No changes detected.
30
8
 
31
9
  ## 0.8.5 - 2026-08-15
32
10
 
@@ -64,3 +42,4 @@ generated artifacts, and documentation.
64
42
  not select a current Team or automatically partition Capsule data; Sporades
65
43
  never sends Join-link email. See the [Built-in Teams reference](https://mgscox.github.io/sporades/reference/teams).
66
44
 
45
+
package/README.md CHANGED
@@ -51,6 +51,9 @@ sporades host push --restart
51
51
  logs, database inspection, and non-interactive commands are first-class.
52
52
  - **Comprehensive SDK**: built-in authentication, database, user preferences,
53
53
  and storage.
54
+ - **Named automation without fake users**: linked users can issue scoped Access
55
+ keys for API clients while Capsule code retains the real owner in `ctx.auth`
56
+ and distinguishes named API access through `ctx.credential`.
54
57
  - **Built-in collaboration, explicitly authorized**: runtime-owned Teams,
55
58
  email-bound Join links, membership application roles, and explicit Team ACL
56
59
  decisions—without a hidden current Team or automatic data partitioning.
@@ -66,6 +69,9 @@ sporades host push --restart
66
69
  command lookup, split into focused topic pages.
67
70
  - [Built-in Teams](https://mgscox.github.io/sporades/reference/teams): collaboration model, Join
68
71
  links, application roles, and explicit Team ACL decisions.
72
+ - [Access-key server and operator reference](https://mgscox.github.io/sporades/reference/server-runtime#gate-handlers-with-requireauth):
73
+ scoped Bearer admission, user-owned lifecycle, Credential provenance, and
74
+ the bounded operator CLI.
69
75
  - [LLM documentation manifest](https://mgscox.github.io/sporades/llms.txt): release-matched Markdown
70
76
  sources for agents and other documentation tools.
71
77
  - [Architecture](https://mgscox.github.io/sporades/architecture): platform model, runtime modes, Host
@@ -146,6 +152,28 @@ Sporades currently includes:
146
152
  - Inferno scaffolds across the complete template set, using native class-component
147
153
  lifecycle and classic Inferno JSX without React compatibility packages. esbuild
148
154
  remains the default; Vite is available explicitly with full-page Dev refresh.
155
+ - Every new blank Capsule includes a built-in but disabled Stripe payment
156
+ foundation: server-owned wiring, named Checkout and Customer Portal Jobs, shared Job state, an
157
+ empty Price catalogue, and no credentials, payment UI, active callback route, or
158
+ browser Stripe SDK. Complete activation supports an authorized, idempotent
159
+ one-time or recurring subscription Checkout through the same durable Job,
160
+ actor-scoped progress, safe retries, and validated Stripe-hosted redirects.
161
+ Portal remains linked-user-only: Capsule policy authorizes an opaque billing
162
+ holder and resolves its existing Customer before enqueue, then the Job repeats
163
+ both checks before provider access so revocation fails safely. No Customer
164
+ identity is stored in the Capsule intent or Job payload. It is the preferred
165
+ surface for ordinary customer-managed payment methods, invoices,
166
+ cancellations, and supported subscription changes. Complete activation also
167
+ registers one collision-checked callback path. Signed exact bytes are admitted
168
+ into one idempotent Privileged Job per Stripe Event before acknowledgment;
169
+ retries converge and no Capsule billing state changes automatically. The Job
170
+ delivers one Verified Stripe event to the blank Capsule's single
171
+ `stripeEvents: stripeEvent(...)` policy seam under the userless Privileged
172
+ audit lifecycle. Generated policy ignores unknown events and tells authors to
173
+ make deliberate consequences idempotent and order-independent, reject stale
174
+ later-arriving observations, and avoid logging or retaining raw provider data.
175
+ The catalogue owns each Price mode; browser input owns neither mode nor Stripe
176
+ identity. Existing and non-blank Capsules remain unchanged.
149
177
  - Local Dev sessions with rebuilds, WebSocket reconnects, SQLite persistence,
150
178
  logs, database inspection, auth helpers, and file storage. Every admitted
151
179
  esbuild/Vite pair uses the same acknowledged Sporades full-page refresh
@@ -156,6 +184,9 @@ Sporades currently includes:
156
184
  - Runtime-owned auth with anonymous sessions, email auth, Google OAuth,
157
185
  Microsoft OpenID Connect, Sign in with Apple, Facebook Login, and
158
186
  local identity simulation for tests and agents.
187
+ - User-owned named Access keys with Capsule-declared scopes, explicit Custom
188
+ endpoint and private-File opt-in, one-time secret disclosure, Session-only
189
+ owner management, durable Job provenance, and audited operator retirement.
159
190
  - Runtime-owned current-user preferences exposed through `sporades/client`
160
191
  without app preference tables. Preferences follow the Sporades user identity,
161
192
  survive Anonymous account linking, and notify same-user connected clients when
@@ -163,8 +163,23 @@ function validatePublicTreeFileSet(files) {
163
163
  // src/server-runtime-source.ts
164
164
  import { createHash, randomBytes, randomUUID } from "node:crypto";
165
165
 
166
+ // src/auth-admission.ts
167
+ var AUTH_REQUIREMENTS = Symbol.for("sporades.auth.requirements");
168
+ var ACCESS_KEY_SCOPE_LIMIT = 1024;
169
+ var ACCESS_KEY_SCOPE_BYTE_LIMIT = 256;
170
+
171
+ // src/access-key-contract.ts
172
+ var ACCESS_KEY_GRANT_LIMIT = 128;
173
+ var ACCESS_KEY_GRANT_BYTE_LIMIT = 256;
174
+ var ACCESS_KEY_GRANTS_JSON_BYTE_LIMIT = 32 * 1024;
175
+ var ACCESS_KEY_CLIENT_ADDRESS_HEADER = "x-sporades-client-address";
176
+
177
+ // src/access-keys-runtime.ts
178
+ var UNKNOWN_ACCESS_KEY_DIGEST = Buffer.from("4f7c77f7b9231094754542ed50fdfd62a2cf24a5e961b61f899b85b6fe33c72b", "hex");
179
+
166
180
  // src/jobs-runtime.ts
167
181
  var nodeCryptoModule = process.getBuiltinModule("node:crypto");
182
+ var JOB_AUTH_SNAPSHOT_MAX_BYTES = 8 * 1024;
168
183
  var MAX_JOB_TIMESTAMP_MS = Date.parse("9999-12-31T23:59:59.999Z");
169
184
  var MIN_JOB_TIMESTAMP_MS = Date.parse("0000-01-01T00:00:00.000Z");
170
185
 
@@ -219,7 +234,7 @@ function privilegedAuditLevelForOutcome(outcome) {
219
234
  return "info";
220
235
  }
221
236
  function safePrivilegedAuditErrorCode(value, outcome = "started") {
222
- const source = value && typeof value === "object" && "code" in value ? value.code : value;
237
+ const source = value && typeof value === "object" ? "code" in value ? value.code : null : value;
223
238
  if (source === null || source === void 0 || source === "") {
224
239
  if (outcome === "errored") {
225
240
  return "UNKNOWN_ERROR";
@@ -262,6 +277,160 @@ var PASSWORD_RESET_DEFAULT_TTL_MS = 60 * 60 * 1e3;
262
277
  var PASSWORD_RESET_MIN_TTL_MS = 5 * 60 * 1e3;
263
278
  var PASSWORD_RESET_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
264
279
 
280
+ // src/cli/access-key-operator-envelope.ts
281
+ import { createInterface } from "node:readline/promises";
282
+ var ACCESS_KEY_OPERATOR_ACTIONS = [
283
+ "access-keys.list",
284
+ "access-keys.inspect",
285
+ "access-keys.revoke",
286
+ "access-keys.revoke-all",
287
+ "access-keys.delete"
288
+ ];
289
+ var ACTIONS = new Set(ACCESS_KEY_OPERATOR_ACTIONS);
290
+ var STATUSES = /* @__PURE__ */ new Set(["active", "expired", "revoked"]);
291
+ var REVOCATION_CAUSES = /* @__PURE__ */ new Set(["owner", "operator", "password-reset", "owner-unlinked", "owner-deleted"]);
292
+ var ACCESS_KEY_OPERATOR_LIST_PAGE_LIMIT = 100;
293
+ var JSON_STRING_MAX_BYTE_EXPANSION = 6;
294
+ var ACCESS_KEY_OPERATOR_ENVELOPE_STRUCTURAL_HEADROOM = 8 * 1024 * 1024;
295
+ var ACCESS_KEY_OPERATOR_ENVELOPE_BYTE_LIMIT = JSON_STRING_MAX_BYTE_EXPANSION * ((ACCESS_KEY_OPERATOR_LIST_PAGE_LIMIT + 1) * ACCESS_KEY_SCOPE_LIMIT * ACCESS_KEY_SCOPE_BYTE_LIMIT + ACCESS_KEY_OPERATOR_LIST_PAGE_LIMIT * ACCESS_KEY_GRANT_LIMIT * ACCESS_KEY_GRANT_BYTE_LIMIT) + ACCESS_KEY_OPERATOR_ENVELOPE_STRUCTURAL_HEADROOM;
296
+ var ACCESS_KEY_OPERATOR_PROCESS_MAX_BUFFER = ACCESS_KEY_OPERATOR_ENVELOPE_BYTE_LIMIT + 1024 * 1024;
297
+ var SAFE_ERRORS = {
298
+ UNAUTHENTICATED: { message: "Authentication is required.", hint: "Use an authorized Session and retry the operation." },
299
+ FORBIDDEN: { message: "Access-key operation is forbidden.", hint: "Use an authorized operator context." },
300
+ ACCESS_KEY_DELETE_REQUIRES_REVOKED: { message: "Access key must be revoked before deletion.", hint: "Revoke the key, then delete its history." },
301
+ ACCESS_KEY_LIMIT_REACHED: { message: "Access-key limit reached.", hint: "Retire an existing key before retrying." },
302
+ ACCESS_KEY_NAME_CONFLICT: { message: "Access-key name is already in use.", hint: "Choose a unique name." },
303
+ ACCESS_KEY_NOT_ACTIVE: { message: "Access key is not active.", hint: "Inspect current metadata before retrying." },
304
+ ACCESS_KEY_NOT_FOUND: { message: "Access key was not found.", hint: "Refresh metadata and use an exact immutable key ID." },
305
+ ACCESS_KEY_REVISION_CONFLICT: { message: "Access-key revision changed.", hint: "Refresh metadata and retry." },
306
+ ACCESS_KEY_SECRET_CONFLICT: { message: "Access-key generation conflicted.", hint: "Retry the operation." },
307
+ INVALID_ACCESS_KEY_EXPIRY: { message: "Access-key expiry is invalid.", hint: "Use a valid future expiry." },
308
+ INVALID_ACCESS_KEY_GRANTS: { message: "Access-key grants are invalid.", hint: "Use the Capsule's declared scope vocabulary." },
309
+ INVALID_ACCESS_KEY_LIST_OPTIONS: { message: "Access-key list options are invalid.", hint: "Use supported cursor, limit, and status filters." },
310
+ INVALID_ACCESS_KEY_NAME: { message: "Access-key name is invalid.", hint: "Use a valid unique name." },
311
+ INVALID_ACCESS_KEY_ACTION_INPUT: { message: "Invalid Access-key operator action input.", hint: "Upgrade the Sporades CLI and generated Bundle together." },
312
+ ACCESS_KEY_ACTION_UNSUPPORTED: { message: "Unsupported Access-key operator action.", hint: "Upgrade the Sporades CLI and generated Bundle together." },
313
+ ACCESS_KEY_ACTION_FAILED: { message: "Access-key operator action failed.", hint: "Check the Privileged audit events and retry the operation." },
314
+ HOST_HELPER_UPGRADE_REQUIRED: { message: "The Host helper does not support this Access-key action.", hint: "Upgrade the Host helper, redeploy the Capsule, and retry." },
315
+ HOSTED_CAPSULE_NOT_RUNNING: { message: "The Hosted Capsule is not running.", hint: "Start the Hosted Capsule, then retry the operation." },
316
+ HOSTED_ACCESS_KEY_RESPONSE_INVALID: { message: "Hosted Access-key action returned an invalid response.", hint: "Upgrade the Host helper, redeploy the Capsule, and retry." }
317
+ };
318
+ function plain(value) {
319
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
320
+ const prototype = Object.getPrototypeOf(value);
321
+ return prototype === Object.prototype || prototype === null;
322
+ }
323
+ function exactKeys(value, required, optional = []) {
324
+ const keys = Object.keys(value);
325
+ return required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key));
326
+ }
327
+ function boundedString(value, maximum = 256) {
328
+ return typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= maximum;
329
+ }
330
+ function optionalString(value, maximum = 512) {
331
+ return value === null || boundedString(value, maximum);
332
+ }
333
+ function stringList(value, maximumItems, maximumItemBytes) {
334
+ return Array.isArray(value) && value.length <= maximumItems && value.every((item) => boundedString(item, maximumItemBytes));
335
+ }
336
+ function optionalRevocationCause(value) {
337
+ return value === null || typeof value === "string" && REVOCATION_CAUSES.has(value);
338
+ }
339
+ function encodedWithinLimit(value, maximum) {
340
+ try {
341
+ return Buffer.byteLength(JSON.stringify(value), "utf8") <= maximum;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+ function validateAccessKeyOperatorActionInput(action, value, invalid) {
347
+ if (typeof action !== "string" || !ACTIONS.has(action) || !plain(value) || !encodedWithinLimit(value, 16 * 1024)) return invalid();
348
+ if (action === "access-keys.list") {
349
+ if (!exactKeys(value, ["userId", "options"]) || !boundedString(value.userId) || !plain(value.options) || !exactKeys(value.options, [], ["cursor", "limit", "status"])) return invalid();
350
+ const options = {};
351
+ if (value.options.cursor !== void 0) {
352
+ if (!boundedString(value.options.cursor, 512)) return invalid();
353
+ options.cursor = value.options.cursor;
354
+ }
355
+ if (value.options.limit !== void 0) {
356
+ if (!Number.isInteger(value.options.limit) || value.options.limit < 1 || value.options.limit > 100) return invalid();
357
+ options.limit = value.options.limit;
358
+ }
359
+ if (value.options.status !== void 0) {
360
+ if (typeof value.options.status !== "string" || !STATUSES.has(value.options.status)) return invalid();
361
+ options.status = value.options.status;
362
+ }
363
+ return { userId: value.userId, options };
364
+ }
365
+ if (action === "access-keys.revoke-all") {
366
+ if (!exactKeys(value, ["userId"]) || !boundedString(value.userId)) return invalid();
367
+ return { userId: value.userId };
368
+ }
369
+ if (!exactKeys(value, ["keyId"]) || !boundedString(value.keyId)) return invalid();
370
+ return { keyId: value.keyId };
371
+ }
372
+ function canonicalCapsule(value, invalid) {
373
+ if (!plain(value) || !exactKeys(value, ["name"]) || !boundedString(value.name)) return invalid();
374
+ return { name: value.name };
375
+ }
376
+ function canonicalSummary(value, invalid) {
377
+ const fields = [
378
+ "id",
379
+ "ownerUserId",
380
+ "name",
381
+ "grants",
382
+ "effectiveScopes",
383
+ "status",
384
+ "createdAt",
385
+ "expiresAt",
386
+ "rotatedAt",
387
+ "revokedAt",
388
+ "revocationCause",
389
+ "lastUsedAt",
390
+ "lifecycleRevision"
391
+ ];
392
+ if (!plain(value) || !exactKeys(value, fields) || !boundedString(value.id) || !boundedString(value.ownerUserId) || !boundedString(value.name, 512) || !stringList(value.grants, ACCESS_KEY_GRANT_LIMIT, ACCESS_KEY_GRANT_BYTE_LIMIT) || !stringList(value.effectiveScopes, ACCESS_KEY_SCOPE_LIMIT, ACCESS_KEY_SCOPE_BYTE_LIMIT) || typeof value.status !== "string" || !STATUSES.has(value.status) || !boundedString(value.createdAt, 64) || !optionalString(value.expiresAt, 64) || !optionalString(value.rotatedAt, 64) || !optionalString(value.revokedAt, 64) || !optionalRevocationCause(value.revocationCause) || !optionalString(value.lastUsedAt, 64) || !Number.isSafeInteger(value.lifecycleRevision) || value.lifecycleRevision < 1) return invalid();
393
+ return Object.fromEntries(fields.map((field) => [field, value[field]]));
394
+ }
395
+ function canonicalSuccessData(action, value, input, invalid) {
396
+ if (!plain(value)) return invalid();
397
+ const capsule = canonicalCapsule(value.capsule, invalid);
398
+ if (action === "access-keys.list") {
399
+ if (!exactKeys(value, ["capsule", "accessKeys", "declaredScopes", "nextCursor", "totalCount"]) || !Array.isArray(value.accessKeys) || value.accessKeys.length > 100 || !stringList(value.declaredScopes, ACCESS_KEY_SCOPE_LIMIT, ACCESS_KEY_SCOPE_BYTE_LIMIT) || !optionalString(value.nextCursor, 512) || !Number.isSafeInteger(value.totalCount) || value.totalCount < 0) return invalid();
400
+ const accessKeys = value.accessKeys.map((item) => canonicalSummary(item, invalid));
401
+ if (accessKeys.some((item) => item.ownerUserId !== input.userId)) return invalid();
402
+ return { capsule, accessKeys, declaredScopes: [...value.declaredScopes], nextCursor: value.nextCursor, totalCount: value.totalCount };
403
+ }
404
+ if (["access-keys.inspect", "access-keys.revoke"].includes(action)) {
405
+ if (!exactKeys(value, ["capsule", "accessKey"])) return invalid();
406
+ const accessKey = canonicalSummary(value.accessKey, invalid);
407
+ if (accessKey.id !== input.keyId || action === "access-keys.revoke" && accessKey.status !== "revoked") return invalid();
408
+ return { capsule, accessKey };
409
+ }
410
+ if (action === "access-keys.revoke-all") {
411
+ if (!exactKeys(value, ["capsule", "ownerUserId", "revokedCount", "accessKeys"]) || value.ownerUserId !== input.userId || !Number.isSafeInteger(value.revokedCount) || value.revokedCount < 0 || !Array.isArray(value.accessKeys) || value.accessKeys.length > 100) return invalid();
412
+ const accessKeys = value.accessKeys.map((item) => canonicalSummary(item, invalid));
413
+ if (accessKeys.some((item) => item.ownerUserId !== input.userId || item.status !== "revoked" || item.revocationCause !== "operator") || accessKeys.length !== value.revokedCount) return invalid();
414
+ return { capsule, ownerUserId: value.ownerUserId, revokedCount: value.revokedCount, accessKeys };
415
+ }
416
+ if (!exactKeys(value, ["capsule", "id", "ownerUserId", "deleted"]) || value.id !== input.keyId || !boundedString(value.ownerUserId) || value.deleted !== true) return invalid();
417
+ return { capsule, id: value.id, ownerUserId: value.ownerUserId, deleted: true };
418
+ }
419
+ function canonicalError(value, invalid) {
420
+ if (!plain(value) || !exactKeys(value, ["code", "message", "hint"]) || typeof value.code !== "string" || !SAFE_ERRORS[value.code] || !boundedString(value.message, 1024) || !boundedString(value.hint, 1024)) return invalid();
421
+ return { code: value.code, ...SAFE_ERRORS[value.code] };
422
+ }
423
+ function sanitizeAccessKeyOperatorEnvelope(value, action, input, invalid) {
424
+ if (!plain(value) || !encodedWithinLimit(value, ACCESS_KEY_OPERATOR_ENVELOPE_BYTE_LIMIT) || typeof value.ok !== "boolean") return invalid();
425
+ const boundedInput = validateAccessKeyOperatorActionInput(action, input, invalid);
426
+ if (value.ok) {
427
+ if (!exactKeys(value, ["ok", "data", "error"]) || value.error !== null) return invalid();
428
+ return { ok: true, data: canonicalSuccessData(String(action), value.data, boundedInput, invalid), error: null };
429
+ }
430
+ if (!exactKeys(value, ["ok", "data", "error"]) || value.data !== null) return invalid();
431
+ return { ok: false, data: null, error: canonicalError(value.error, invalid) };
432
+ }
433
+
265
434
  // src/database-runtime.ts
266
435
  var nodeCryptoModule4 = process.getBuiltinModule("node:crypto");
267
436
  var nodeFsModule = process.getBuiltinModule("node:fs");
@@ -270,6 +439,7 @@ var transactionBeforeCommitChecks2 = Symbol.for("sporades.database.transactionBe
270
439
 
271
440
  // src/server-runtime-source.ts
272
441
  var trustedReadTransactionAdapter = Symbol("sporades.trustedReadTransactionAdapter");
442
+ var runtimeOwnedJobEnqueueHandler = Symbol("sporades.runtimeOwnedJobEnqueueHandler");
273
443
  function logPayloadMaxBytes(config = {}) {
274
444
  const configured = Number(config.logs?.payloadMaxBytes ?? config.logging?.payloadMaxBytes);
275
445
  return Number.isInteger(configured) && configured > 0 ? configured : 4096;
@@ -425,13 +595,59 @@ function writeEnvelope(result, failed = false) {
425
595
  }
426
596
 
427
597
  // src/cli/cli-version.ts
428
- var CLI_VERSION = "0.8.7";
598
+ var CLI_VERSION = "0.9.1";
429
599
 
430
600
  // src/cli/schedule-inspection-envelope.ts
601
+ var SCHEDULE_DIAGNOSTIC_FIELDS = /* @__PURE__ */ new Set([
602
+ "name",
603
+ "expression",
604
+ "timezone",
605
+ "missedRun",
606
+ "enabled",
607
+ "exhausted",
608
+ "nextOccurrence",
609
+ "latestOccurrence",
610
+ "latestOccurrence.scheduledFor",
611
+ "latestOccurrence.jobId",
612
+ "latestOccurrence.errorCode",
613
+ "latestOccurrence.outcome"
614
+ ]);
615
+ var SCHEDULE_DIAGNOSTIC_INLINE_NAME_BYTES = 4096;
616
+ var SCHEDULE_DIAGNOSTIC_NAME_PREFIX_LENGTH = 128;
617
+ function scheduleNameDigest(value) {
618
+ return process.getBuiltinModule("node:crypto").createHash("sha256").update(value, "utf8").digest("hex");
619
+ }
620
+ function boundedScheduleDiagnostic(candidate) {
621
+ const scheduleName = candidate?.scheduleName;
622
+ const field = candidate?.field;
623
+ if (candidate?.code !== "SCHEDULE_INSPECTION_INVALID_STATE" || typeof field !== "string" || !SCHEDULE_DIAGNOSTIC_FIELDS.has(field)) return void 0;
624
+ if (typeof scheduleName === "string" && /^[A-Za-z][A-Za-z0-9_-]*$/.test(scheduleName)) {
625
+ const scheduleNameBytes = Buffer.byteLength(scheduleName, "utf8");
626
+ if (scheduleNameBytes <= SCHEDULE_DIAGNOSTIC_INLINE_NAME_BYTES) return { code: candidate.code, scheduleName, field };
627
+ return {
628
+ code: candidate.code,
629
+ scheduleNamePrefix: scheduleName.slice(0, SCHEDULE_DIAGNOSTIC_NAME_PREFIX_LENGTH),
630
+ scheduleNameSha256: scheduleNameDigest(scheduleName),
631
+ scheduleNameBytes,
632
+ field
633
+ };
634
+ }
635
+ if (typeof candidate?.scheduleNamePrefix === "string" && /^[A-Za-z][A-Za-z0-9_-]*$/.test(candidate.scheduleNamePrefix) && candidate.scheduleNamePrefix.length === SCHEDULE_DIAGNOSTIC_NAME_PREFIX_LENGTH && typeof candidate?.scheduleNameSha256 === "string" && /^[a-f0-9]{64}$/.test(candidate.scheduleNameSha256) && Number.isInteger(candidate?.scheduleNameBytes) && candidate.scheduleNameBytes > SCHEDULE_DIAGNOSTIC_INLINE_NAME_BYTES) {
636
+ return {
637
+ code: candidate.code,
638
+ scheduleNamePrefix: candidate.scheduleNamePrefix,
639
+ scheduleNameSha256: candidate.scheduleNameSha256,
640
+ scheduleNameBytes: candidate.scheduleNameBytes,
641
+ field
642
+ };
643
+ }
644
+ return void 0;
645
+ }
431
646
  function sanitizeScheduleInspectionEnvelope(envelope, invalid) {
432
647
  if (envelope?.ok === false) {
433
648
  const source = envelope.error;
434
- const diagnostics = source?.code === "SCHEDULE_INSPECTION_INVALID_STATE" && typeof source.scheduleName === "string" && typeof source.field === "string" ? { code: source.code, scheduleName: source.scheduleName, field: source.field } : void 0;
649
+ const candidate = source?.diagnostics ?? source;
650
+ const diagnostics = boundedScheduleDiagnostic(candidate);
435
651
  return { ok: false, data: null, error: {
436
652
  message: diagnostics ? "Persisted Schedule state is malformed." : "Schedule inspection failed.",
437
653
  hint: diagnostics ? "Repair or remove the malformed Schedule before retrying inspection." : "Inspect the Capsule and retry the command.",
@@ -1047,13 +1263,45 @@ function validateClaimedReleaseFiles(files) {
1047
1263
  // src/cli/sporades-host-helper.ts
1048
1264
  var CAPSULE_RUNTIME_HEALTH_PATH = "/__sporades/health/runtime";
1049
1265
  var RUNTIME_PROBE_HEADER = "x-sporades-host-probe";
1266
+ var CLOUDFLARE_ORIGIN_IP_RANGES = Object.freeze([
1267
+ "173.245.48.0/20",
1268
+ "103.21.244.0/22",
1269
+ "103.22.200.0/22",
1270
+ "103.31.4.0/22",
1271
+ "141.101.64.0/18",
1272
+ "108.162.192.0/18",
1273
+ "190.93.240.0/20",
1274
+ "188.114.96.0/20",
1275
+ "197.234.240.0/22",
1276
+ "198.41.128.0/17",
1277
+ "162.158.0.0/15",
1278
+ "104.16.0.0/13",
1279
+ "104.24.0.0/14",
1280
+ "172.64.0.0/13",
1281
+ "131.0.72.0/22",
1282
+ "2400:cb00::/32",
1283
+ "2606:4700::/32",
1284
+ "2803:f800::/32",
1285
+ "2405:b500::/32",
1286
+ "2405:8100::/32",
1287
+ "2a06:98c0::/29",
1288
+ "2c0f:f248::/32"
1289
+ ]);
1050
1290
  var hostHelperConfig = defaultHostHelperConfig();
1291
+ var HOSTED_ACCESS_KEY_ACTIONS = /* @__PURE__ */ new Set([
1292
+ "access-keys.list",
1293
+ "access-keys.inspect",
1294
+ "access-keys.revoke",
1295
+ "access-keys.revoke-all",
1296
+ "access-keys.delete"
1297
+ ]);
1051
1298
  main().catch((error) => {
1052
1299
  writeEnvelope(
1053
1300
  {
1054
1301
  ok: false,
1055
1302
  data: null,
1056
1303
  error: {
1304
+ ...error.code ? { code: error.code } : {},
1057
1305
  message: error.message,
1058
1306
  hint: error.hint ?? "Check the Host helper request and retry the command.",
1059
1307
  ...error.diagnostics ? { diagnostics: error.diagnostics } : {}
@@ -1126,6 +1374,10 @@ async function main() {
1126
1374
  inspectCapsuleSchedules(request);
1127
1375
  return;
1128
1376
  }
1377
+ if (HOSTED_ACCESS_KEY_ACTIONS.has(request.action)) {
1378
+ runCapsuleAccessKeyAction(request);
1379
+ return;
1380
+ }
1129
1381
  if (request.action === "host.stats") {
1130
1382
  await statsHost(request);
1131
1383
  return;
@@ -1155,14 +1407,33 @@ function inspectCapsuleSchedules(request) {
1155
1407
  validateScheduleInspectionRequest(request);
1156
1408
  inspectCapsuleRuntime(request, "schedules.inspect", "Schedule", (envelope) => sanitizeScheduleInspectionEnvelope(envelope, () => {
1157
1409
  throw helperError("Hosted Schedule inspection returned an invalid response.", "Run `sporades host upgrade`, redeploy the Capsule, and retry the command.");
1158
- }));
1410
+ }), [], true);
1411
+ }
1412
+ function runCapsuleAccessKeyAction(request) {
1413
+ const exactKeys2 = (value, keys) => Object.keys(value).length === keys.length && Object.keys(value).every((key) => keys.includes(key));
1414
+ if (!exactKeys2(request, ["action", "host", "capsule", "accessKeys"]) || !request.host || typeof request.host !== "object" || Array.isArray(request.host) || !exactKeys2(request.host, ["alias", "domain", "scheme", "remoteRoot"]) || !request.capsule || typeof request.capsule !== "object" || Array.isArray(request.capsule) || !exactKeys2(request.capsule, ["subname"])) {
1415
+ throw Object.assign(helperError("Invalid Hosted Access-key action request.", "Upgrade the local Sporades CLI and Host helper together."), { code: "INVALID_ACCESS_KEY_ACTION_INPUT" });
1416
+ }
1417
+ const accessKeys = validateAccessKeyOperatorActionInput(request.action, request.accessKeys, () => {
1418
+ throw Object.assign(helperError("Invalid Hosted Access-key action request.", "Upgrade the local Sporades CLI and Host helper together."), { code: "INVALID_ACCESS_KEY_ACTION_INPUT" });
1419
+ });
1420
+ inspectCapsuleRuntime(request, request.action, "Access-key", (envelope) => sanitizeAccessKeyOperatorEnvelope(envelope, request.action, accessKeys, () => {
1421
+ throw Object.assign(helperError("Hosted Access-key action returned an invalid response.", "Run `sporades host upgrade`, redeploy the Capsule, and retry the command."), { code: "HOSTED_ACCESS_KEY_RESPONSE_INVALID" });
1422
+ }), [
1423
+ "--sporades-action-input",
1424
+ Buffer.from(JSON.stringify(accessKeys), "utf8").toString("base64url")
1425
+ ]);
1159
1426
  }
1160
- function inspectCapsuleRuntime(request, action, label, sanitize = (envelope) => envelope) {
1427
+ function inspectCapsuleRuntime(request, action, label, sanitize = (envelope) => envelope, extraArgs = [], preserveBoundedDiagnostics = false) {
1161
1428
  const containerName = createHostedContainerName(request.host.domain, request.capsule.subname);
1162
1429
  if (!checkContainerRunning(containerName)) {
1163
- throw helperError("The Hosted Capsule is not running.", `Run \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`, then retry the command.`);
1430
+ const error = helperError("The Hosted Capsule is not running.", `Run \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`, then retry the command.`);
1431
+ if (label === "Access-key") error.code = "HOSTED_CAPSULE_NOT_RUNNING";
1432
+ throw error;
1164
1433
  }
1165
- const result = runDocker(["exec", containerName, "node", "/app/server.mjs", "--sporades-action", action]);
1434
+ const result = runDocker(["exec", containerName, "node", "/app/server.mjs", "--sporades-action", action, ...extraArgs], {
1435
+ maxBuffer: label === "Access-key" ? ACCESS_KEY_OPERATOR_PROCESS_MAX_BUFFER : void 0
1436
+ });
1166
1437
  let envelope;
1167
1438
  try {
1168
1439
  envelope = JSON.parse(result.stdout.trim());
@@ -1170,7 +1441,11 @@ function inspectCapsuleRuntime(request, action, label, sanitize = (envelope) =>
1170
1441
  throw helperError(`Hosted ${label} inspection returned invalid JSON.`, "Run `sporades host upgrade`, redeploy the Capsule, and retry the command.");
1171
1442
  }
1172
1443
  const bounded = sanitize(envelope);
1173
- if (!bounded.ok) throw helperError(bounded.error.message, bounded.error.hint, bounded.error.diagnostics);
1444
+ if (!bounded.ok) {
1445
+ const error = helperError(bounded.error.message, bounded.error.hint, preserveBoundedDiagnostics ? bounded.error.diagnostics : void 0);
1446
+ if (label === "Access-key" && bounded.error.code) error.code = bounded.error.code;
1447
+ throw error;
1448
+ }
1174
1449
  writeEnvelope(bounded);
1175
1450
  }
1176
1451
  function versionHost(request) {
@@ -3865,7 +4140,7 @@ function ensureHostedBaseImage(lifecycle) {
3865
4140
  }
3866
4141
  }
3867
4142
  function runDocker(args, options = {}) {
3868
- const result = spawnSync2("docker", args, { encoding: "utf8" });
4143
+ const result = spawnSync2("docker", args, { encoding: "utf8", ...options.maxBuffer ? { maxBuffer: options.maxBuffer } : {} });
3869
4144
  if (options.ignoreFailure) {
3870
4145
  return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
3871
4146
  }
@@ -3951,11 +4226,22 @@ function loopbackRunningRoute(route, publishedPort) {
3951
4226
  }
3952
4227
  async function writeRunningRoute(lifecycle, route = lifecycle.routes.running) {
3953
4228
  await provisionRouteLogFile(route);
3954
- const proxyLine = `reverse_proxy ${route.upstream ?? `${route.containerName}:${route.port ?? 4e3}`}`;
4229
+ const cloudflareOrigin = route.tls?.mode === "cloudflare-origin";
4230
+ const proxyLine = [
4231
+ `reverse_proxy ${route.upstream ?? `${route.containerName}:${route.port ?? 4e3}`} {`,
4232
+ ` header_up ${ACCESS_KEY_CLIENT_ADDRESS_HEADER} ${cloudflareOrigin ? "{http.request.header.CF-Connecting-IP}" : "{http.request.remote.host}"}`,
4233
+ " }"
4234
+ ].join("\n");
4235
+ const routeHandler = renderRunningRouteHandler(route, proxyLine);
4236
+ const guardedHandler = cloudflareOrigin ? [
4237
+ `@sporadesUntrustedCloudflareSource not remote_ip ${CLOUDFLARE_ORIGIN_IP_RANGES.join(" ")}`,
4238
+ "respond @sporadesUntrustedCloudflareSource 403",
4239
+ routeHandler
4240
+ ].join("\n ") : routeHandler;
3955
4241
  await applyManagedRoute(
3956
4242
  lifecycle,
3957
4243
  route.routeFile,
3958
- renderRoute(route, renderRunningRouteHandler(route, proxyLine))
4244
+ renderRoute(route, guardedHandler)
3959
4245
  );
3960
4246
  }
3961
4247
  function renderRunningRouteHandler(route, proxyLine) {