@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.
Files changed (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
@@ -1,180 +1,212 @@
1
+ import { Effect } from "effect";
1
2
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
2
- import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
3
- async function readCredentialInput(request, config) {
4
- if (!request.headers
5
- .get("content-type")
6
- ?.toLowerCase()
7
- .startsWith("application/json")) {
8
- return {
9
- ok: false,
10
- response: privateJson({ error: "Content-Type must be application/json" }, { status: 415 }),
11
- };
12
- }
13
- const raw = await request.text();
14
- if (raw.length > 20_000) {
15
- return {
16
- ok: false,
17
- response: privateJson({ error: "request body is too large" }, { status: 413 }),
18
- };
19
- }
20
- try {
21
- const body = JSON.parse(raw);
22
- if (!config.fields?.length) {
23
- if (typeof body.value !== "string" || !body.value.trim()) {
24
- return {
25
- ok: false,
26
- response: privateJson({ error: "value must be a non-empty string" }, { status: 400 }),
27
- };
28
- }
29
- return { ok: true, input: { kind: "single", value: body.value } };
30
- }
31
- if (!body.values ||
32
- typeof body.values !== "object" ||
33
- Array.isArray(body.values)) {
34
- return {
35
- ok: false,
36
- response: privateJson({ error: "values must be an object" }, { status: 400 }),
37
- };
38
- }
39
- const rawValues = body.values;
40
- const expected = new Set(config.fields.map((field) => field.name));
41
- const unexpected = Object.keys(rawValues).find((field) => !expected.has(field));
42
- if (unexpected) {
43
- return {
44
- ok: false,
45
- response: privateJson({ error: `unexpected credential field "${unexpected}"` }, { status: 400 }),
46
- };
47
- }
48
- const values = {};
49
- for (const field of config.fields) {
50
- const value = rawValues[field.name];
51
- if (typeof value !== "string" || !value.trim()) {
52
- return {
53
- ok: false,
54
- response: privateJson({ error: `${field.name} must be a non-empty string` }, { status: 400 }),
55
- };
56
- }
57
- values[field.name] = value;
3
+ import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
4
+ import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
5
+ /** The longest body, in characters, a credential write accepts. */
6
+ const MAX_BODY_CHARS = 20_000;
7
+ /**
8
+ * The most UTF-8 a body within MAX_BODY_CHARS can take. No UTF-16 unit costs
9
+ * more than three bytes, and a malformed sequence decodes to one U+FFFD per
10
+ * at most three bytes, so a body past this many bytes is past the character
11
+ * limit too — and reading stops there, instead of buffering whatever a client
12
+ * cares to send before measuring it.
13
+ */
14
+ const MAX_BODY_BYTES = MAX_BODY_CHARS * 3;
15
+ /** The body as text, or undefined once it is longer than MAX_BODY_CHARS. */
16
+ async function boundedText(request) {
17
+ if (!request.body)
18
+ return "";
19
+ const reader = request.body.getReader();
20
+ const chunks = [];
21
+ let size = 0;
22
+ for (;;) {
23
+ const { done, value } = await reader.read();
24
+ if (done)
25
+ break;
26
+ size += value.byteLength;
27
+ if (size > MAX_BODY_BYTES) {
28
+ reader.cancel().catch(() => { });
29
+ return undefined;
58
30
  }
59
- return { ok: true, input: { kind: "multiple", values } };
31
+ chunks.push(value);
60
32
  }
61
- catch {
62
- return {
63
- ok: false,
64
- response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
65
- };
33
+ const bytes = new Uint8Array(size);
34
+ let offset = 0;
35
+ for (const chunk of chunks) {
36
+ bytes.set(chunk, offset);
37
+ offset += chunk.byteLength;
66
38
  }
39
+ const text = new TextDecoder().decode(bytes);
40
+ return text.length > MAX_BODY_CHARS ? undefined : text;
67
41
  }
68
- async function handleCredentialRequest(context, connectorId, action) {
69
- const { request, baseUrl, opts } = context;
70
- if (!opts.credentialVault) {
71
- return privateJson({ error: "credential storage is not configured" }, { status: 503 });
72
- }
73
- if (!isSameOrigin(request, baseUrl)) {
74
- return privateJson({ error: "same-origin request required" }, { status: 403 });
75
- }
76
- const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "credential management", context.runtimeContext, opts.identity);
77
- if (!authz.ok)
78
- return authz.response;
79
- let registry;
80
- try {
81
- validateAuthPermissions(authz, opts.registry);
82
- registry = opts.registry.scoped({
83
- connectorIds: authz.connectorIds,
84
- ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
85
- ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
86
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
87
- });
88
- }
89
- catch (error) {
90
- return privateJson({ error: msg(error) }, { status: 403 });
42
+ /** Field-by-field checks, in words the Credentials page shows as written. */
43
+ function credentialInput(body, config) {
44
+ if (!config.fields?.length) {
45
+ if (typeof body.value !== "string" || !body.value.trim()) {
46
+ return refuse("value must be a non-empty string", 400);
47
+ }
48
+ return Effect.succeed({ kind: "single", value: body.value });
91
49
  }
92
- const connector = registry.getConnector(connectorId);
93
- if (!connector?.credential) {
94
- return privateJson({ error: "unknown credential slot" }, { status: 404 });
50
+ if (!body.values ||
51
+ typeof body.values !== "object" ||
52
+ Array.isArray(body.values)) {
53
+ return refuse("values must be an object", 400);
95
54
  }
96
- if (!mayManageConnector(authz, connector))
97
- return privateJson({ error: "credential management is not permitted" }, { status: 403 });
98
- const personal = connector.authScope === "personal";
99
- if (personal && !authz.principalKey) {
100
- return privateJson({ error: "forbidden" }, { status: 403 });
55
+ const rawValues = body.values;
56
+ const expected = new Set(config.fields.map((field) => field.name));
57
+ const unexpected = Object.keys(rawValues).find((field) => !expected.has(field));
58
+ if (unexpected) {
59
+ return refuse(`unexpected credential field "${unexpected}"`, 400);
101
60
  }
102
- const owner = personal ? authz.principalKey : undefined;
103
- const updatedBy = authz.identity.principal
104
- ? `${authz.identity.principal.namespace}:${authz.identity.principal.id}`
105
- : authz.actor.id ?? authz.actor.kind;
106
- if (action === "test") {
107
- if (request.method !== "POST") {
108
- return privateJson({ error: "method not allowed" }, { status: 405 });
61
+ const values = {};
62
+ for (const field of config.fields) {
63
+ const value = rawValues[field.name];
64
+ if (typeof value !== "string" || !value.trim()) {
65
+ return refuse(`${field.name} must be a non-empty string`, 400);
109
66
  }
110
- // The declared credential shape picks the hook — the same single rule the
111
- // Credentials page asks for its Test affordance, so a shown button reaches
112
- // that reads the shape the credential was stored in.
113
- const rule = credentialTestRule(connector);
114
- if (!rule.mode) {
115
- return privateJson({
116
- error: rule.mismatch
117
- ? "this connector cannot test its credential: " +
118
- describeCredentialTestMismatch(rule.mismatch)
119
- : "this connector does not support credential testing",
120
- }, { status: 400 });
67
+ values[field.name] = value;
68
+ }
69
+ return Effect.succeed({ kind: "multiple", values });
70
+ }
71
+ function readCredentialInput(request, config) {
72
+ return Effect.gen(function* () {
73
+ if (!request.headers
74
+ .get("content-type")
75
+ ?.toLowerCase()
76
+ .startsWith("application/json")) {
77
+ return yield* refuse("Content-Type must be application/json", 415);
121
78
  }
79
+ const raw = yield* Effect.promise(() => boundedText(request));
80
+ if (raw === undefined)
81
+ return yield* refuse("request body is too large", 413);
82
+ let body;
122
83
  try {
123
- const values = await opts.credentialVault.getAll(connectorId, owner);
124
- const shape = storedCredentialShape(connector.credential, values);
125
- if (shape.state === "missing") {
126
- return privateJson({
127
- error: rule.mode === "multiple"
128
- ? "configure the credentials before testing them"
129
- : "configure the credential before testing it",
130
- }, { status: 409 });
131
- }
132
- if (shape.state === "mismatch") {
133
- return privateJson({ error: shape.message }, { status: 409 });
134
- }
135
- const storedValues = values;
136
- const ctx = registry.contextFor(connectorId, baseUrl);
137
- const result = rule.mode === "multiple"
138
- ? await connector.testCredentials(storedValues, ctx)
139
- : await connector.testCredential(
140
- // The single-value shape check above guarantees this key.
141
- storedValues.value, ctx);
142
- return privateJson(result);
84
+ body = JSON.parse(raw);
143
85
  }
144
- catch (err) {
145
- return privateJson({ ok: false, message: msg(err) });
86
+ catch {
87
+ return yield* refuse("invalid JSON body", 400);
146
88
  }
147
- }
148
- if (action) {
149
- return privateJson({ error: "not found" }, { status: 404 });
150
- }
151
- if (request.method === "PUT") {
152
- const input = await readCredentialInput(request, connector.credential);
153
- if (!input.ok)
154
- return input.response;
155
- try {
156
- const metadata = input.input.kind === "single"
157
- ? await opts.credentialVault.set(connectorId, input.input.value, updatedBy, owner)
158
- : await opts.credentialVault.setAll(connectorId, input.input.values, updatedBy, owner);
159
- await registry.invalidateStored(connectorId);
160
- return privateJson({ credential: metadata });
89
+ // `null` parses, but a credential cannot be read from it.
90
+ if (body === null)
91
+ return yield* refuse("invalid JSON body", 400);
92
+ return yield* credentialInput(body, config);
93
+ });
94
+ }
95
+ function credentialRequest(context, connectorId, action) {
96
+ const { request, baseUrl, opts } = context;
97
+ return Effect.gen(function* () {
98
+ const vault = opts.credentialVault;
99
+ if (!vault)
100
+ return yield* refuse("credential storage is not configured", 503);
101
+ if (!isSameOrigin(request, baseUrl)) {
102
+ return yield* refuse("same-origin request required", 403);
161
103
  }
162
- catch (err) {
163
- return privateJson({ error: msg(err) }, { status: 400 });
104
+ const authz = yield* authorizedPerson(context, "credential management");
105
+ const registry = yield* visibleRegistry(context, authz);
106
+ const connector = registry.getConnector(connectorId);
107
+ const credential = connector?.credential;
108
+ if (!connector || !credential)
109
+ return yield* refuse("unknown credential slot", 404);
110
+ if (!mayManageConnector(authz, connector)) {
111
+ return yield* refuse("credential management is not permitted", 403);
164
112
  }
165
- }
166
- if (request.method === "DELETE") {
167
- await opts.credentialVault.delete(connectorId, owner);
168
- await registry.invalidateStored(connectorId);
169
- return new Response(null, {
170
- status: 204,
171
- headers: {
172
- "Cache-Control": "no-store",
173
- "Referrer-Policy": "no-referrer",
174
- },
175
- });
176
- }
177
- return privateJson({ error: "method not allowed" }, { status: 405 });
113
+ const personal = connector.authScope === "personal";
114
+ if (personal && !authz.principalKey)
115
+ return yield* refuse("forbidden", 403);
116
+ const owner = personal ? authz.principalKey : undefined;
117
+ const updatedBy = authz.identity.principal
118
+ ? `${authz.identity.principal.namespace}:${authz.identity.principal.id}`
119
+ : authz.actor.id ?? authz.actor.kind;
120
+ if (action === "test") {
121
+ if (request.method !== "POST")
122
+ return yield* refuse("method not allowed", 405);
123
+ // The declared credential shape picks the hook — the same single rule the
124
+ // Credentials page asks for its Test affordance, so a shown button reaches
125
+ // that reads the shape the credential was stored in.
126
+ const { mode, mismatch } = credentialTestRule(connector);
127
+ if (!mode) {
128
+ return yield* refuse(mismatch
129
+ ? "this connector cannot test its credential: " +
130
+ describeCredentialTestMismatch(mismatch)
131
+ : "this connector does not support credential testing", 400);
132
+ }
133
+ // Whatever goes wrong past this point is the test's result, not the
134
+ // route's: the page shows it beside the Test button. Only `ok` leaves
135
+ // the host. A hook's message, and anything it throws, can quote the
136
+ // downstream's reply, and that reply can quote the credential it just
137
+ // rejected — so the text goes to the deployment's log and the page
138
+ // says a fixed sentence for the outcome instead.
139
+ const logged = (ok, detail) => {
140
+ if (!detail)
141
+ return;
142
+ const line = `[connecta] connector "${connectorId}" credential test ${ok ? "passed" : "failed"}: ${detail}`;
143
+ if (ok)
144
+ opts.logger.info(line);
145
+ else
146
+ opts.logger.warn(line);
147
+ };
148
+ return yield* Effect.tryPromise({
149
+ try: async () => {
150
+ const values = await vault.getAll(connectorId, owner);
151
+ const shape = storedCredentialShape(credential, values);
152
+ // Both refusals are the route's own words, and each carries the
153
+ // problem kind the page keys its copy and fix prompt off.
154
+ if (shape.state === "missing") {
155
+ return privateJson({
156
+ error: mode === "multiple"
157
+ ? "configure the credentials before testing them"
158
+ : "configure the credential before testing it",
159
+ problem: "credential_required",
160
+ }, { status: 409 });
161
+ }
162
+ if (shape.state === "mismatch") {
163
+ return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
164
+ }
165
+ const storedValues = values;
166
+ const ctx = registry.contextFor(connectorId, baseUrl);
167
+ const result = mode === "multiple"
168
+ ? await connector.testCredentials(storedValues, ctx)
169
+ : await connector.testCredential(
170
+ // The single-value shape check above guarantees this key.
171
+ storedValues.value, ctx);
172
+ const ok = result?.ok === true;
173
+ logged(ok, result?.message);
174
+ return privateJson({ ok });
175
+ },
176
+ catch: (error) => error,
177
+ }).pipe(Effect.catch((error) => {
178
+ logged(false, msg(error));
179
+ return Effect.succeed(privateJson({ ok: false }));
180
+ }));
181
+ }
182
+ if (action)
183
+ return yield* refuse("not found", 404);
184
+ if (request.method === "PUT") {
185
+ const input = yield* readCredentialInput(request, credential);
186
+ return yield* Effect.tryPromise({
187
+ try: async () => {
188
+ const metadata = input.kind === "single"
189
+ ? await vault.set(connectorId, input.value, updatedBy, owner)
190
+ : await vault.setAll(connectorId, input.values, updatedBy, owner);
191
+ await registry.invalidateStored(connectorId);
192
+ return privateJson({ credential: metadata });
193
+ },
194
+ catch: (error) => error,
195
+ }).pipe(Effect.catch((error) => refuse(msg(error), 400)));
196
+ }
197
+ if (request.method === "DELETE") {
198
+ yield* Effect.promise(() => vault.delete(connectorId, owner));
199
+ yield* Effect.promise(() => registry.invalidateStored(connectorId));
200
+ return new Response(null, {
201
+ status: 204,
202
+ headers: {
203
+ "Cache-Control": "no-store",
204
+ "Referrer-Policy": "no-referrer",
205
+ },
206
+ });
207
+ }
208
+ return yield* refuse("method not allowed", 405);
209
+ });
178
210
  }
179
211
  export async function routeCredentials(context) {
180
212
  const match = /^\/ui\/credentials\/([a-z0-9_-]+)(?:\/([a-z]+))?$/.exec(context.path);
@@ -188,5 +220,6 @@ export async function routeCredentials(context) {
188
220
  if (context.request.method === "OPTIONS") {
189
221
  return privateJson({ error: "method not allowed" }, { status: 405 });
190
222
  }
191
- return handleCredentialRequest(context, connectorId, match[2]);
223
+ // No signal: a write that has started runs through to its invalidation.
224
+ return serveOperator(credentialRequest(context, connectorId, match[2]));
192
225
  }