@ixo/oracle-runtime-workers 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
package/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  Run a QiForge oracle on **Cloudflare Workers** — no server, no disk, no Redis.
4
4
  The same plugin API, wire protocol and UCAN auth as `@ixo/oracle-runtime`,
5
- re-hosted on Durable Objects.
5
+ re-hosted on Durable Objects. This is the only maintained runtime: the Node
6
+ runtime is deprecated, and what differs from it is in
7
+ [node-parity](docs/node-parity.md). Bounded semantic Decisions are included too, and
8
+ can run on Workers AI through the `AI` binding instead of account credentials
9
+ (see [configuration](docs/configuration.md#decisions)).
6
10
 
7
11
  ```ts
8
12
  // src/index.ts of your oracle Worker
@@ -53,7 +57,8 @@ graph LR
53
57
  exported on a debounced alarm and re-imported on a cold boot. The oracle
54
58
  reaches it with the one delegation the user deposits for it
55
59
  (`POST /delegation`), which must carry `ixo:filesystem/.oracles` next to
56
- the plugin grants — see [architecture](docs/architecture.md#self-sovereign-storage).
60
+ the plugin grants and must expire (a delegation with no expiry is refused)
61
+ — see [architecture](docs/architecture.md#self-sovereign-storage).
57
62
  - **Two scripts in production.** The gateway runs in its own Worker script
58
63
  so its memory footprint never competes with user objects; the scripts
59
64
  talk over cross-script Durable Object bindings.
@@ -84,7 +89,13 @@ Every variable, binding and migration is explained in
84
89
  | `GET /health`, `GET /health/matrix` | Liveness; the gateway's health (503 when not running) |
85
90
  | `GET /matrix/status`, `POST /matrix/start` | Gateway status and start |
86
91
  | `POST/GET /sessions`, `POST/GET /messages/:id` | The chat API |
92
+ | `GET /runs/:runId`, `GET /sessions/:id/messages` | Re-join a durable run; one page of a transcript |
87
93
  | `/delegation`, `/models`, `/socket.io/`, `/byo-llm/*` | Delegation, models, realtime channel, BYO LLM |
94
+ | `POST /channels/turn` | IXO Channels ingress (its own channel UCAN policy) |
95
+ | `POST /messages/:sessionId/:messageId/feedback` | Anonymous feedback on a reply (when configured) |
96
+ | `/topic-deliverables/:operationId` | Owner-only Topic deliverables (when enabled) |
97
+ | `GET /a/:id`, `GET /a/:id/data` | Artefact links opened from chat apps (public) |
98
+ | `GET/DELETE /artifacts/:id` | An artefact's canonical copy; revoke its link |
88
99
  | `/debug/*` (with `ORACLE_DEBUG_ROUTES=true`) | Storage, tasks, sockets, outbox, gateway restart |
89
100
 
90
101
  The status fields, the runbook and the behaviour of both objects over their
@@ -92,14 +103,19 @@ lifetime are in [operations](docs/operations.md).
92
103
 
93
104
  ## Documentation
94
105
 
95
- | Page | What it covers |
96
- | -------------------------------------- | ---------------------------------------------------------------------------------------- |
97
- | [architecture](docs/architecture.md) | The objects, the gateway split, self-sovereign storage, cost model, the rules of workerd |
98
- | [node-parity](docs/node-parity.md) | What is identical to the Node runtime, what is ported differently, what is not ported |
99
- | [configuration](docs/configuration.md) | Wrangler config, migrations, every env var, first-time setup, the bot's send rate |
100
- | [operations](docs/operations.md) | Routes, status fields, gateway and user-object lifecycle, the ChatGPT proxy, the runbook |
101
- | [testing](docs/testing.md) | Unit, harness e2e, the devnet feature matrix, stress |
102
- | [load-tests](docs/load-tests.md) | Measurements and the findings that shaped the gateway |
106
+ | Page | What it covers |
107
+ | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
108
+ | [architecture](docs/architecture.md) | The objects, the gateway split, self-sovereign storage, cost model, workerd rules, tasks |
109
+ | [node-parity](docs/node-parity.md) | What is identical to the Node runtime, what differs, what is Workers-only, what is not ported |
110
+ | [configuration](docs/configuration.md) | Wrangler config, migrations, every env var and binding, `createOracleWorker` options, setup |
111
+ | [operations](docs/operations.md) | Routes, status fields, gateway and user-object lifecycle, the ChatGPT proxy, the runbook |
112
+ | [chat-delivery](docs/chat-delivery.md) | Chat replies on WhatsApp and Matrix: profiles, Reply Plans, artefacts and their links |
113
+ | [channels](docs/channels.md) | The IXO Channels ingress: authorization, retries, Reply Plans, Matrix continuity |
114
+ | [frontend-bridge](docs/frontend-bridge.md) | Browser tools and AG-UI actions over the socket: one invocation, one socket, unknown outcomes |
115
+ | [pod-creator](docs/pod-creator.md) | The POD Creator plugin: roles, state in `ctx.kv`, the create path and its launch gate |
116
+ | [ixo-transaction](docs/ixo-transaction.md) | The opt-in wallet-signing plugin: chain ids, signing outcomes, the mainnet receipt rule |
117
+ | [testing](docs/testing.md) | Unit, harness e2e, the devnet feature matrix, stress |
118
+ | [load-tests](docs/load-tests.md) | Measurements and the findings that shaped the gateway |
103
119
 
104
120
  ## Status (September 2026)
105
121
 
@@ -114,6 +130,12 @@ lifetime are in [operations](docs/operations.md).
114
130
  attachments now use (a 50 MB Node-era checkpoint no longer resets the
115
131
  user object's isolate) and the crypto-snapshot flush before every keys
116
132
  upload.
133
+ - October 2026: the Node runtime was deprecated (pull request 330), and the
134
+ Workers runtime gained the IXO Channels ingress, chat delivery with
135
+ artefacts, request admission, supplied-context tasks and the Topic
136
+ deliverable API, admin-plane tools, anonymous feedback, the POD Creator
137
+ and ixo-transaction plugins (see the pages above and
138
+ [node-parity](docs/node-parity.md#workers-only)).
117
139
  - Open: the memory engine's recall ranking on accounts with a long history;
118
140
  the storage cost of task-holding users at scale (R2 page tier, see
119
141
  [architecture](docs/architecture.md#storage-cost-planning)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -18,7 +18,8 @@
18
18
  "types": "src/index.ts",
19
19
  "exports": {
20
20
  ".": "./src/index.ts",
21
- "./gateway": "./src/gateway-worker.ts"
21
+ "./gateway": "./src/gateway-worker.ts",
22
+ "./prompt": "./src/core/prompt-composer.ts"
22
23
  },
23
24
  "files": [
24
25
  "src",
@@ -26,7 +27,8 @@
26
27
  "!src/**/__snapshots__/",
27
28
  "!src/**/fixtures/",
28
29
  "!src/**/test-do.ts",
29
- "!src/core/test-fixtures.ts"
30
+ "!src/core/test-fixtures.ts",
31
+ "!src/plugins/pod-creator/test-fixtures.ts"
30
32
  ],
31
33
  "dependencies": {
32
34
  "@blocknote/server-util": "0.29.1",
@@ -36,9 +38,9 @@
36
38
  "@ixo/matrix-bot-workers-sdk": "0.7.0",
37
39
  "@ixo/matrix-crdt": "^1.2.5",
38
40
  "@langchain/core": "1.1.48",
39
- "@langchain/langgraph": "1.3.2",
40
- "@langchain/langgraph-checkpoint": "1.0.2",
41
- "@langchain/mcp-adapters": "1.1.3",
41
+ "@langchain/langgraph": "1.4.17",
42
+ "@langchain/langgraph-checkpoint": "1.1.5",
43
+ "@langchain/mcp-adapters": "1.1.4",
42
44
  "@langchain/openai": "1.4.7",
43
45
  "@noble/hashes": "^1.8.0",
44
46
  "cron-parser": "^5.5.0",
@@ -46,7 +48,7 @@
46
48
  "hono": "^4.13.4",
47
49
  "jose": "^6.0.0",
48
50
  "langchain": "1.4.2",
49
- "langsmith": "^0.5.26",
51
+ "langsmith": "^0.6.0",
50
52
  "linkedom": "^0.18.12",
51
53
  "marked": "^16.4.0",
52
54
  "matrix-js-sdk": "^42.2.0",
@@ -54,11 +56,14 @@
54
56
  "wa-sqlite": "1.0.0",
55
57
  "yjs": "13.6.32",
56
58
  "zod": "^4.4.3",
57
- "@ixo/ucan": "2.2.0"
59
+ "@ixo/common": "1.6.0",
60
+ "@ixo/ixo-transaction": "0.1.0",
61
+ "@ixo/ucan": "2.2.1"
58
62
  },
59
63
  "devDependencies": {
60
64
  "@cloudflare/vitest-pool-workers": "^0.22.0",
61
65
  "@cloudflare/workers-types": "^5.20260825.1",
66
+ "@modelcontextprotocol/sdk": "1.30.0",
62
67
  "happy-dom": "^19.0.0",
63
68
  "typescript": "^5.9.3",
64
69
  "vitest": "^4.0.0",
@@ -0,0 +1,158 @@
1
+ import type { OracleWorkerEnv } from '../do/contracts';
2
+
3
+ /**
4
+ * Links default to "anyone with the link", expiring after this many days.
5
+ * The decision and its review note live in the repository README.
6
+ */
7
+ export const DEFAULT_ARTIFACT_TTL_DAYS = 30;
8
+ /** Expiry is part of the policy: no setting makes a link permanent. */
9
+ export const MAX_ARTIFACT_TTL_DAYS = 365;
10
+
11
+ /** How often the cron starts a full sweep of expired share copies. */
12
+ export const DEFAULT_ARTIFACT_SWEEP_INTERVAL_HOURS = 24;
13
+ export const MAX_ARTIFACT_SWEEP_INTERVAL_HOURS = 168;
14
+
15
+ const HOUR_MS = 60 * 60 * 1000;
16
+ const DAY_MS = 24 * HOUR_MS;
17
+
18
+ /** Where share links point: the oracle's `/a/:id`, or a shared viewer page. */
19
+ export interface ArtifactLinkBase {
20
+ /** The oracle Worker's public origin; share copies are served under `/a/`. */
21
+ publicUrl: string;
22
+ /** A shared viewer page (Qi.Space). Unset → the oracle's own `/a/:id` page. */
23
+ viewerUrl?: string;
24
+ }
25
+
26
+ /**
27
+ * Artefact storage. `links` is null while no usable public origin is set:
28
+ * no new artefact is created then, but the ones already stored stay
29
+ * readable, revocable and deleted with their session, because the bucket
30
+ * still holds their share copies.
31
+ */
32
+ export interface ArtifactStorageConfig {
33
+ bucket: R2Bucket;
34
+ ttlMs: number;
35
+ links: ArtifactLinkBase | null;
36
+ }
37
+
38
+ type ArtifactEnv = Pick<
39
+ OracleWorkerEnv,
40
+ | 'ARTIFACT_BUCKET'
41
+ | 'ORACLE_PUBLIC_URL'
42
+ | 'ARTIFACT_VIEWER_URL'
43
+ | 'ARTIFACT_LINK_TTL_DAYS'
44
+ >;
45
+
46
+ function isLocalHost(url: URL): boolean {
47
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
48
+ }
49
+
50
+ /** https, or http on localhost: a link carries its decryption key. */
51
+ export function isSecureUrl(raw: string): boolean {
52
+ try {
53
+ const url = new URL(raw);
54
+ return (
55
+ url.protocol === 'https:' ||
56
+ (url.protocol === 'http:' && isLocalHost(url))
57
+ );
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Null when no bucket is bound. The env schema (`src/core/env.ts`) rejects a
65
+ * malformed or insecure URL and an out-of-range TTL at boot; anything that
66
+ * still reaches here unusable is reported through `warn`, never dropped
67
+ * silently.
68
+ */
69
+ export function artifactStorageConfig(
70
+ env: ArtifactEnv,
71
+ warn: (message: string) => void = () => undefined,
72
+ ): ArtifactStorageConfig | null {
73
+ const bucket = env.ARTIFACT_BUCKET;
74
+ if (!bucket) {
75
+ if (env.ORACLE_PUBLIC_URL)
76
+ warn(
77
+ '[artifacts] ORACLE_PUBLIC_URL is set but no ARTIFACT_BUCKET is bound: artefacts are off',
78
+ );
79
+ return null;
80
+ }
81
+ let links: ArtifactLinkBase | null = null;
82
+ if (!env.ORACLE_PUBLIC_URL)
83
+ warn(
84
+ '[artifacts] ARTIFACT_BUCKET is bound but ORACLE_PUBLIC_URL is not set: no new artefacts; stored ones stay readable and revocable',
85
+ );
86
+ else if (!isSecureUrl(env.ORACLE_PUBLIC_URL))
87
+ warn(
88
+ `[artifacts] ORACLE_PUBLIC_URL must be an https URL (http only on localhost), got ${JSON.stringify(env.ORACLE_PUBLIC_URL)}: no new artefacts; stored ones stay readable and revocable`,
89
+ );
90
+ else {
91
+ const viewerRaw = env.ARTIFACT_VIEWER_URL;
92
+ const viewer =
93
+ viewerRaw && isSecureUrl(viewerRaw) ? new URL(viewerRaw) : null;
94
+ if (viewerRaw && !viewer)
95
+ warn(
96
+ `[artifacts] ARTIFACT_VIEWER_URL must be an https URL (http only on localhost), got ${JSON.stringify(viewerRaw)}: links use the oracle's own viewer`,
97
+ );
98
+ links = {
99
+ publicUrl: new URL(env.ORACLE_PUBLIC_URL).origin,
100
+ ...(viewer ? { viewerUrl: `${viewer.origin}${viewer.pathname}` } : {}),
101
+ };
102
+ }
103
+ let days = DEFAULT_ARTIFACT_TTL_DAYS;
104
+ if (env.ARTIFACT_LINK_TTL_DAYS !== undefined) {
105
+ const parsed = Number(env.ARTIFACT_LINK_TTL_DAYS);
106
+ if (
107
+ Number.isInteger(parsed) &&
108
+ parsed >= 1 &&
109
+ parsed <= MAX_ARTIFACT_TTL_DAYS
110
+ )
111
+ days = parsed;
112
+ else
113
+ warn(
114
+ `[artifacts] ARTIFACT_LINK_TTL_DAYS must be a whole number from 1 to ${MAX_ARTIFACT_TTL_DAYS}, got ${JSON.stringify(env.ARTIFACT_LINK_TTL_DAYS)}: links last ${DEFAULT_ARTIFACT_TTL_DAYS} days`,
115
+ );
116
+ }
117
+ return { bucket, ttlMs: days * DAY_MS, links };
118
+ }
119
+
120
+ /**
121
+ * The interval between full sweeps. The env schema rejects an out-of-range
122
+ * value at boot; one that still reaches the cron is reported through `warn`
123
+ * and replaced by the default.
124
+ */
125
+ export function artifactSweepIntervalMs(
126
+ env: Pick<OracleWorkerEnv, 'ARTIFACT_SWEEP_INTERVAL_HOURS'>,
127
+ warn: (message: string) => void = () => undefined,
128
+ ): number {
129
+ const raw = env.ARTIFACT_SWEEP_INTERVAL_HOURS;
130
+ if (raw === undefined) return DEFAULT_ARTIFACT_SWEEP_INTERVAL_HOURS * HOUR_MS;
131
+ const hours = Number(raw);
132
+ if (
133
+ Number.isInteger(hours) &&
134
+ hours >= 1 &&
135
+ hours <= MAX_ARTIFACT_SWEEP_INTERVAL_HOURS
136
+ )
137
+ return hours * HOUR_MS;
138
+ warn(
139
+ `[artifacts] ARTIFACT_SWEEP_INTERVAL_HOURS must be a whole number from 1 to ${MAX_ARTIFACT_SWEEP_INTERVAL_HOURS}, got ${JSON.stringify(raw)}: sweeping every ${DEFAULT_ARTIFACT_SWEEP_INTERVAL_HOURS} hours`,
140
+ );
141
+ return DEFAULT_ARTIFACT_SWEEP_INTERVAL_HOURS * HOUR_MS;
142
+ }
143
+
144
+ /**
145
+ * Where a person opens an artefact. The oracle's own page reads the key from
146
+ * its fragment; a shared viewer also gets the source in the fragment, so its
147
+ * server learns nothing either.
148
+ */
149
+ export function artifactLink(
150
+ config: ArtifactLinkBase,
151
+ artifactId: string,
152
+ key: string,
153
+ ): string {
154
+ const source = `${config.publicUrl}/a/${artifactId}`;
155
+ return config.viewerUrl
156
+ ? `${config.viewerUrl}#a=${encodeURIComponent(source)}&k=${key}`
157
+ : `${source}#k=${key}`;
158
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Share-copy encryption. An artefact's share copy is AES-256-GCM ciphertext;
3
+ * the key travels only in the link's fragment (`#k=`), which browsers never
4
+ * send to a server, so the bucket, the logs and link-preview crawlers see
5
+ * nothing readable. Object layout: 12-byte IV, then the ciphertext.
6
+ */
7
+
8
+ export interface ArtifactEnvelope {
9
+ v: 1;
10
+ title: string;
11
+ mime: 'text/markdown';
12
+ content: string;
13
+ createdAt: string;
14
+ }
15
+
16
+ const IV_BYTES = 12;
17
+
18
+ export function base64url(bytes: Uint8Array): string {
19
+ let binary = '';
20
+ for (const byte of bytes) binary += String.fromCharCode(byte);
21
+ return btoa(binary)
22
+ .replace(/\+/g, '-')
23
+ .replace(/\//g, '_')
24
+ .replace(/=+$/, '');
25
+ }
26
+
27
+ export function fromBase64url(text: string): Uint8Array {
28
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/');
29
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
30
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
31
+ }
32
+
33
+ /** A fresh 256-bit share key, base64url. */
34
+ export function newShareKey(): string {
35
+ return base64url(crypto.getRandomValues(new Uint8Array(32)));
36
+ }
37
+
38
+ function importKey(
39
+ key: string,
40
+ usage: 'encrypt' | 'decrypt',
41
+ ): Promise<CryptoKey> {
42
+ return crypto.subtle.importKey('raw', fromBase64url(key), 'AES-GCM', false, [
43
+ usage,
44
+ ]);
45
+ }
46
+
47
+ export async function sealArtifact(
48
+ key: string,
49
+ envelope: ArtifactEnvelope,
50
+ ): Promise<Uint8Array> {
51
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
52
+ const ciphertext = new Uint8Array(
53
+ await crypto.subtle.encrypt(
54
+ { name: 'AES-GCM', iv },
55
+ await importKey(key, 'encrypt'),
56
+ new TextEncoder().encode(JSON.stringify(envelope)),
57
+ ),
58
+ );
59
+ const sealed = new Uint8Array(IV_BYTES + ciphertext.byteLength);
60
+ sealed.set(iv, 0);
61
+ sealed.set(ciphertext, IV_BYTES);
62
+ return sealed;
63
+ }
64
+
65
+ /** The viewer's decryption, for tests and the owner API. */
66
+ export async function openArtifact(
67
+ key: string,
68
+ sealed: Uint8Array,
69
+ ): Promise<ArtifactEnvelope> {
70
+ const plain = await crypto.subtle.decrypt(
71
+ { name: 'AES-GCM', iv: sealed.slice(0, IV_BYTES) },
72
+ await importKey(key, 'decrypt'),
73
+ sealed.slice(IV_BYTES),
74
+ );
75
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(plain));
76
+ if (
77
+ !parsed ||
78
+ typeof parsed !== 'object' ||
79
+ !('content' in parsed) ||
80
+ typeof parsed.content !== 'string' ||
81
+ !('title' in parsed) ||
82
+ typeof parsed.title !== 'string' ||
83
+ !('createdAt' in parsed) ||
84
+ typeof parsed.createdAt !== 'string'
85
+ )
86
+ throw new Error('artifacts: the share copy is not a valid envelope');
87
+ return {
88
+ v: 1,
89
+ title: parsed.title,
90
+ mime: 'text/markdown',
91
+ content: parsed.content,
92
+ createdAt: parsed.createdAt,
93
+ };
94
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The public artefact routes. `GET /a/:id` is the same static page for every
3
+ * artefact; `GET /a/:id/data` returns the ciphertext. Neither needs auth:
4
+ * without the key in the link's fragment the bytes are unreadable, and any
5
+ * origin may fetch them (a shared viewer on Qi.Space does).
6
+ */
7
+ import type { OracleWorkerEnv } from '../do/contracts';
8
+ import { ARTIFACT_ID_RE, artifactObjectKey } from './store';
9
+ import { viewerContentSecurityPolicy, viewerHtml } from './viewer-page';
10
+
11
+ const SECURITY_HEADERS: Record<string, string> = {
12
+ 'x-content-type-options': 'nosniff',
13
+ 'referrer-policy': 'no-referrer',
14
+ 'x-robots-tag': 'noindex, nofollow',
15
+ };
16
+
17
+ function notFound(): Response {
18
+ return new Response('Not found', {
19
+ status: 404,
20
+ headers: {
21
+ 'content-type': 'text/plain',
22
+ 'access-control-allow-origin': '*',
23
+ ...SECURITY_HEADERS,
24
+ },
25
+ });
26
+ }
27
+
28
+ export async function artifactPageResponse(
29
+ env: Pick<OracleWorkerEnv, 'ORACLE_NAME' | 'ARTIFACT_BUCKET'>,
30
+ artifactId: string,
31
+ ): Promise<Response> {
32
+ if (!env.ARTIFACT_BUCKET || !ARTIFACT_ID_RE.test(artifactId))
33
+ return notFound();
34
+ return new Response(viewerHtml(env.ORACLE_NAME || 'QiForge oracle'), {
35
+ headers: {
36
+ 'content-type': 'text/html; charset=utf-8',
37
+ 'content-security-policy': await viewerContentSecurityPolicy(),
38
+ 'cache-control': 'public, max-age=300',
39
+ ...SECURITY_HEADERS,
40
+ },
41
+ });
42
+ }
43
+
44
+ export async function artifactDataResponse(
45
+ env: Pick<OracleWorkerEnv, 'ARTIFACT_BUCKET'>,
46
+ artifactId: string,
47
+ now: number = Date.now(),
48
+ ): Promise<Response> {
49
+ const bucket = env.ARTIFACT_BUCKET;
50
+ if (!bucket || !ARTIFACT_ID_RE.test(artifactId)) return notFound();
51
+ const object = await bucket.get(artifactObjectKey(artifactId));
52
+ if (!object) return notFound();
53
+ const expiresAt = Date.parse(object.customMetadata?.expiresAt ?? '');
54
+ if (Number.isFinite(expiresAt) && expiresAt <= now) {
55
+ await bucket.delete(artifactObjectKey(artifactId));
56
+ return new Response('Expired', {
57
+ status: 410,
58
+ headers: {
59
+ 'content-type': 'text/plain',
60
+ 'access-control-allow-origin': '*',
61
+ ...SECURITY_HEADERS,
62
+ },
63
+ });
64
+ }
65
+ return new Response(object.body, {
66
+ headers: {
67
+ 'content-type': 'application/octet-stream',
68
+ 'cache-control': 'private, max-age=60',
69
+ 'access-control-allow-origin': '*',
70
+ ...SECURITY_HEADERS,
71
+ },
72
+ });
73
+ }