capability-worker 0.1.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/README.md ADDED
@@ -0,0 +1,1012 @@
1
+ # capability-worker
2
+
3
+ `capability-worker` is the shared toolkit for building Cloudflare Workers that expose a CLI-like capability over HTTP.
4
+
5
+ Use this package when you are building a capability worker and want the common platform mechanics handled in one place:
6
+
7
+ - public capability contract assets
8
+ - `capability-signature-v1` verification
9
+ - public key lookup and caching
10
+ - first-party browser auth-cookie verification
11
+ - JSON, text, redirect, and error responses
12
+ - crypto helpers for Workers
13
+ - encrypted credential fields
14
+ - multipart file parsing and file validation
15
+ - R2/S3-compatible presigned URLs
16
+ - Worker-proxied signed download URLs
17
+ - contract validation
18
+ - test helpers for signed requests
19
+
20
+ The package lets each worker focus on provider or product behavior: mail, calendar, meetings, invoices, files, or another domain.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pnpm add capability-worker
26
+ ```
27
+
28
+ The package is ESM-only and usable from Cloudflare Workers.
29
+
30
+ Runtime imports use subpaths so the worker only imports the modules it needs:
31
+
32
+ ```ts
33
+ import { requireCapabilitySignature } from "capability-worker/auth";
34
+ import { jsonResponse, errorResponse } from "capability-worker/http";
35
+ ```
36
+
37
+ Node-only tools, such as the asset builder CLI, are exposed through package binaries:
38
+
39
+ ```sh
40
+ pnpm exec capability-build-assets --validate
41
+ pnpm exec capability-validate
42
+ ```
43
+
44
+ ## Mental Model
45
+
46
+ A capability worker has two parts:
47
+
48
+ 1. Static public contract assets that describe the CLI and HTTP mapping.
49
+ 2. Dynamic API routes that execute account-scoped operations.
50
+
51
+ The static assets live at the installed capability URL:
52
+
53
+ ```text
54
+ <capability-url>/info
55
+ <capability-url>/definition
56
+ <capability-url>/mapping
57
+ <capability-url>/skill/SKILL.md
58
+ ```
59
+
60
+ For a capability installed at `https://meeting.example.com/api`, the files are:
61
+
62
+ ```text
63
+ https://meeting.example.com/api/info
64
+ https://meeting.example.com/api/definition
65
+ https://meeting.example.com/api/mapping
66
+ https://meeting.example.com/api/skill/SKILL.md
67
+ ```
68
+
69
+ The dynamic API also lives under `apiBaseUrl`. Account-scoped routes must verify the capability signature before reading or writing account data.
70
+
71
+ ## Recommended Worker Layout
72
+
73
+ ```text
74
+ worker/
75
+ capability/
76
+ info.template.json
77
+ public/
78
+ api/
79
+ info
80
+ definition
81
+ mapping
82
+ skill/
83
+ SKILL.md
84
+ _headers
85
+ scripts/
86
+ src/
87
+ worker.ts
88
+ cli/
89
+ definition.json
90
+ mapping.json
91
+ skills/
92
+ meeting/
93
+ SKILL.md
94
+ ```
95
+
96
+ The package helps generate `worker/public/...` from the source files in `capability/`, `cli/`, and `skills/`.
97
+
98
+ ## Exports
99
+
100
+ Available exports:
101
+
102
+ ```ts
103
+ import {} from "capability-worker";
104
+ import {} from "capability-worker/auth";
105
+ import {} from "capability-worker/crypto";
106
+ import {} from "capability-worker/credentials";
107
+ import {} from "capability-worker/files";
108
+ import {} from "capability-worker/http";
109
+ import {} from "capability-worker/storage";
110
+ import {} from "capability-worker/testing";
111
+ import {} from "capability-worker/validation";
112
+ import {} from "capability-worker/node";
113
+ ```
114
+
115
+ Use runtime exports from Worker code. Use `testing` from tests. Use `node` or the package CLIs from Node scripts only.
116
+
117
+ ## 1. Generate Capability Assets
118
+
119
+ Use `capability-build-assets` to create the public files that a capability client installs and reads.
120
+
121
+ ```sh
122
+ CAPABILITY_BASE_URL=https://meeting.example.com/api \
123
+ APP_ASSET_VERSION=0.1.0 \
124
+ pnpm exec capability-build-assets --validate
125
+ ```
126
+
127
+ Common package script:
128
+
129
+ ```json
130
+ {
131
+ "scripts": {
132
+ "build:capability-assets": "capability-build-assets --validate"
133
+ }
134
+ }
135
+ ```
136
+
137
+ Worker-specific script:
138
+
139
+ ```json
140
+ {
141
+ "scripts": {
142
+ "build:capability-assets": "CAPABILITY_BASE_URL=https://meeting.example.com/api APP_ASSET_VERSION=0.1.0 capability-build-assets --validate"
143
+ }
144
+ }
145
+ ```
146
+
147
+ Useful flags:
148
+
149
+ ```sh
150
+ capability-build-assets \
151
+ --worker-root worker \
152
+ --repo-root . \
153
+ --public-root worker/public \
154
+ --validate
155
+ ```
156
+
157
+ Useful environment variables:
158
+
159
+ ```text
160
+ CAPABILITY_BASE_URL
161
+ APP_ASSET_VERSION
162
+ CAPABILITY_INFO_TEMPLATE_PATH
163
+ CAPABILITY_DEFINITION_PATH
164
+ CAPABILITY_MAPPING_PATH
165
+ CAPABILITY_SKILL_ENTRYPOINT
166
+ ```
167
+
168
+ The generated layout looks like this:
169
+
170
+ ```text
171
+ worker/public/
172
+ api/
173
+ info
174
+ definition
175
+ mapping
176
+ skill/
177
+ SKILL.md
178
+ _headers
179
+ ```
180
+
181
+ Configure Wrangler static assets:
182
+
183
+ ```jsonc
184
+ {
185
+ "assets": {
186
+ "directory": "./public",
187
+ "binding": "ASSETS",
188
+ "html_handling": "none",
189
+ "not_found_handling": "none"
190
+ }
191
+ }
192
+ ```
193
+
194
+ The builder:
195
+
196
+ - normalize `CAPABILITY_BASE_URL`
197
+ - generate files under the URL path prefix, such as `/api`
198
+ - replace template variables such as `${CAPABILITY_BASE_URL}` and `${APP_ASSET_VERSION}`
199
+ - copy the skill entrypoint and skill resources
200
+ - reject unsafe paths such as `../secret`
201
+ - write `_headers` with JSON and Markdown content types
202
+ - optionally validate generated assets
203
+
204
+ ## 2. Verify Signed Capability Requests
205
+
206
+ Use `capability-signature-v1` for agent or server-to-server account-scoped routes.
207
+
208
+ Required headers:
209
+
210
+ ```text
211
+ x-capability-account-id: <account-id>
212
+ x-capability-timestamp: <unix-seconds>
213
+ x-capability-key-id: <key-id>
214
+ x-capability-signature: v1=<base64url-signature>
215
+ ```
216
+
217
+ Canonical string:
218
+
219
+ ```text
220
+ CAPABILITY-SIGNATURE-V1
221
+ <METHOD>
222
+ <PATH_WITH_QUERY>
223
+ <TIMESTAMP>
224
+ <ACCOUNT_ID>
225
+ <SHA256_HEX_BODY>
226
+ ```
227
+
228
+ Use `requireCapabilitySignature` when route handlers prefer exception flow:
229
+
230
+ ```ts
231
+ import { requireCapabilitySignature } from "capability-worker/auth";
232
+ import { errorResponse, jsonResponse } from "capability-worker/http";
233
+
234
+ export default {
235
+ async fetch(request: Request, env: Env): Promise<Response> {
236
+ try {
237
+ const { accountId } = await requireCapabilitySignature(request, env);
238
+
239
+ const rows = await env.DB.prepare(
240
+ "SELECT * FROM meetings WHERE account_id = ? ORDER BY starts_at DESC"
241
+ )
242
+ .bind(accountId)
243
+ .all();
244
+
245
+ return jsonResponse({ ok: true, data: rows.results });
246
+ } catch (error) {
247
+ return errorResponse(error);
248
+ }
249
+ }
250
+ };
251
+ ```
252
+
253
+ Use `verifyCapabilitySignature` when route handlers prefer explicit result handling:
254
+
255
+ ```ts
256
+ import { verifyCapabilitySignature } from "capability-worker/auth";
257
+ import { errorJson, jsonResponse } from "capability-worker/http";
258
+
259
+ const auth = await verifyCapabilitySignature(request, env);
260
+
261
+ if (!auth.ok) {
262
+ return errorJson(auth.code, auth.message, auth.status);
263
+ }
264
+
265
+ return jsonResponse({
266
+ ok: true,
267
+ accountId: auth.accountId
268
+ });
269
+ ```
270
+
271
+ Rules for signed routes:
272
+
273
+ - verify before account-scoped reads or writes
274
+ - use the verified account id as the only tenant scope
275
+ - include query strings in the canonical path
276
+ - hash the exact raw request body bytes
277
+ - never accept account id from the body or query string
278
+ - return `404` when a resource does not exist inside the verified account scope
279
+
280
+ ## 3. Fetch And Cache Public Keys
281
+
282
+ Use the public key store behind signature and auth-cookie verification.
283
+
284
+ ```ts
285
+ import { PublicKeyStore } from "capability-worker/auth";
286
+
287
+ const keyStore = new PublicKeyStore(env, {
288
+ cacheTtlMs: 300_000
289
+ });
290
+
291
+ const key = await keyStore.getKey("key_123");
292
+ ```
293
+
294
+ Supported key sources, in priority order:
295
+
296
+ 1. `CAPABILITY_PUBLIC_KEYS_JSON`
297
+ 2. `CAPABILITY_PUBLIC_KEYS`
298
+ 3. `CAPABILITY_PUBLIC_KEYS_SERVICE.fetch(CAPABILITY_PUBLIC_KEYS_URL)`
299
+ 4. `fetch(CAPABILITY_PUBLIC_KEYS_URL)`
300
+
301
+ Recommended Wrangler variables:
302
+
303
+ ```toml
304
+ [vars]
305
+ CAPABILITY_PUBLIC_KEYS_URL = "https://host.example.com/capability/public-keys"
306
+ ```
307
+
308
+ When the public-key endpoint is another Worker on the same Cloudflare zone, also use a service binding in production:
309
+
310
+ ```toml
311
+ [[services]]
312
+ binding = "CAPABILITY_PUBLIC_KEYS_SERVICE"
313
+ service = "public-keys-worker"
314
+ ```
315
+
316
+ Public key rules:
317
+
318
+ - accept `{ "keys": [...] }` and raw `[...]` payloads
319
+ - reject private key material such as `d`
320
+ - refetch keys once when a key id is unknown
321
+ - clear caches in tests with `clearGlobalPublicKeyCache`
322
+
323
+ ## 4. Verify First-Party Browser Auth Cookies
324
+
325
+ Use browser auth cookie helpers when the capability also has a first-party site.
326
+
327
+ Example:
328
+
329
+ ```ts
330
+ import { verifyAuthCookie } from "capability-worker/auth";
331
+ import { errorJson, jsonResponse } from "capability-worker/http";
332
+
333
+ const auth = await verifyAuthCookie(request, env, {
334
+ cookieName: "auth"
335
+ });
336
+
337
+ if (!auth.ok) {
338
+ return errorJson(auth.code, auth.message, auth.status);
339
+ }
340
+
341
+ const meeting = await env.DB.prepare(
342
+ "SELECT * FROM meetings WHERE id = ? AND account_id = ?"
343
+ )
344
+ .bind(meetingId, auth.accountId)
345
+ .first();
346
+
347
+ if (!meeting) {
348
+ return errorJson("meeting_not_found", "Meeting not found.", 404);
349
+ }
350
+
351
+ return jsonResponse({ ok: true, data: meeting });
352
+ ```
353
+
354
+ Use browser auth for site-origin requests such as creator/admin views. Use capability signatures for agent/server-to-server management calls.
355
+
356
+ Browser auth rules:
357
+
358
+ - verify the JWT with keys from `CAPABILITY_PUBLIC_KEYS_URL`
359
+ - accept only configured asymmetric algorithms, normally `ES256`
360
+ - use `sub` as the verified account id
361
+ - treat `email` as trusted only when `email_verified === true`
362
+ - protect unsafe methods with an `Origin` allowlist
363
+ - never accept admin status, creator id, or account id from the browser body, query string, local storage, or unsigned headers
364
+
365
+ For routes that can accept either a capability signature or browser cookie, use `accountAuthFromRequest`:
366
+
367
+ ```ts
368
+ import { accountAuthFromRequest } from "capability-worker/auth";
369
+
370
+ const auth = await accountAuthFromRequest(request, env, {
371
+ cookieName: "auth"
372
+ });
373
+ ```
374
+
375
+ ## 5. Return Consistent HTTP Responses
376
+
377
+ Use the HTTP helpers to keep response shape consistent without adopting a router.
378
+
379
+ ```ts
380
+ import {
381
+ HttpError,
382
+ errorJson,
383
+ errorResponse,
384
+ jsonResponse,
385
+ textResponse
386
+ } from "capability-worker/http";
387
+
388
+ export async function readMeeting(request: Request, env: Env) {
389
+ const meeting = await loadMeeting(request, env);
390
+
391
+ if (!meeting) {
392
+ throw new HttpError(404, "meeting_not_found", "Meeting not found.");
393
+ }
394
+
395
+ return jsonResponse({ ok: true, data: meeting });
396
+ }
397
+ ```
398
+
399
+ Error shape:
400
+
401
+ ```json
402
+ {
403
+ "ok": false,
404
+ "error": {
405
+ "code": "invalid_request",
406
+ "message": "Invalid request."
407
+ }
408
+ }
409
+ ```
410
+
411
+ Useful helpers:
412
+
413
+ ```ts
414
+ jsonResponse(value, init);
415
+ textResponse(value, init);
416
+ htmlResponse(value, init);
417
+ redirectResponse(location, status);
418
+ errorJson(code, message, status, extra);
419
+ errorResponse(error);
420
+ parseJsonBody(request);
421
+ readJsonObject(request);
422
+ isJsonRequested(url, body);
423
+ stringParam(value);
424
+ stringArrayParam(value);
425
+ booleanParam(value);
426
+ limitParam(value, defaultValue, max);
427
+ getRepeated(url, key);
428
+ ```
429
+
430
+ Return text command output exactly as the agent or user sees it:
431
+
432
+ ```ts
433
+ return textResponse(
434
+ [
435
+ "ID Starts Title",
436
+ "mtg_1 2026-07-06 10:00 Intro call"
437
+ ].join("\n")
438
+ );
439
+ ```
440
+
441
+ If a command supports JSON output, expose a normal `--json` option in the definition asset and return `application/json` only when requested.
442
+
443
+ ## 6. Use Worker-Safe Crypto Helpers
444
+
445
+ Use `capability-worker/crypto` for common Web Crypto and encoding work.
446
+
447
+ ```ts
448
+ import {
449
+ base64UrlDecode,
450
+ base64UrlEncode,
451
+ randomBase64Url,
452
+ sha256Hex,
453
+ timingSafeEqual
454
+ } from "capability-worker/crypto";
455
+
456
+ const id = `file_${randomBase64Url(12)}`;
457
+ const bodyHash = await sha256Hex(await request.arrayBuffer());
458
+ const token = base64UrlEncode(new TextEncoder().encode("hello"));
459
+ const bytes = base64UrlDecode(token);
460
+ const matches = timingSafeEqual("a", "a");
461
+ ```
462
+
463
+ Available helpers:
464
+
465
+ ```ts
466
+ utf8(value);
467
+ decodeUtf8(value);
468
+ toArrayBuffer(bytes);
469
+ base64UrlEncode(value);
470
+ base64UrlDecode(value);
471
+ base64Encode(value);
472
+ base64Decode(value);
473
+ sha256Hex(value);
474
+ hmacSha256Base64Url(secret, payload);
475
+ randomBytes(byteLength);
476
+ randomBase64Url(byteLength);
477
+ timingSafeEqual(a, b);
478
+ ```
479
+
480
+ Use these instead of copying base64url, hashing, HMAC, random token, and timing-safe comparison code across workers.
481
+
482
+ ## 7. Encrypt Credential Fields
483
+
484
+ Use credential helpers for account-owned secrets such as refresh tokens, app passwords, SMTP credentials, API keys, or provider tokens.
485
+
486
+ ```ts
487
+ import {
488
+ decryptCredentialField,
489
+ encryptCredentialField
490
+ } from "capability-worker/credentials";
491
+
492
+ const encrypted = await encryptCredentialField({
493
+ env,
494
+ accountId,
495
+ rowId: connectionId,
496
+ fieldName: "refresh_token",
497
+ plaintext: refreshToken
498
+ });
499
+
500
+ await env.DB.prepare(
501
+ "UPDATE google_connections SET refresh_token_encrypted = ? WHERE id = ? AND account_id = ?"
502
+ )
503
+ .bind(JSON.stringify(encrypted), connectionId, accountId)
504
+ .run();
505
+ ```
506
+
507
+ Decrypt later using the same account id, row id, and field name:
508
+
509
+ ```ts
510
+ const refreshToken = await decryptCredentialField({
511
+ env,
512
+ accountId,
513
+ rowId: connectionId,
514
+ fieldName: "refresh_token",
515
+ encrypted: JSON.parse(row.refresh_token_encrypted)
516
+ });
517
+ ```
518
+
519
+ Environment convention:
520
+
521
+ ```text
522
+ CREDENTIAL_ENCRYPTION_KEYS={"v1":"<base64url-32-byte-key>"}
523
+ CREDENTIAL_ACTIVE_KEY_ID=v1
524
+ ```
525
+
526
+ Credential rules:
527
+
528
+ - store one encrypted field as `{ keyId, nonce, ciphertext }`
529
+ - use AES-GCM with a 12-byte random nonce
530
+ - use AAD that includes account id, row id, field name, and key id
531
+ - do not log plaintext, ciphertext, nonces, or full secret values
532
+ - fail in production when no keyring is configured
533
+ - rotate keys by changing `CREDENTIAL_ACTIVE_KEY_ID`; old fields continue to decrypt while their key remains in the keyring
534
+
535
+ The package does not own your database schema. It only encrypts and decrypts values.
536
+
537
+ ## 8. Parse Multipart Requests And Validate Files
538
+
539
+ Use file helpers when commands accept local files from the host.
540
+
541
+ Expected multipart shape:
542
+
543
+ ```text
544
+ payload=<JSON object string>
545
+ attachments=<raw bytes>
546
+ attachments=<raw bytes>
547
+ ```
548
+
549
+ Route example:
550
+
551
+ ```ts
552
+ import {
553
+ parseMultipartRequest,
554
+ validateCapabilityFiles
555
+ } from "capability-worker/files";
556
+ import { requireCapabilitySignature } from "capability-worker/auth";
557
+ import { errorJson, jsonResponse } from "capability-worker/http";
558
+
559
+ const rawBody = await request.arrayBuffer();
560
+
561
+ const { accountId } = await requireCapabilitySignature(request, env, {
562
+ rawBody
563
+ });
564
+
565
+ const parsed = await parseMultipartRequest(
566
+ new Request(request, { body: rawBody }),
567
+ {
568
+ payloadFieldName: "payload",
569
+ fileFieldNames: ["attachments"],
570
+ maxTotalBytes: 20 * 1024 * 1024
571
+ }
572
+ );
573
+
574
+ const fileValidation = validateCapabilityFiles(parsed.files, {
575
+ allowedExtensions: [".pdf", ".png", ".jpg"],
576
+ allowedMimeTypes: ["application/pdf", "image/png", "image/jpeg"],
577
+ maxBytes: 10 * 1024 * 1024,
578
+ maxCount: 5,
579
+ maxTotalBytes: 20 * 1024 * 1024
580
+ });
581
+
582
+ if (!fileValidation.ok) {
583
+ return errorJson(
584
+ fileValidation.code,
585
+ fileValidation.message,
586
+ fileValidation.status
587
+ );
588
+ }
589
+
590
+ return jsonResponse({
591
+ ok: true,
592
+ accountId,
593
+ payload: parsed.payload,
594
+ fileCount: parsed.files.length
595
+ });
596
+ ```
597
+
598
+ Important: verify the signature before parsing multipart, but use the same raw bytes for both verification and parsing.
599
+
600
+ File rules:
601
+
602
+ - normalize and sanitize filenames
603
+ - reject slashes, nulls, carriage returns, and line feeds in filenames
604
+ - validate extension and MIME type server-side
605
+ - enforce per-file byte limit, file count, and total byte limit
606
+ - make file fields match the capability mapping
607
+
608
+ ## 9. Create R2 Or S3-Compatible Presigned URLs
609
+
610
+ Use storage helpers when a worker writes generated files to a private bucket and returns a temporary download URL.
611
+
612
+ For Cloudflare R2:
613
+
614
+ ```ts
615
+ import { presignR2ObjectUrl } from "capability-worker/storage";
616
+
617
+ const downloadUrl = await presignR2ObjectUrl(env, {
618
+ method: "GET",
619
+ key: `accounts/${accountId}/exports/${exportId}.pdf`,
620
+ expiresInSeconds: 3600,
621
+ query: {
622
+ "response-content-disposition": `attachment; filename="${filename}"`,
623
+ "response-content-type": "application/pdf"
624
+ }
625
+ });
626
+ ```
627
+
628
+ R2 environment:
629
+
630
+ ```text
631
+ R2_PUBLIC_BASE_URL
632
+ R2_ACCOUNT_ID
633
+ R2_BUCKET_NAME
634
+ R2_ACCESS_KEY_ID
635
+ R2_SECRET_ACCESS_KEY
636
+ ```
637
+
638
+ For generic S3-compatible storage:
639
+
640
+ ```ts
641
+ import { presignS3ObjectUrl } from "capability-worker/storage";
642
+
643
+ const url = await presignS3ObjectUrl({
644
+ method: "GET",
645
+ key: `accounts/${accountId}/exports/${exportId}.zip`,
646
+ expiresInSeconds: 3600,
647
+ config: {
648
+ baseUrl: "https://storage.example.com/bucket",
649
+ region: "auto",
650
+ service: "s3",
651
+ credentials: {
652
+ accessKeyId: env.S3_ACCESS_KEY_ID,
653
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY
654
+ }
655
+ }
656
+ });
657
+ ```
658
+
659
+ Presigned URL rules:
660
+
661
+ - default to read-only `GET` URLs
662
+ - cap expiry in worker code, usually at 24 hours or less
663
+ - keep buckets private
664
+ - include the verified account id in object keys
665
+ - never return access keys, secret keys, or signing keys
666
+ - only use response override query params for presentation metadata
667
+ - do not accept arbitrary object keys from request bodies without account-scope checks
668
+
669
+ ## 10. Create Worker-Proxied Signed Download URLs
670
+
671
+ Use Worker-proxied signed download URLs for local development or when the Worker streams private R2 objects itself.
672
+
673
+ ```ts
674
+ import {
675
+ createSignedDownloadUrl,
676
+ r2DownloadResponse,
677
+ verifySignedDownloadToken
678
+ } from "capability-worker/storage";
679
+
680
+ const url = await createSignedDownloadUrl({
681
+ env,
682
+ payload: {
683
+ accountId,
684
+ storageKey: `accounts/${accountId}/exports/${exportId}.pdf`,
685
+ filename: "invoice.pdf",
686
+ contentType: "application/pdf",
687
+ expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
688
+ disposition: "attachment"
689
+ },
690
+ routePrefix: "/__dev/r2-download"
691
+ });
692
+ ```
693
+
694
+ Handle the download route:
695
+
696
+ ```ts
697
+ const url = new URL(request.url);
698
+
699
+ if (url.pathname === "/__dev/r2-download") {
700
+ const token = url.searchParams.get("token");
701
+ if (!token) {
702
+ return new Response("Missing token.", { status: 400 });
703
+ }
704
+
705
+ const payload = await verifySignedDownloadToken(env, token);
706
+ return r2DownloadResponse({
707
+ bucket: env.PDFS,
708
+ payload
709
+ });
710
+ }
711
+ ```
712
+
713
+ Signed download rules:
714
+
715
+ - sign payloads with HMAC-SHA256
716
+ - use timing-safe comparison
717
+ - return `403` for expired tokens
718
+ - return `404` for missing R2 objects
719
+ - include account id in the signed payload
720
+ - use `cache-control: no-store` for private proxy downloads
721
+
722
+ ## 11. Validate Capability Contracts
723
+
724
+ Use validation before deploy and in tests.
725
+
726
+ ```ts
727
+ import { validateCapabilityAssets } from "capability-worker/validation";
728
+
729
+ const issues = validateCapabilityAssets({
730
+ info,
731
+ definition,
732
+ mapping,
733
+ skillFiles: [
734
+ {
735
+ path: "SKILL.md",
736
+ content: skillMarkdown
737
+ }
738
+ ]
739
+ });
740
+
741
+ const errors = issues.filter((issue) => issue.severity === "error");
742
+
743
+ if (errors.length > 0) {
744
+ throw new Error(errors.map((issue) => issue.message).join("\n"));
745
+ }
746
+ ```
747
+
748
+ The validator checks:
749
+
750
+ - `info.schemaVersion`
751
+ - required `info` fields
752
+ - safe command names
753
+ - `definition.schemaVersion`
754
+ - `definition.cli.name === info.name`
755
+ - stable and unique command ids
756
+ - mapping keys exist in the definition
757
+ - mapping paths are relative
758
+ - path params exist in route paths
759
+ - mapped fields exist in command arguments or options
760
+ - file fields have constraints
761
+ - skill paths are safe relative paths
762
+ - public assets do not contain obvious secrets
763
+ - production `apiBaseUrl` uses HTTPS
764
+
765
+ CLI use:
766
+
767
+ ```sh
768
+ pnpm exec capability-validate
769
+ ```
770
+
771
+ Or validate during asset generation:
772
+
773
+ ```sh
774
+ pnpm exec capability-build-assets --validate
775
+ ```
776
+
777
+ ## 12. Route Static Capability Assets
778
+
779
+ Most workers use Cloudflare Workers Static Assets to serve contract files directly.
780
+
781
+ If the worker must route assets itself, use the routing helper to detect capability asset paths and delegate to `env.ASSETS`.
782
+
783
+ ```ts
784
+ import { isCapabilityAssetPath } from "capability-worker";
785
+
786
+ export default {
787
+ async fetch(request: Request, env: Env): Promise<Response> {
788
+ const url = new URL(request.url);
789
+
790
+ if (isCapabilityAssetPath(url.pathname, { basePath: "/api" })) {
791
+ return env.ASSETS.fetch(request);
792
+ }
793
+
794
+ return handleApi(request, env);
795
+ }
796
+ };
797
+ ```
798
+
799
+ The helper recognizes paths like:
800
+
801
+ ```text
802
+ /info
803
+ /definition
804
+ /mapping
805
+ /skill/SKILL.md
806
+ /api/info
807
+ /api/definition
808
+ /api/mapping
809
+ /api/skill/SKILL.md
810
+ ```
811
+
812
+ This helper is only for identifying contract assets. It is not a full router.
813
+
814
+ ## 13. Test Signed Routes
815
+
816
+ Use `capability-worker/testing` to remove repeated signing boilerplate while still testing the real Worker entrypoint.
817
+
818
+ ```ts
819
+ import {
820
+ createCapabilityTestKeyPair,
821
+ publicKeysResponse,
822
+ withCapabilitySignature
823
+ } from "capability-worker/testing";
824
+ import worker from "../src/worker";
825
+
826
+ test("lists meetings for the signed account", async () => {
827
+ const key = await createCapabilityTestKeyPair({ alg: "ES256" });
828
+
829
+ const env = {
830
+ ...testEnv,
831
+ CAPABILITY_PUBLIC_KEYS_JSON: JSON.stringify({
832
+ keys: [key.publicJwk]
833
+ })
834
+ };
835
+
836
+ const request = await withCapabilitySignature({
837
+ request: new Request("https://meeting.example.com/api/meetings"),
838
+ accountId: "acct_123",
839
+ keyId: key.keyId,
840
+ privateJwk: key.privateJwk
841
+ });
842
+
843
+ const response = await worker.fetch(request, env);
844
+
845
+ expect(response.status).toBe(200);
846
+ });
847
+ ```
848
+
849
+ Available helpers:
850
+
851
+ ```ts
852
+ createCapabilityTestKeyPair(options);
853
+ signCapabilityRequest(input);
854
+ withCapabilitySignature(input);
855
+ publicKeysResponse(keys);
856
+ ```
857
+
858
+ Test cases every worker covers:
859
+
860
+ - valid signatures
861
+ - missing signatures
862
+ - stale timestamps
863
+ - wrong body hashes
864
+ - unknown key ids and public-key refresh
865
+ - account isolation
866
+ - multipart body signatures
867
+ - generated contract assets
868
+ - text output mode
869
+ - JSON output mode when a command supports `--json`
870
+
871
+ Use Cloudflare's Workers Vitest integration for Worker tests. Do not replace D1, R2, KV, Durable Objects, assets, or bindings with hand-written mocks as proof that the Worker works.
872
+
873
+ ## Environment Variables
874
+
875
+ Common worker variables:
876
+
877
+ ```text
878
+ CAPABILITY_BASE_URL
879
+ APP_ASSET_VERSION
880
+ CAPABILITY_PUBLIC_KEYS_URL
881
+ CAPABILITY_PUBLIC_KEYS_JSON
882
+ CAPABILITY_PUBLIC_KEYS
883
+ CREDENTIAL_ENCRYPTION_KEYS
884
+ CREDENTIAL_ACTIVE_KEY_ID
885
+ R2_PUBLIC_BASE_URL
886
+ R2_ACCOUNT_ID
887
+ R2_BUCKET_NAME
888
+ R2_ACCESS_KEY_ID
889
+ R2_SECRET_ACCESS_KEY
890
+ S3_PUBLIC_BASE_URL
891
+ S3_ENDPOINT
892
+ S3_REGION
893
+ S3_BUCKET_NAME
894
+ S3_ACCESS_KEY_ID
895
+ S3_SECRET_ACCESS_KEY
896
+ DEV_DOWNLOAD_SECRET
897
+ ```
898
+
899
+ Use Wrangler secrets for secret values:
900
+
901
+ ```sh
902
+ wrangler secret put CREDENTIAL_ENCRYPTION_KEYS
903
+ wrangler secret put R2_ACCESS_KEY_ID
904
+ wrangler secret put R2_SECRET_ACCESS_KEY
905
+ ```
906
+
907
+ Use Wrangler vars for public or non-secret values:
908
+
909
+ ```toml
910
+ [vars]
911
+ CAPABILITY_BASE_URL = "https://meeting.example.com/api"
912
+ APP_ASSET_VERSION = "0.1.0"
913
+ CAPABILITY_PUBLIC_KEYS_URL = "https://host.example.com/capability/public-keys"
914
+ ```
915
+
916
+ ## New Capability Worker Recipe
917
+
918
+ 1. Create `cli/definition.json`.
919
+ 2. Create `cli/mapping.json`.
920
+ 3. Create `skills/<capability-name>/SKILL.md`.
921
+ 4. Create `worker/capability/info.template.json`.
922
+ 5. Configure Wrangler static assets.
923
+ 6. Add `build:capability-assets`.
924
+ 7. Implement dynamic API routes.
925
+ 8. Verify signed account-scoped routes with `requireCapabilitySignature` or `verifyCapabilitySignature`.
926
+ 9. Scope every account-owned query and object key by verified account id.
927
+ 10. Return final command output from the worker as text or JSON.
928
+ 11. Validate assets with `capability-build-assets --validate`.
929
+ 12. Test through Cloudflare Workers Vitest or Wrangler-backed tooling.
930
+
931
+ Minimal route skeleton:
932
+
933
+ ```ts
934
+ import { requireCapabilitySignature } from "capability-worker/auth";
935
+ import { errorResponse, jsonResponse, textResponse } from "capability-worker/http";
936
+
937
+ export default {
938
+ async fetch(request: Request, env: Env): Promise<Response> {
939
+ try {
940
+ const url = new URL(request.url);
941
+
942
+ if (url.pathname === "/api/health") {
943
+ return jsonResponse({ ok: true });
944
+ }
945
+
946
+ if (url.pathname === "/api/meetings" && request.method === "GET") {
947
+ const { accountId } = await requireCapabilitySignature(request, env);
948
+ const wantsJson = url.searchParams.get("json") === "true";
949
+
950
+ const rows = await env.DB.prepare(
951
+ "SELECT id, title, starts_at FROM meetings WHERE account_id = ?"
952
+ )
953
+ .bind(accountId)
954
+ .all();
955
+
956
+ if (wantsJson) {
957
+ return jsonResponse({ ok: true, data: rows.results });
958
+ }
959
+
960
+ return textResponse(formatMeetingsTable(rows.results));
961
+ }
962
+
963
+ return jsonResponse(
964
+ {
965
+ ok: false,
966
+ error: {
967
+ code: "not_found",
968
+ message: "Not found."
969
+ }
970
+ },
971
+ 404
972
+ );
973
+ } catch (error) {
974
+ return errorResponse(error);
975
+ }
976
+ }
977
+ };
978
+ ```
979
+
980
+ ## What Stays Local To Each Worker
981
+
982
+ Keep these in the worker, not in `capability-worker`:
983
+
984
+ - provider API clients
985
+ - OAuth flows and provider-specific token refresh
986
+ - product database schema
987
+ - product domain validation
988
+ - command-specific output formatting
989
+ - email, calendar, invoice, or meeting business logic
990
+ - public site UI
991
+ - webhooks and provider-specific verification
992
+ - deployment-specific route ownership
993
+
994
+ The shared package owns repeated mechanics. The worker owns the product.
995
+
996
+ ## Compatibility Checklist
997
+
998
+ Before shipping a worker that uses this package:
999
+
1000
+ - `/info`, `/definition`, `/mapping`, and `/skill/SKILL.md` are static assets
1001
+ - `info.name` matches `definition.cli.name`
1002
+ - command ids are stable and unique
1003
+ - every mapping key exists in the definition
1004
+ - mapping paths are relative under `apiBaseUrl`
1005
+ - public contract and skill assets contain no secrets or account data
1006
+ - production `apiBaseUrl` uses HTTPS
1007
+ - account-scoped routes verify `capability-signature-v1`
1008
+ - browser creator/admin routes verify the auth cookie and compare verified account id to stored ownership
1009
+ - every account-owned database query is scoped by verified account id
1010
+ - every account-owned object key includes verified account id
1011
+ - text and JSON output modes are documented and tested when both exist
1012
+ - tests use Wrangler-backed Cloudflare runtime tooling and configured bindings