@zackbart/connecta 0.24.4 → 0.26.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.
- package/AGENTS.md +43 -12
- package/CHANGELOG.md +524 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +330 -240
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A deployable Worker that aggregates a downstream remote MCP and an in-code HTTP
|
|
4
4
|
API connector, guarded by Cloudflare Access, with state in a KV namespace. Its
|
|
5
|
-
required Worker Loader binding backs the
|
|
5
|
+
required Worker Loader binding backs the eight-tool surface and requires the
|
|
6
6
|
Workers Paid plan.
|
|
7
7
|
|
|
8
8
|
This is also the **starting template for a deployment**: a real deployment
|
|
@@ -16,15 +16,26 @@ migrations, and secrets.
|
|
|
16
16
|
| --- | --- |
|
|
17
17
|
| `src/index.ts` | the Worker entrypoint — connector and auth configuration |
|
|
18
18
|
| `src/cloudflare-kv.ts` | `KVStorage` over Workers KV (deployment-owned, not a package export) |
|
|
19
|
+
| `src/d1-storage.ts` | `KVStorage` over D1 with atomic compare-and-set (optional; see below) |
|
|
19
20
|
| `src/d1-activity.ts` | `ActivityStore` over D1 (deployment-owned; see below) |
|
|
20
21
|
| `src/d1-activity-row.ts` | the row ↔ event mapping `d1-activity.ts` uses, including friction derived from `error_code` for rows written before that column existed |
|
|
22
|
+
| `src/r2-artifact-blobs.ts` | artifact bodies in an R2 bucket, beside a D1-backed `kvArtifactStore` (optional) |
|
|
21
23
|
| `wrangler.jsonc` | Worker name, vars, bindings, `compatibility_flags` |
|
|
22
24
|
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Keep `enable_request_signal` in `wrangler.jsonc`. On Workers it lets a live
|
|
26
|
+
response's client disconnect abort its request, so connecta cancels the stream
|
|
27
|
+
and returns the admission permit promptly. The total admitted-request bound in
|
|
28
|
+
`admission.requests.maxDurationMs` (default 300,000 ms) also reclaims capacity
|
|
29
|
+
on a later request if workerd ends one without delivering a signal or stream
|
|
30
|
+
cancellation. Set it above the longest authorization, tool call, and response
|
|
31
|
+
delivery your deployment expects to serve.
|
|
32
|
+
|
|
33
|
+
`cloudflare-kv.ts`, `d1-storage.ts`, and `d1-activity.ts` deliberately live
|
|
34
|
+
here rather than in the package: storage backends are deployment-owned, so the
|
|
35
|
+
package ships only the generic `KVStorage` and `ActivityStore` contracts.
|
|
36
|
+
Workers KV is eventually consistent across locations; use a strongly consistent
|
|
37
|
+
`KVStorage` adapter, such as [`d1-storage.ts`](#strongly-consistent-storage-optional),
|
|
38
|
+
when OAuth disconnect or credential rotation must become globally visible
|
|
28
39
|
immediately.
|
|
29
40
|
|
|
30
41
|
## Deploy
|
|
@@ -101,11 +112,106 @@ The checked-in `access.dev` block gives `wrangler dev` a local operator
|
|
|
101
112
|
identity. Remove the block to test the missing-Access refusal. It has no effect
|
|
102
113
|
on a deployed Worker's production identity.
|
|
103
114
|
|
|
115
|
+
### Optional resource management with Alchemy
|
|
116
|
+
|
|
117
|
+
Keep this example's Worker on Wrangler. A copied deployment may use
|
|
118
|
+
[Alchemy](https://alchemy.run) to manage its KV namespace, optional D1 databases
|
|
119
|
+
and R2 bucket, and the Access application without adding Alchemy to connecta or
|
|
120
|
+
changing this template. In that deployment, install `alchemy@2.0.0-beta.79`
|
|
121
|
+
and `effect@4.0.0-rc.117` at exact versions. The latter is connecta's own
|
|
122
|
+
Effect pin; check `npm ls effect` for one resolved copy after installation.
|
|
123
|
+
The following `alchemy.run.ts` is a resource stack, not a Worker deployment:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import * as Alchemy from "alchemy";
|
|
127
|
+
import * as Cloudflare from "alchemy/Cloudflare";
|
|
128
|
+
import { adopt } from "alchemy/AdoptPolicy";
|
|
129
|
+
import * as Effect from "effect/Effect";
|
|
130
|
+
|
|
131
|
+
// Replace this with the immutable ID of the Wrangler-deployed Worker.
|
|
132
|
+
const workerId = "replace-with-worker-id";
|
|
133
|
+
|
|
134
|
+
export default Alchemy.Stack(
|
|
135
|
+
"ConnectaResources",
|
|
136
|
+
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const kv = yield* Cloudflare.KV.Namespace("ConnectaKV", { title: "connecta-kv" }).pipe(adopt(true));
|
|
139
|
+
const activity = yield* Cloudflare.D1.Database("ActivityDB", {
|
|
140
|
+
name: "connecta-activity", migrations: "./migrations/activity",
|
|
141
|
+
}).pipe(adopt(true));
|
|
142
|
+
const storage = yield* Cloudflare.D1.Database("StorageDB", {
|
|
143
|
+
name: "connecta-storage", migrations: "./migrations/storage",
|
|
144
|
+
}).pipe(adopt(true));
|
|
145
|
+
const blobs = yield* Cloudflare.R2.Bucket("ArtifactBlobs", { name: "connecta-artifacts" }).pipe(adopt(true));
|
|
146
|
+
const access = yield* Cloudflare.Access.Application("ConnectaAccess", {
|
|
147
|
+
type: "self_hosted",
|
|
148
|
+
name: "Connecta",
|
|
149
|
+
destinations: [{ type: "worker", workerId }],
|
|
150
|
+
policies: [{ decision: "allow", include: [{ emailDomain: "example.com" }] }],
|
|
151
|
+
oauthConfiguration: {
|
|
152
|
+
enabled: true,
|
|
153
|
+
dynamicClientRegistration: {
|
|
154
|
+
enabled: true,
|
|
155
|
+
allowedUris: [
|
|
156
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
157
|
+
"https://chatgpt.com/connector_platform_oauth_redirect",
|
|
158
|
+
"https://chatgpt.com/connector/oauth/*",
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}).pipe(adopt(true));
|
|
163
|
+
return { kvId: kv.namespaceId, activityId: activity.databaseId,
|
|
164
|
+
storageId: storage.databaseId, bucketName: blobs.bucketName,
|
|
165
|
+
accessId: access.applicationId };
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Replace the Worker ID and example Access policy before applying this stack.
|
|
171
|
+
Keep only the resources the deployment uses: `STORAGE_DB` replaces
|
|
172
|
+
`CONNECTA_KV`; `ACTIVITY_DB` and R2 are optional. Copy the output IDs into the
|
|
173
|
+
matching `wrangler.jsonc` bindings. Keep `LOADER` in `worker_loaders` and, when
|
|
174
|
+
enabled, the Browser Rendering binding and cron trigger in the Wrangler-owned
|
|
175
|
+
Worker configuration. Configure the main and artifact hostnames as Wrangler
|
|
176
|
+
custom domain `routes` with `custom_domain: true`. The Worker-level Access
|
|
177
|
+
destination covers both hostnames; verify both against the same application
|
|
178
|
+
after domain setup. Continue to set secrets with `wrangler secret put` and to
|
|
179
|
+
deploy the Worker with Wrangler. After its initial `wrangler deploy`, use
|
|
180
|
+
`wrangler versions upload` and `wrangler versions deploy` for code updates;
|
|
181
|
+
apply route and cron changes through Wrangler too.
|
|
182
|
+
|
|
183
|
+
The `adopt(true)` calls explicitly permit taking over matching existing
|
|
184
|
+
resources; compare their live names, IDs, Access policy, and data before the
|
|
185
|
+
first Alchemy deploy. Remove adoption where a resource is new. Put the D1 SQL
|
|
186
|
+
from this README in the shown migration directories before deploying. With
|
|
187
|
+
`migrations` configured, Alchemy copies an adopted database's Wrangler
|
|
188
|
+
migration history once into `__alchemy_migrations`; the old history stays
|
|
189
|
+
frozen. Review pending SQL before handing migrations over. Remote
|
|
190
|
+
`Cloudflare.state()` bootstraps an account-level state-store Worker backed by a
|
|
191
|
+
Durable Object and Secrets Store entries for its credentials. Retain them:
|
|
192
|
+
deleting the state store loses Alchemy's record of what it owns.
|
|
193
|
+
|
|
194
|
+
Verification for this optional path stops at the API and types. The fenced
|
|
195
|
+
snippet was copied to an isolated `alchemy.run.ts` and passed `tsc --noEmit`
|
|
196
|
+
with TypeScript 5.9.3, Alchemy 2.0.0-beta.79, and Effect 4.0.0-rc.117; no
|
|
197
|
+
Alchemy deploy or adoption was run.
|
|
198
|
+
[Alchemy's Worker resource](https://alchemy.run/cloudflare/compute/workers/)
|
|
199
|
+
can declare `WorkerLoader`, domains, Browser Rendering, and cron wiring, but
|
|
200
|
+
those properties are coupled to its script deploy. Its current full deploy
|
|
201
|
+
still uses the script-upload endpoint; only a conditional gradual rollout uses
|
|
202
|
+
the versions API. A previous full deploy through that endpoint did not provide
|
|
203
|
+
`ctx.access`, so do not transfer Worker ownership until that path is verified
|
|
204
|
+
end to end. The [Access](https://alchemy.run/cloudflare/security/access/),
|
|
205
|
+
[D1](https://alchemy.run/cloudflare/data/d1/), and
|
|
206
|
+
[state-store](https://alchemy.run/state-store/) guides describe the resources
|
|
207
|
+
above; the typecheck does not prove Cloudflare will accept this particular
|
|
208
|
+
Access adoption or preserve production identity.
|
|
209
|
+
|
|
104
210
|
### Copied into its own repository
|
|
105
211
|
|
|
106
212
|
The `npm install` above is the connecta repository's, which already has every
|
|
107
|
-
dependency this file imports. A copy with its own `package.json` installs
|
|
108
|
-
|
|
213
|
+
dependency this file imports. A copy with its own `package.json` installs
|
|
214
|
+
Connecta and the separately installed `@cloudflare/codemode` peer:
|
|
109
215
|
|
|
110
216
|
```sh
|
|
111
217
|
npm install @zackbart/connecta @cloudflare/codemode
|
|
@@ -123,7 +229,10 @@ the Clerk path in the Node template.
|
|
|
123
229
|
|
|
124
230
|
Then point an MCP client at `<PUBLIC_URL>/mcp`. The example explicitly enables
|
|
125
231
|
`ui: operatorUi()`; open `<PUBLIC_URL>/` for Connections and each connection's
|
|
126
|
-
authentication controls.
|
|
232
|
+
authentication controls. The page labels tool safety, offers fixed repair
|
|
233
|
+
prompts for classified failures, and shows client setup commands for endpoints
|
|
234
|
+
the signed-in person may use. Those commands contain no token. There is no
|
|
235
|
+
separate Credentials or Tokens tab.
|
|
127
236
|
|
|
128
237
|
## Select optional modules
|
|
129
238
|
|
|
@@ -164,6 +273,8 @@ Reuse the same key and KV namespace during upgrades. Without the secret, omit
|
|
|
164
273
|
the vault; declared credential slots remain unmanageable. Keep the key outside
|
|
165
274
|
KV because it protects a copied namespace. Credential replacement takes effect
|
|
166
275
|
on the next call without redeploying; no liveness probe runs in the background.
|
|
276
|
+
With this vault, 0.25.0 seals existing downstream OAuth state on first read.
|
|
277
|
+
Rolling back to 0.24.x then requires reauthorizing those OAuth connectors.
|
|
167
278
|
|
|
168
279
|
The shipped Notion connector uses a deployment-owned static header and echo
|
|
169
280
|
needs no secret. To exercise vault controls, declare a `credential` slot on an
|
|
@@ -184,7 +295,7 @@ below. Omit the module and store wiring to record no history and show no
|
|
|
184
295
|
Activity tab. Diagnostics remain independent; `logger: "silent"` suppresses
|
|
185
296
|
them explicitly.
|
|
186
297
|
|
|
187
|
-
Verify MCP health and the exact
|
|
298
|
+
Verify MCP health and the exact eight tools with:
|
|
188
299
|
|
|
189
300
|
```sh
|
|
190
301
|
CF_ACCESS_CLIENT_ID=… CF_ACCESS_CLIENT_SECRET=… \
|
|
@@ -209,11 +320,105 @@ The required Worker Loader binding is checked into `wrangler.jsonc`:
|
|
|
209
320
|
```
|
|
210
321
|
|
|
211
322
|
`src/index.ts` constructs `DynamicWorkerExecutor` with only `env.LOADER` and
|
|
212
|
-
serves the
|
|
323
|
+
serves the eight-tool surface. Do not add `bindings`, `modules`, or
|
|
213
324
|
`globalOutbound`; they grant guest code ambient authority. A copied deployment
|
|
214
325
|
owns the package install — see
|
|
215
326
|
[copied into its own repository](#copied-into-its-own-repository).
|
|
216
327
|
|
|
328
|
+
## Strongly consistent storage (optional)
|
|
329
|
+
|
|
330
|
+
`KVStorage` has an optional atomic `compareAndSet`, for a subsystem that must
|
|
331
|
+
claim a key exactly once — resumable writes, where two `resume_execution` calls
|
|
332
|
+
racing for one paused program must send its writes once. **Workers KV cannot
|
|
333
|
+
provide it**, so on KV this example turns resumable writes off
|
|
334
|
+
(`execute: { resumableWrites: false }` in `src/index.ts`) and its programs
|
|
335
|
+
refuse writes. It is eventually
|
|
336
|
+
consistent: two locations can each read a key as absent and both write, so
|
|
337
|
+
`cloudflare-kv.ts` declares no `compareAndSet` rather than fake one with a read
|
|
338
|
+
followed by a write. `src/d1-storage.ts` is a complete `KVStorage` over D1
|
|
339
|
+
that does provide it, one SQL statement per claim, with TTLs honored at read
|
|
340
|
+
time. It replaces the KV namespace rather than sitting beside it: every piece
|
|
341
|
+
of connecta state lives in one store.
|
|
342
|
+
|
|
343
|
+
1. Create the database and uncomment the `STORAGE_DB` entry of the
|
|
344
|
+
`d1_databases` binding in `wrangler.jsonc`, pasting in the id it prints:
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
wrangler d1 create connecta-storage
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
2. Apply the schema (keep it in a deployment-owned `migrations/` directory):
|
|
351
|
+
|
|
352
|
+
```sql
|
|
353
|
+
CREATE TABLE IF NOT EXISTS connecta_kv (
|
|
354
|
+
key TEXT PRIMARY KEY,
|
|
355
|
+
value TEXT NOT NULL,
|
|
356
|
+
expires_at_ms INTEGER
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
CREATE INDEX IF NOT EXISTS connecta_kv_expiry
|
|
360
|
+
ON connecta_kv (expires_at_ms);
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
3. In `src/index.ts`, add `STORAGE_DB: D1Database` to `Env` and build the store
|
|
364
|
+
from it:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
import { d1Storage } from "./d1-storage.js";
|
|
368
|
+
|
|
369
|
+
const storage = d1Storage(env.STORAGE_DB);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Then delete the `execute: { resumableWrites: false }` line: on D1,
|
|
373
|
+
resumable writes default on, and programs pause at a write until
|
|
374
|
+
`resume_execution` approves it.
|
|
375
|
+
|
|
376
|
+
Switching an existing deployment starts from empty state: downstream OAuth
|
|
377
|
+
connections must be re-authorized and vault credentials re-entered, because
|
|
378
|
+
nothing copies them out of the KV namespace.
|
|
379
|
+
|
|
380
|
+
## Artifacts (optional)
|
|
381
|
+
|
|
382
|
+
`artifacts()` from `@zackbart/connecta/artifacts` adds the built-in `artifacts`
|
|
383
|
+
connector: team pages whose data lives in versioned JSON documents. Every write
|
|
384
|
+
commits by compare-and-set, so the store needs the D1 storage above — Workers
|
|
385
|
+
KV is refused at construction. Artifacts share the `connecta_kv` table and need
|
|
386
|
+
no schema of their own. Bodies (page sources and documents) can stay in D1 rows,
|
|
387
|
+
which the default limits keep well under D1's row size, or go to R2 with
|
|
388
|
+
`src/r2-artifact-blobs.ts`:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { artifacts, kvArtifactStore } from "@zackbart/connecta/artifacts";
|
|
392
|
+
import { r2ArtifactBlobs } from "./r2-artifact-blobs.js";
|
|
393
|
+
|
|
394
|
+
createConnecta({
|
|
395
|
+
// …
|
|
396
|
+
publicUrl: env.PUBLIC_URL, // required: artifact links are shared
|
|
397
|
+
artifacts: artifacts({
|
|
398
|
+
store: kvArtifactStore(d1Storage(env.STORAGE_DB), {
|
|
399
|
+
blobs: r2ArtifactBlobs(env.ARTIFACTS_BUCKET), // optional
|
|
400
|
+
}),
|
|
401
|
+
}),
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
For R2, create the bucket (`wrangler r2 bucket create connecta-artifacts`) and
|
|
406
|
+
add `"r2_buckets": [{ "binding": "ARTIFACTS_BUCKET", "bucket_name":
|
|
407
|
+
"connecta-artifacts" }]` to `wrangler.jsonc` and `ARTIFACTS_BUCKET: R2Bucket`
|
|
408
|
+
to `Env`. Nothing ever deletes a body: versions are immutable, a rollback
|
|
409
|
+
points back at an old one, and a body written by a write that lost a conflict
|
|
410
|
+
stays stored unreferenced.
|
|
411
|
+
|
|
412
|
+
To refresh pages, keep the module in a variable, pass it to `createConnecta`,
|
|
413
|
+
and assign it to `scheduledArtifacts.module` as shown in `src/index.ts`.
|
|
414
|
+
Uncomment the hourly cron in `wrangler.jsonc`. An hourly tick starts at most
|
|
415
|
+
10 due pages; each page's `manual`, `daily`, or `weekly` schedule lives in its
|
|
416
|
+
versioned refresh configuration. Refresh programs use only shared connectors'
|
|
417
|
+
explicitly read-only tools within the program owner's current grants. Revoking
|
|
418
|
+
refresh or pool access stops future runs. D1's compare-and-set prevents two cron invocations
|
|
419
|
+
from claiming the same page. Failed runs keep the last good data and show a
|
|
420
|
+
stale banner without exposing downstream error text to readers.
|
|
421
|
+
|
|
217
422
|
## Activity history (optional)
|
|
218
423
|
|
|
219
424
|
`src/d1-activity.ts` is a complete `ActivityStore` over D1 — keyset paging on
|
|
@@ -252,6 +457,7 @@ To enable it:
|
|
|
252
457
|
attempts INTEGER NOT NULL,
|
|
253
458
|
error_code TEXT,
|
|
254
459
|
friction TEXT,
|
|
460
|
+
approval TEXT,
|
|
255
461
|
server_name TEXT NOT NULL,
|
|
256
462
|
server_version TEXT NOT NULL,
|
|
257
463
|
deployment_id TEXT
|
|
@@ -261,15 +467,19 @@ To enable it:
|
|
|
261
467
|
ON tool_call_activity (occurred_at_ms DESC, id DESC);
|
|
262
468
|
```
|
|
263
469
|
|
|
264
|
-
**Already have this table?** `actor_namespace` and `
|
|
265
|
-
after the original example, and `CREATE TABLE IF NOT EXISTS`
|
|
266
|
-
them to a table that already exists. Add them as migrations:
|
|
470
|
+
**Already have this table?** `actor_namespace`, `friction`, and `approval`
|
|
471
|
+
were added after the original example, and `CREATE TABLE IF NOT EXISTS`
|
|
472
|
+
will not add them to a table that already exists. Add them as migrations:
|
|
267
473
|
|
|
268
474
|
```sql
|
|
269
475
|
ALTER TABLE tool_call_activity ADD COLUMN actor_namespace TEXT;
|
|
270
476
|
ALTER TABLE tool_call_activity ADD COLUMN friction TEXT;
|
|
477
|
+
ALTER TABLE tool_call_activity ADD COLUMN approval TEXT;
|
|
271
478
|
```
|
|
272
479
|
|
|
480
|
+
`approval` is set only on an `approved` row — the scope a resumed
|
|
481
|
+
program's approval covered, `call` or `tool` — and is otherwise null.
|
|
482
|
+
|
|
273
483
|
`friction` is stored rather than derived because one of its classes belongs
|
|
274
484
|
to a call that *succeeded*: a result too large to return inline is friction
|
|
275
485
|
for the agent and carries no error code. Rows written before the column keep
|
|
@@ -18,8 +18,15 @@ export interface ActivityEvent {
|
|
|
18
18
|
| "call_tool"
|
|
19
19
|
| "call_destructive_tool"
|
|
20
20
|
| "batch_call"
|
|
21
|
-
| "execute_code"
|
|
22
|
-
|
|
21
|
+
| "execute_code"
|
|
22
|
+
| "resume_execution";
|
|
23
|
+
outcome:
|
|
24
|
+
| "success"
|
|
25
|
+
| "error"
|
|
26
|
+
| "timeout"
|
|
27
|
+
| "cancelled"
|
|
28
|
+
| "paused"
|
|
29
|
+
| "approved";
|
|
23
30
|
durationMs: number;
|
|
24
31
|
attempts: number;
|
|
25
32
|
errorCode?: string;
|
|
@@ -29,6 +36,7 @@ export interface ActivityEvent {
|
|
|
29
36
|
| "destructive_reroute"
|
|
30
37
|
| "auth_required"
|
|
31
38
|
| "result_too_large";
|
|
39
|
+
approval?: "call" | "tool";
|
|
32
40
|
serverName: string;
|
|
33
41
|
serverVersion: string;
|
|
34
42
|
deploymentId?: string;
|
|
@@ -76,6 +84,8 @@ export interface ActivityRow {
|
|
|
76
84
|
attempts: number;
|
|
77
85
|
error_code: string | null;
|
|
78
86
|
friction: ActivityEvent["friction"] | null;
|
|
87
|
+
/** Absent from tables created before resumable writes; read as null. */
|
|
88
|
+
approval?: ActivityEvent["approval"] | null;
|
|
79
89
|
server_name: string;
|
|
80
90
|
server_version: string;
|
|
81
91
|
deployment_id: string | null;
|
|
@@ -102,6 +112,7 @@ export function activityEventToRow(
|
|
|
102
112
|
// friction on a call that succeeded, so it has no error code to derive from
|
|
103
113
|
// — and `error_code IS NOT NULL` stays an honest count of failures.
|
|
104
114
|
friction: event.friction ?? null,
|
|
115
|
+
approval: event.approval ?? null,
|
|
105
116
|
server_name: event.serverName,
|
|
106
117
|
server_version: event.serverVersion,
|
|
107
118
|
deployment_id: event.deploymentId ?? null,
|
|
@@ -131,6 +142,7 @@ export function activityRowToEvent(
|
|
|
131
142
|
attempts: row.attempts,
|
|
132
143
|
...(row.error_code ? { errorCode: row.error_code } : {}),
|
|
133
144
|
...(friction ? { friction } : {}),
|
|
145
|
+
...(row.approval ? { approval: row.approval } : {}),
|
|
134
146
|
serverName: row.server_name,
|
|
135
147
|
serverVersion: row.server_version,
|
|
136
148
|
...(row.deployment_id
|
|
@@ -50,8 +50,9 @@ export function d1ActivityStore(db: D1Database): ActivityStore {
|
|
|
50
50
|
id, occurred_at_ms, request_id, actor_kind, actor_id,
|
|
51
51
|
actor_namespace,
|
|
52
52
|
connector_id, tool_name, source, outcome, duration_ms, attempts,
|
|
53
|
-
error_code, friction, server_name, server_version,
|
|
54
|
-
|
|
53
|
+
error_code, friction, approval, server_name, server_version,
|
|
54
|
+
deployment_id
|
|
55
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
55
56
|
)
|
|
56
57
|
.bind(
|
|
57
58
|
row.id,
|
|
@@ -68,6 +69,7 @@ export function d1ActivityStore(db: D1Database): ActivityStore {
|
|
|
68
69
|
row.attempts,
|
|
69
70
|
row.error_code,
|
|
70
71
|
row.friction ?? null,
|
|
72
|
+
row.approval ?? null,
|
|
71
73
|
row.server_name,
|
|
72
74
|
row.server_version,
|
|
73
75
|
row.deployment_id,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { KVStorage } from "@zackbart/connecta";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KVStorage over a D1 database, with the atomic `compareAndSet` that Workers
|
|
5
|
+
* KV cannot offer. Apply the `connecta_kv` schema in README.md § "Strongly
|
|
6
|
+
* consistent storage" before first use.
|
|
7
|
+
*
|
|
8
|
+
* Every compare-and-set is a single SQL statement. D1 runs one database's
|
|
9
|
+
* statements one at a time against its primary, so the comparison and the
|
|
10
|
+
* write cannot interleave with another request's: of fifty concurrent claims
|
|
11
|
+
* on an absent key, exactly one changes a row.
|
|
12
|
+
*
|
|
13
|
+
* Expiry is judged by the Worker's clock at each call, the same `Date.now()`
|
|
14
|
+
* the core uses. An expired row reads as absent immediately and is removed
|
|
15
|
+
* physically by later writes, a bounded batch at a time, so no request starts
|
|
16
|
+
* a background job and no cron is required.
|
|
17
|
+
*/
|
|
18
|
+
export function d1Storage(db: D1Database): KVStorage {
|
|
19
|
+
// Every statement that tests liveness binds the current time as ?2.
|
|
20
|
+
const live = "(expires_at_ms IS NULL OR expires_at_ms > ?2)";
|
|
21
|
+
const expiresAt = (now: number, ttlSeconds?: number) =>
|
|
22
|
+
ttlSeconds ? now + ttlSeconds * 1000 : null;
|
|
23
|
+
const changed = (result: D1Result) => result.meta.changes > 0;
|
|
24
|
+
return {
|
|
25
|
+
async get(key) {
|
|
26
|
+
const row = await db
|
|
27
|
+
.prepare(`SELECT value FROM connecta_kv WHERE key = ?1 AND ${live}`)
|
|
28
|
+
.bind(key, Date.now())
|
|
29
|
+
.first<{ value: string }>();
|
|
30
|
+
return row?.value ?? null;
|
|
31
|
+
},
|
|
32
|
+
async set(key, value, opts) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
await db.batch([
|
|
35
|
+
db
|
|
36
|
+
.prepare(
|
|
37
|
+
`DELETE FROM connecta_kv WHERE key IN (
|
|
38
|
+
SELECT key FROM connecta_kv WHERE expires_at_ms <= ?1 LIMIT 16
|
|
39
|
+
)`,
|
|
40
|
+
)
|
|
41
|
+
.bind(now),
|
|
42
|
+
db
|
|
43
|
+
.prepare(
|
|
44
|
+
`INSERT INTO connecta_kv (key, value, expires_at_ms)
|
|
45
|
+
VALUES (?1, ?2, ?3)
|
|
46
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
47
|
+
value = excluded.value, expires_at_ms = excluded.expires_at_ms`,
|
|
48
|
+
)
|
|
49
|
+
.bind(key, value, expiresAt(now, opts?.ttlSeconds)),
|
|
50
|
+
]);
|
|
51
|
+
},
|
|
52
|
+
async delete(key) {
|
|
53
|
+
await db.prepare("DELETE FROM connecta_kv WHERE key = ?1").bind(key).run();
|
|
54
|
+
},
|
|
55
|
+
async list(prefix) {
|
|
56
|
+
const { results } = await db
|
|
57
|
+
.prepare(
|
|
58
|
+
`SELECT key FROM connecta_kv
|
|
59
|
+
WHERE key >= ?1 AND substr(key, 1, length(?1)) = ?1 AND ${live}`,
|
|
60
|
+
)
|
|
61
|
+
.bind(prefix, Date.now())
|
|
62
|
+
.all<{ key: string }>();
|
|
63
|
+
// Sort in JavaScript: SQLite orders UTF-8 bytes, the contract orders
|
|
64
|
+
// the UTF-16 code units every other adapter sorts by.
|
|
65
|
+
return results.map((row) => row.key).sort();
|
|
66
|
+
},
|
|
67
|
+
async compareAndSet(key, expected, next, opts) {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (next === null && expected === null) {
|
|
70
|
+
// Nothing to write; the claim holds exactly when no live row exists.
|
|
71
|
+
const row = await db
|
|
72
|
+
.prepare(`SELECT 1 AS present FROM connecta_kv WHERE key = ?1 AND ${live}`)
|
|
73
|
+
.bind(key, now)
|
|
74
|
+
.first();
|
|
75
|
+
return row === null;
|
|
76
|
+
}
|
|
77
|
+
if (next === null) {
|
|
78
|
+
return changed(
|
|
79
|
+
await db
|
|
80
|
+
.prepare(
|
|
81
|
+
`DELETE FROM connecta_kv WHERE key = ?1 AND ${live} AND value = ?3`,
|
|
82
|
+
)
|
|
83
|
+
.bind(key, now, expected)
|
|
84
|
+
.run(),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const expiry = expiresAt(now, opts?.ttlSeconds);
|
|
88
|
+
if (expected === null) {
|
|
89
|
+
// Insert, or take over a row that has expired. A live row makes the
|
|
90
|
+
// upsert's WHERE false, so nothing changes and the claim is refused.
|
|
91
|
+
return changed(
|
|
92
|
+
await db
|
|
93
|
+
.prepare(
|
|
94
|
+
`INSERT INTO connecta_kv (key, value, expires_at_ms)
|
|
95
|
+
VALUES (?1, ?3, ?4)
|
|
96
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
97
|
+
value = excluded.value, expires_at_ms = excluded.expires_at_ms
|
|
98
|
+
WHERE connecta_kv.expires_at_ms IS NOT NULL
|
|
99
|
+
AND connecta_kv.expires_at_ms <= ?2`,
|
|
100
|
+
)
|
|
101
|
+
.bind(key, now, next, expiry)
|
|
102
|
+
.run(),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return changed(
|
|
106
|
+
await db
|
|
107
|
+
.prepare(
|
|
108
|
+
`UPDATE connecta_kv SET value = ?4, expires_at_ms = ?5
|
|
109
|
+
WHERE key = ?1 AND ${live} AND value = ?3`,
|
|
110
|
+
)
|
|
111
|
+
.bind(key, now, expected, next, expiry)
|
|
112
|
+
.run(),
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -8,7 +8,7 @@ import { encryptedCredentialVault } from "@zackbart/connecta/credentials";
|
|
|
8
8
|
* by Cloudflare Access, with OAuth/cache state in a KV namespace. Access
|
|
9
9
|
* authenticates the request before this Worker runs and supplies the trusted
|
|
10
10
|
* identity through ctx.access. The required Worker Loader binding in
|
|
11
|
-
* wrangler.jsonc backs the
|
|
11
|
+
* wrangler.jsonc backs the eight-tool surface.
|
|
12
12
|
*
|
|
13
13
|
* The operator surface is wired here except for activity history, which needs
|
|
14
14
|
* a database this example does not create for you: sign-in and the credential
|
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
} from "@zackbart/connecta";
|
|
44
44
|
import { cloudflareAccessAuth } from "@zackbart/connecta/auth/cloudflare-access";
|
|
45
45
|
import { cloudflareKvStorage } from "./cloudflare-kv.js";
|
|
46
|
+
// import { artifacts, kvArtifactStore } from "@zackbart/connecta/artifacts";
|
|
47
|
+
// import { d1Storage } from "./d1-storage.js";
|
|
46
48
|
// Activity history, off by default because it needs a D1 database.
|
|
47
49
|
// import { d1ActivityStore } from "./d1-activity.js";
|
|
48
50
|
|
|
@@ -50,8 +52,8 @@ interface Env {
|
|
|
50
52
|
CONNECTA_KV: KVNamespace;
|
|
51
53
|
/**
|
|
52
54
|
* Base64 32-byte AES key encrypting operator-managed credentials in KV.
|
|
53
|
-
* Unset means no vault:
|
|
54
|
-
*
|
|
55
|
+
* Unset means no vault: credential management is unavailable in the operator
|
|
56
|
+
* UI. Never put it in KV, since it is what protects KV.
|
|
55
57
|
*/
|
|
56
58
|
CREDENTIAL_ENCRYPTION_KEY: string;
|
|
57
59
|
DOWNSTREAM_TOKEN: string;
|
|
@@ -67,10 +69,21 @@ interface Env {
|
|
|
67
69
|
|
|
68
70
|
function build(env: Env) {
|
|
69
71
|
const storage = cloudflareKvStorage(env.CONNECTA_KV);
|
|
72
|
+
// Optional refreshable pages need the D1 binding, not Workers KV:
|
|
73
|
+
// const artifactModule = artifacts({ store: kvArtifactStore(d1Storage(env.STORAGE_DB)) });
|
|
74
|
+
// scheduledArtifacts.module = artifactModule;
|
|
70
75
|
return createConnecta({
|
|
71
76
|
publicUrl: env.PUBLIC_URL,
|
|
72
77
|
storage,
|
|
73
78
|
executor: new DynamicWorkerExecutor({ loader: env.LOADER }),
|
|
79
|
+
// Resumable writes — a program pausing at a write until resume_execution
|
|
80
|
+
// approves it — need storage with an atomic compareAndSet, and Workers KV
|
|
81
|
+
// cannot provide one. On KV they are off: programs refuse writes and
|
|
82
|
+
// resume_execution says there is nothing to resume. This line states that
|
|
83
|
+
// choice (without it connecta would warn once at startup). To let programs
|
|
84
|
+
// pause, switch to the D1 store in README.md § "Strongly consistent
|
|
85
|
+
// storage" and delete this line.
|
|
86
|
+
execute: { resumableWrites: false },
|
|
74
87
|
auth: [
|
|
75
88
|
// Access owns admission policy. A human identity may use MCP and the
|
|
76
89
|
// operator pages; a service token may use MCP but cannot mutate operator
|
|
@@ -103,7 +116,24 @@ function build(env: Env) {
|
|
|
103
116
|
// connector like `notion()`, which declares its own) and the form appears
|
|
104
117
|
// on the next load.
|
|
105
118
|
vault: encryptedCredentialVault(storage, env.CREDENTIAL_ENCRYPTION_KEY),
|
|
119
|
+
// Branding is code too: name, owner, description, favicon, and five theme
|
|
120
|
+
// tokens that every other color is mixed from. A value that fails its
|
|
121
|
+
// check falls back to the default, and the startup warning names it.
|
|
122
|
+
// ui: operatorUi({
|
|
123
|
+
// branding: {
|
|
124
|
+
// productName: "Acme Tools",
|
|
125
|
+
// ownerName: "Acme",
|
|
126
|
+
// theme: {
|
|
127
|
+
// accent: "#2f5fe0", // hex only
|
|
128
|
+
// radius: 10, // pixels, or a CSS length such as "0.5rem"
|
|
129
|
+
// fontFamily: "Inter, system-ui, sans-serif",
|
|
130
|
+
// monoFamily: "ui-monospace, monospace",
|
|
131
|
+
// colorScheme: "system", // or "light" | "dark"
|
|
132
|
+
// },
|
|
133
|
+
// },
|
|
134
|
+
// }),
|
|
106
135
|
ui: operatorUi(),
|
|
136
|
+
// artifacts: artifactModule,
|
|
107
137
|
identity: { credentialAdministration: () => "all", personalConnection: () => "all" },
|
|
108
138
|
// Payload-free activity at /activity, off until a database exists to hold
|
|
109
139
|
// it. Uncomment the `d1_databases` binding in wrangler.jsonc, apply the
|
|
@@ -121,8 +151,8 @@ function build(env: Env) {
|
|
|
121
151
|
type: "headers",
|
|
122
152
|
headers: { Authorization: `Bearer ${env.DOWNSTREAM_TOKEN}` },
|
|
123
153
|
// The vault-backed alternative for a downstream that authenticates
|
|
124
|
-
// with a static key: the operator
|
|
125
|
-
//
|
|
154
|
+
// with a static key: the operator manages it inside its connection
|
|
155
|
+
// in the operator UI, so no Worker secret holds it.
|
|
126
156
|
// type: "credential",
|
|
127
157
|
// credential: { label: "Notion internal integration token" },
|
|
128
158
|
},
|
|
@@ -132,8 +162,8 @@ function build(env: Env) {
|
|
|
132
162
|
}),
|
|
133
163
|
api("echo", {
|
|
134
164
|
description: "Echo — text transforms",
|
|
135
|
-
// What a vault-backed connector adds — an operator edits this slot
|
|
136
|
-
//
|
|
165
|
+
// What a vault-backed connector adds — an operator edits this slot
|
|
166
|
+
// inside its connection in the operator UI and the handler reads it with
|
|
137
167
|
// `await ctx.credential?.get()`, so the secret never lives in source
|
|
138
168
|
// or in a Worker variable:
|
|
139
169
|
// credential: { label: "API token" },
|
|
@@ -162,6 +192,7 @@ function build(env: Env) {
|
|
|
162
192
|
// Lazy per-isolate singleton: reuses the plain-data tool cache. Downstream MCP
|
|
163
193
|
// clients are request-scoped internally so Worker I/O never crosses requests.
|
|
164
194
|
let connecta: ReturnType<typeof build> | undefined;
|
|
195
|
+
const scheduledArtifacts: { module?: { runDue(): Promise<unknown> } } = {};
|
|
165
196
|
|
|
166
197
|
export default {
|
|
167
198
|
// Pass `ctx` through: connecta hands deferred work (activity sinks) to
|
|
@@ -175,4 +206,8 @@ export default {
|
|
|
175
206
|
connecta ??= build(env);
|
|
176
207
|
return connecta.fetch(request, env, ctx);
|
|
177
208
|
},
|
|
209
|
+
async scheduled(_event: ScheduledController, env: Env): Promise<void> {
|
|
210
|
+
connecta ??= build(env);
|
|
211
|
+
await scheduledArtifacts.module?.runDue();
|
|
212
|
+
},
|
|
178
213
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ArtifactBlobStore } from "@zackbart/connecta/artifacts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Artifact bodies — page sources, data documents, refresh programs — in an R2
|
|
5
|
+
* bucket, beside a D1-backed `kvArtifactStore` that keeps the heads, versions,
|
|
6
|
+
* and runs. Optional: without it, bodies live in D1 rows, which the default
|
|
7
|
+
* limits keep well under D1's row size. Use it once pages or documents grow,
|
|
8
|
+
* or to keep large bodies out of the database a backup copies.
|
|
9
|
+
*
|
|
10
|
+
* Keys are content addresses, so writing one twice writes the same bytes, and
|
|
11
|
+
* nothing here ever deletes one: versions are immutable, and a rollback points
|
|
12
|
+
* back at an old body rather than copying it.
|
|
13
|
+
*/
|
|
14
|
+
export function r2ArtifactBlobs(
|
|
15
|
+
bucket: R2Bucket,
|
|
16
|
+
prefix = "artifacts/blobs/",
|
|
17
|
+
): ArtifactBlobStore {
|
|
18
|
+
return {
|
|
19
|
+
async put(key, body) {
|
|
20
|
+
await bucket.put(`${prefix}${key}`, body, {
|
|
21
|
+
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
async get(key) {
|
|
25
|
+
const object = await bucket.get(`${prefix}${key}`);
|
|
26
|
+
return object ? object.text() : null;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|