@zackbart/connecta 0.7.9 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +47 -148
  3. package/dist/activity.d.ts +0 -8
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js +0 -1
  6. package/dist/activity.js.map +1 -1
  7. package/dist/auth/bearer.d.ts +1 -7
  8. package/dist/auth/bearer.d.ts.map +1 -1
  9. package/dist/auth/bearer.js +3 -24
  10. package/dist/auth/bearer.js.map +1 -1
  11. package/dist/auth/clerk.d.ts +2 -11
  12. package/dist/auth/clerk.d.ts.map +1 -1
  13. package/dist/auth/clerk.js +31 -46
  14. package/dist/auth/clerk.js.map +1 -1
  15. package/dist/call-admission.d.ts.map +1 -1
  16. package/dist/call-admission.js +9 -4
  17. package/dist/call-admission.js.map +1 -1
  18. package/dist/catalog-fingerprint.d.ts +20 -0
  19. package/dist/catalog-fingerprint.d.ts.map +1 -0
  20. package/dist/catalog-fingerprint.js +34 -0
  21. package/dist/catalog-fingerprint.js.map +1 -0
  22. package/dist/catalog-limits.d.ts +10 -0
  23. package/dist/catalog-limits.d.ts.map +1 -0
  24. package/dist/catalog-limits.js +10 -0
  25. package/dist/catalog-limits.js.map +1 -0
  26. package/dist/catalog-service.d.ts +137 -0
  27. package/dist/catalog-service.d.ts.map +1 -0
  28. package/dist/catalog-service.js +401 -0
  29. package/dist/catalog-service.js.map +1 -0
  30. package/dist/catalog.d.ts +6 -0
  31. package/dist/catalog.d.ts.map +1 -1
  32. package/dist/catalog.js +53 -2
  33. package/dist/catalog.js.map +1 -1
  34. package/dist/concurrency.d.ts +4 -0
  35. package/dist/concurrency.d.ts.map +1 -0
  36. package/dist/concurrency.js +31 -0
  37. package/dist/concurrency.js.map +1 -0
  38. package/dist/connectors/api.d.ts.map +1 -1
  39. package/dist/connectors/api.js +22 -12
  40. package/dist/connectors/api.js.map +1 -1
  41. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  42. package/dist/connectors/remote-mcp.js +53 -41
  43. package/dist/connectors/remote-mcp.js.map +1 -1
  44. package/dist/credentials.d.ts +3 -2
  45. package/dist/credentials.d.ts.map +1 -1
  46. package/dist/errors.d.ts +19 -1
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js.map +1 -1
  49. package/dist/execute.d.ts +10 -16
  50. package/dist/execute.d.ts.map +1 -1
  51. package/dist/execute.js +143 -330
  52. package/dist/execute.js.map +1 -1
  53. package/dist/executor-result.d.ts +0 -3
  54. package/dist/executor-result.d.ts.map +1 -1
  55. package/dist/executor-result.js +3 -3
  56. package/dist/executor-result.js.map +1 -1
  57. package/dist/executors/quickjs-child.js +3 -3
  58. package/dist/executors/quickjs-child.js.map +1 -1
  59. package/dist/executors/quickjs-protocol.d.ts +4 -1
  60. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  61. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  62. package/dist/executors/quickjs-runtime.js +7 -2
  63. package/dist/executors/quickjs-runtime.js.map +1 -1
  64. package/dist/executors/quickjs.d.ts.map +1 -1
  65. package/dist/executors/quickjs.js +4 -1
  66. package/dist/executors/quickjs.js.map +1 -1
  67. package/dist/favicon.js +2 -2
  68. package/dist/favicon.js.map +1 -1
  69. package/dist/index.d.ts +8 -82
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +58 -104
  72. package/dist/index.js.map +1 -1
  73. package/dist/invocation.d.ts +101 -0
  74. package/dist/invocation.d.ts.map +1 -0
  75. package/dist/invocation.js +405 -0
  76. package/dist/invocation.js.map +1 -0
  77. package/dist/meta-tools.d.ts +9 -95
  78. package/dist/meta-tools.d.ts.map +1 -1
  79. package/dist/meta-tools.js +235 -725
  80. package/dist/meta-tools.js.map +1 -1
  81. package/dist/operator-ui/generated.d.ts +3 -0
  82. package/dist/operator-ui/generated.d.ts.map +1 -0
  83. package/dist/operator-ui/generated.js +5 -0
  84. package/dist/operator-ui/generated.js.map +1 -0
  85. package/dist/operator-ui/model.d.ts +73 -0
  86. package/dist/operator-ui/model.d.ts.map +1 -0
  87. package/dist/operator-ui/model.js +28 -0
  88. package/dist/operator-ui/model.js.map +1 -0
  89. package/dist/registry.d.ts +33 -184
  90. package/dist/registry.d.ts.map +1 -1
  91. package/dist/registry.js +345 -330
  92. package/dist/registry.js.map +1 -1
  93. package/dist/retired-toolkits.d.ts +3 -0
  94. package/dist/retired-toolkits.d.ts.map +1 -0
  95. package/dist/retired-toolkits.js +9 -0
  96. package/dist/retired-toolkits.js.map +1 -0
  97. package/dist/routes/activity.d.ts +3 -0
  98. package/dist/routes/activity.d.ts.map +1 -0
  99. package/dist/routes/activity.js +170 -0
  100. package/dist/routes/activity.js.map +1 -0
  101. package/dist/routes/credentials.d.ts +3 -0
  102. package/dist/routes/credentials.d.ts.map +1 -0
  103. package/dist/routes/credentials.js +170 -0
  104. package/dist/routes/credentials.js.map +1 -0
  105. package/dist/routes/mcp.d.ts +8 -0
  106. package/dist/routes/mcp.d.ts.map +1 -0
  107. package/dist/routes/mcp.js +270 -0
  108. package/dist/routes/mcp.js.map +1 -0
  109. package/dist/routes/oauth.d.ts +4 -0
  110. package/dist/routes/oauth.d.ts.map +1 -0
  111. package/dist/routes/oauth.js +263 -0
  112. package/dist/routes/oauth.js.map +1 -0
  113. package/dist/routes/shared.d.ts +76 -0
  114. package/dist/routes/shared.d.ts.map +1 -0
  115. package/dist/routes/shared.js +140 -0
  116. package/dist/routes/shared.js.map +1 -0
  117. package/dist/routes/ui.d.ts +3 -0
  118. package/dist/routes/ui.d.ts.map +1 -0
  119. package/dist/routes/ui.js +116 -0
  120. package/dist/routes/ui.js.map +1 -0
  121. package/dist/server.d.ts +9 -40
  122. package/dist/server.d.ts.map +1 -1
  123. package/dist/server.js +62 -1322
  124. package/dist/server.js.map +1 -1
  125. package/dist/skills.d.ts +3 -21
  126. package/dist/skills.d.ts.map +1 -1
  127. package/dist/skills.js +15 -43
  128. package/dist/skills.js.map +1 -1
  129. package/dist/storage/file.d.ts.map +1 -1
  130. package/dist/storage/file.js +3 -1
  131. package/dist/storage/file.js.map +1 -1
  132. package/dist/storage/memory.d.ts.map +1 -1
  133. package/dist/storage/memory.js +3 -1
  134. package/dist/storage/memory.js.map +1 -1
  135. package/dist/timeout.d.ts.map +1 -1
  136. package/dist/timeout.js +2 -4
  137. package/dist/timeout.js.map +1 -1
  138. package/dist/types.d.ts +9 -59
  139. package/dist/types.d.ts.map +1 -1
  140. package/dist/ui.d.ts +3 -94
  141. package/dist/ui.d.ts.map +1 -1
  142. package/dist/ui.js +30 -1427
  143. package/dist/ui.js.map +1 -1
  144. package/dist/version.d.ts +1 -1
  145. package/dist/version.js +1 -1
  146. package/package.json +15 -8
  147. package/src/activity.ts +0 -9
  148. package/src/auth/bearer.ts +4 -33
  149. package/src/auth/clerk.ts +35 -48
  150. package/src/call-admission.ts +8 -6
  151. package/src/catalog-fingerprint.ts +52 -0
  152. package/src/catalog-limits.ts +11 -0
  153. package/src/catalog-service.ts +601 -0
  154. package/src/catalog.ts +57 -2
  155. package/src/concurrency.ts +39 -0
  156. package/src/connectors/api.ts +22 -12
  157. package/src/connectors/remote-mcp.ts +63 -45
  158. package/src/credentials.ts +2 -2
  159. package/src/errors.ts +21 -1
  160. package/src/execute.ts +193 -423
  161. package/src/executor-result.ts +3 -3
  162. package/src/executors/quickjs-child.ts +8 -4
  163. package/src/executors/quickjs-protocol.ts +1 -1
  164. package/src/executors/quickjs-runtime.ts +5 -1
  165. package/src/executors/quickjs.ts +11 -8
  166. package/src/favicon.ts +2 -2
  167. package/src/index.ts +70 -222
  168. package/src/invocation.ts +580 -0
  169. package/src/meta-tools.ts +393 -985
  170. package/src/operator-ui/browser.css +360 -0
  171. package/src/operator-ui/browser.ts +987 -0
  172. package/src/operator-ui/generated.ts +4 -0
  173. package/src/operator-ui/model.ts +105 -0
  174. package/src/registry.ts +459 -439
  175. package/src/retired-toolkits.ts +14 -0
  176. package/src/routes/activity.ts +215 -0
  177. package/src/routes/credentials.ts +254 -0
  178. package/src/routes/mcp.ts +339 -0
  179. package/src/routes/oauth.ts +322 -0
  180. package/src/routes/shared.ts +240 -0
  181. package/src/routes/ui.ts +143 -0
  182. package/src/server.ts +64 -1731
  183. package/src/skills.ts +15 -43
  184. package/src/storage/file.ts +3 -1
  185. package/src/storage/memory.ts +3 -1
  186. package/src/timeout.ts +2 -4
  187. package/src/types.ts +8 -60
  188. package/src/ui.ts +60 -1538
  189. package/src/version.ts +1 -1
  190. package/dist/credential-health.d.ts +0 -227
  191. package/dist/credential-health.d.ts.map +0 -1
  192. package/dist/credential-health.js +0 -606
  193. package/dist/credential-health.js.map +0 -1
  194. package/dist/toolkits.d.ts +0 -146
  195. package/dist/toolkits.d.ts.map +0 -1
  196. package/dist/toolkits.js +0 -322
  197. package/dist/toolkits.js.map +0 -1
  198. package/src/credential-health.ts +0 -832
  199. package/src/toolkits.ts +0 -461
@@ -0,0 +1,14 @@
1
+ const hasOwn = (value: object, key: PropertyKey): boolean =>
2
+ Object.prototype.hasOwnProperty.call(value, key);
3
+
4
+ /** Fail loudly when JavaScript callers pass the retired audience-scoping keys. */
5
+ export function assertNoRetiredToolkitOptions(
6
+ source: string,
7
+ options: object,
8
+ ): void {
9
+ if (!hasOwn(options, "toolkits") && !hasOwn(options, "unscoped")) return;
10
+ throw new Error(
11
+ `${source} options \`toolkits\` and \`unscoped\` were removed in issue ` +
12
+ "#178. Deploy one connecta instance per audience instead; see ethos.md.",
13
+ );
14
+ }
@@ -0,0 +1,215 @@
1
+ import {
2
+ InvalidActivityCursorError,
3
+ type ActivityActor,
4
+ type ActivityPage,
5
+ type ActivityReadPage,
6
+ } from "../activity.js";
7
+ import type { InboundAuth } from "../types.js";
8
+ import {
9
+ activityActorNamespace,
10
+ authorize,
11
+ privateJson,
12
+ type RouteContext,
13
+ } from "./shared.js";
14
+
15
+ const ACTIVITY_LABEL_CONCURRENCY = 8;
16
+ const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
17
+ const ACTIVITY_LABEL_MAX_LENGTH = 160;
18
+
19
+ function cleanActivityActorLabel(value: unknown): string | undefined {
20
+ if (typeof value !== "string") return undefined;
21
+ const compact = value.replace(/\s+/gu, " ").trim();
22
+ if (!compact) return undefined;
23
+ return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
24
+ }
25
+
26
+ async function boundedActivityActorLabel(
27
+ hook: NonNullable<InboundAuth["activityActorLabel"]>,
28
+ id: string,
29
+ budgetMs: number,
30
+ ): Promise<string | undefined> {
31
+ let timer: number | undefined;
32
+ try {
33
+ return await Promise.race([
34
+ Promise.resolve(hook(id))
35
+ .then(cleanActivityActorLabel)
36
+ .catch(() => undefined),
37
+ new Promise<undefined>((resolve) => {
38
+ timer = setTimeout(resolve, budgetMs);
39
+ }),
40
+ ]);
41
+ } catch {
42
+ return undefined;
43
+ } finally {
44
+ if (timer !== undefined) clearTimeout(timer);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Add display-only actor labels to one authorized activity page. Resolution is
50
+ * best-effort, bounded, and read-time only: stored events retain stable ids and
51
+ * a profile-provider outage falls back to those ids without failing the page.
52
+ */
53
+ async function enrichActivityActorLabels(
54
+ page: ActivityPage,
55
+ auth: readonly InboundAuth[],
56
+ ): Promise<ActivityReadPage> {
57
+ const identities = new Map<
58
+ string,
59
+ { kind: string; id: string; namespace?: string }
60
+ >();
61
+ for (const event of page.events) {
62
+ if (!event.actor.id) continue;
63
+ identities.set(
64
+ JSON.stringify([
65
+ event.actor.kind,
66
+ event.actor.namespace,
67
+ event.actor.id,
68
+ ]),
69
+ {
70
+ kind: event.actor.kind,
71
+ id: event.actor.id,
72
+ ...(event.actor.namespace
73
+ ? { namespace: event.actor.namespace }
74
+ : {}),
75
+ },
76
+ );
77
+ }
78
+ const queue = [...identities.entries()];
79
+ const labels = new Map<string, string>();
80
+ let next = 0;
81
+ const deadline = Date.now() + ACTIVITY_LABEL_PAGE_BUDGET_MS;
82
+ const workers = Array.from(
83
+ { length: Math.min(ACTIVITY_LABEL_CONCURRENCY, queue.length) },
84
+ async () => {
85
+ while (next < queue.length) {
86
+ const entry = queue[next++];
87
+ if (!entry) return;
88
+ const [key, identity] = entry;
89
+ const sameKindProviders = auth
90
+ .map((provider, index) => ({ provider, index }))
91
+ .filter(({ provider }) => provider.kind === identity.kind);
92
+ const candidates = sameKindProviders.filter(({ provider }) =>
93
+ Boolean(provider.activityActorLabel),
94
+ );
95
+ const eligible = identity.namespace
96
+ ? candidates.filter(
97
+ ({ provider }) =>
98
+ activityActorNamespace(provider) === identity.namespace,
99
+ )
100
+ : (() => {
101
+ const directoryKey = ({
102
+ provider,
103
+ index,
104
+ }: (typeof sameKindProviders)[number]) => {
105
+ const namespace = activityActorNamespace(provider);
106
+ return namespace === undefined
107
+ ? `provider:${index}`
108
+ : `namespace:${namespace}`;
109
+ };
110
+ // Every same-kind provider participates in the ambiguity check,
111
+ // even if it cannot resolve labels. Otherwise a legacy ID owned
112
+ // by a provider without a resolver could be disclosed to a
113
+ // different provider that happens to have one.
114
+ const directories = new Set(
115
+ sameKindProviders.map(directoryKey),
116
+ );
117
+ if (directories.size !== 1) return [];
118
+ const [directory] = directories;
119
+ return candidates.filter(
120
+ (candidate) => directoryKey(candidate) === directory,
121
+ );
122
+ })();
123
+ // One namespace is one directory. Use its first configured resolver so
124
+ // duplicate gate adapters over the same Clerk instance do not multiply
125
+ // the provider-level concurrency cap.
126
+ const provider = eligible[0]?.provider;
127
+ if (!provider) continue;
128
+ const remaining = deadline - Date.now();
129
+ if (remaining <= 0) return;
130
+ const label = await boundedActivityActorLabel(
131
+ provider.activityActorLabel!.bind(provider),
132
+ identity.id,
133
+ remaining,
134
+ );
135
+ if (label) labels.set(key, label);
136
+ }
137
+ },
138
+ );
139
+ await Promise.all(workers);
140
+ return {
141
+ ...page,
142
+ events: page.events.map((event) => {
143
+ const resolved = event.actor.id
144
+ ? labels.get(
145
+ JSON.stringify([
146
+ event.actor.kind,
147
+ event.actor.namespace,
148
+ event.actor.id,
149
+ ]),
150
+ )
151
+ : undefined;
152
+ // Never trust or echo a `label` supplied by storage. The persisted event
153
+ // schema has no label; only this authenticated read path may add one.
154
+ const actor: ActivityActor = {
155
+ kind: event.actor.kind,
156
+ ...(event.actor.id ? { id: event.actor.id } : {}),
157
+ ...(event.actor.namespace
158
+ ? { namespace: event.actor.namespace }
159
+ : {}),
160
+ };
161
+ return {
162
+ ...event,
163
+ actor: resolved ? { ...actor, label: resolved } : actor,
164
+ };
165
+ }),
166
+ };
167
+ }
168
+
169
+ export async function routeActivity(
170
+ context: RouteContext,
171
+ ): Promise<Response | null> {
172
+ const { path, request, url, baseUrl, opts } = context;
173
+ if (path !== "/ui/activity") return null;
174
+ if (request.method !== "GET") {
175
+ return privateJson({ error: "method not allowed" }, { status: 405 });
176
+ }
177
+ const authz = await authorize(request, baseUrl, opts.auth);
178
+ if (!authz.ok) return authz.response;
179
+ if (
180
+ opts.activityReadGate &&
181
+ !(await opts.activityReadGate(authz.actor))
182
+ ) {
183
+ return privateJson({ error: "forbidden" }, { status: 403 });
184
+ }
185
+ if (!opts.activity?.list) {
186
+ return privateJson(
187
+ { error: "activity history is not configured" },
188
+ { status: 404 },
189
+ );
190
+ }
191
+ const cursor = url.searchParams.get("cursor") ?? undefined;
192
+ if (cursor && cursor.length > 500) {
193
+ return privateJson({ error: "invalid cursor" }, { status: 400 });
194
+ }
195
+ const requestedLimit = Number(url.searchParams.get("limit") ?? "50");
196
+ const limit = Number.isFinite(requestedLimit)
197
+ ? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
198
+ : 50;
199
+ try {
200
+ const page = await opts.activity.list({
201
+ ...(cursor !== undefined ? { cursor } : {}),
202
+ limit,
203
+ });
204
+ return privateJson(await enrichActivityActorLabels(page, opts.auth));
205
+ } catch (error) {
206
+ if (error instanceof InvalidActivityCursorError) {
207
+ return privateJson({ error: error.message }, { status: 400 });
208
+ }
209
+ opts.logger.error("[connecta] activity read failed", error);
210
+ return privateJson(
211
+ { error: "activity history is temporarily unavailable" },
212
+ { status: 503 },
213
+ );
214
+ }
215
+ }
@@ -0,0 +1,254 @@
1
+ import {
2
+ credentialTestRule,
3
+ describeCredentialTestMismatch,
4
+ storedCredentialShape,
5
+ } from "../credentials.js";
6
+ import type {
7
+ ConnectorCredentialConfig,
8
+ ConnectorCredentialValues,
9
+ } from "../types.js";
10
+ import {
11
+ authorizeUiAdmin,
12
+ isSameOrigin,
13
+ msg,
14
+ privateJson,
15
+ type RouteContext,
16
+ } from "./shared.js";
17
+
18
+ type CredentialInput =
19
+ | { kind: "single"; value: string }
20
+ | { kind: "multiple"; values: ConnectorCredentialValues };
21
+
22
+ async function readCredentialInput(
23
+ request: Request,
24
+ config: ConnectorCredentialConfig,
25
+ ): Promise<
26
+ { ok: true; input: CredentialInput } | { ok: false; response: Response }
27
+ > {
28
+ if (
29
+ !request.headers
30
+ .get("content-type")
31
+ ?.toLowerCase()
32
+ .startsWith("application/json")
33
+ ) {
34
+ return {
35
+ ok: false,
36
+ response: privateJson(
37
+ { error: "Content-Type must be application/json" },
38
+ { status: 415 },
39
+ ),
40
+ };
41
+ }
42
+ const raw = await request.text();
43
+ if (raw.length > 20_000) {
44
+ return {
45
+ ok: false,
46
+ response: privateJson(
47
+ { error: "request body is too large" },
48
+ { status: 413 },
49
+ ),
50
+ };
51
+ }
52
+ try {
53
+ const body = JSON.parse(raw) as {
54
+ value?: unknown;
55
+ values?: unknown;
56
+ };
57
+ if (!config.fields?.length) {
58
+ if (typeof body.value !== "string" || !body.value.trim()) {
59
+ return {
60
+ ok: false,
61
+ response: privateJson(
62
+ { error: "value must be a non-empty string" },
63
+ { status: 400 },
64
+ ),
65
+ };
66
+ }
67
+ return { ok: true, input: { kind: "single", value: body.value } };
68
+ }
69
+ if (
70
+ !body.values ||
71
+ typeof body.values !== "object" ||
72
+ Array.isArray(body.values)
73
+ ) {
74
+ return {
75
+ ok: false,
76
+ response: privateJson(
77
+ { error: "values must be an object" },
78
+ { status: 400 },
79
+ ),
80
+ };
81
+ }
82
+ const rawValues = body.values as Record<string, unknown>;
83
+ const expected = new Set(config.fields.map((field) => field.name));
84
+ const unexpected = Object.keys(rawValues).find(
85
+ (field) => !expected.has(field),
86
+ );
87
+ if (unexpected) {
88
+ return {
89
+ ok: false,
90
+ response: privateJson(
91
+ { error: `unexpected credential field "${unexpected}"` },
92
+ { status: 400 },
93
+ ),
94
+ };
95
+ }
96
+ const values: ConnectorCredentialValues = {};
97
+ for (const field of config.fields) {
98
+ const value = rawValues[field.name];
99
+ if (typeof value !== "string" || !value.trim()) {
100
+ return {
101
+ ok: false,
102
+ response: privateJson(
103
+ { error: `${field.name} must be a non-empty string` },
104
+ { status: 400 },
105
+ ),
106
+ };
107
+ }
108
+ values[field.name] = value;
109
+ }
110
+ return { ok: true, input: { kind: "multiple", values } };
111
+ } catch {
112
+ return {
113
+ ok: false,
114
+ response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
115
+ };
116
+ }
117
+ }
118
+
119
+ async function handleCredentialRequest(
120
+ context: RouteContext,
121
+ connectorId: string,
122
+ action: string | undefined,
123
+ ): Promise<Response> {
124
+ const { request, baseUrl, opts } = context;
125
+ if (!opts.credentialVault) {
126
+ return privateJson(
127
+ { error: "credential storage is not configured" },
128
+ { status: 503 },
129
+ );
130
+ }
131
+ if (!isSameOrigin(request, baseUrl)) {
132
+ return privateJson(
133
+ { error: "same-origin request required" },
134
+ { status: 403 },
135
+ );
136
+ }
137
+ const admin = await authorizeUiAdmin(
138
+ request,
139
+ baseUrl,
140
+ opts.auth,
141
+ );
142
+ if (!admin.ok) return admin.response;
143
+
144
+ const connector = opts.registry.getConnector(connectorId);
145
+ if (!connector?.credential) {
146
+ return privateJson({ error: "unknown credential slot" }, { status: 404 });
147
+ }
148
+
149
+ if (action === "test") {
150
+ if (request.method !== "POST") {
151
+ return privateJson({ error: "method not allowed" }, { status: 405 });
152
+ }
153
+ // The declared credential shape picks the hook — the same single rule the
154
+ // Credentials page asks for its Test affordance, so a shown button reaches
155
+ // that reads the shape the credential was stored in.
156
+ const rule = credentialTestRule(connector);
157
+ if (!rule.mode) {
158
+ return privateJson(
159
+ {
160
+ error: rule.mismatch
161
+ ? "this connector cannot test its credential: " +
162
+ describeCredentialTestMismatch(rule.mismatch)
163
+ : "this connector does not support credential testing",
164
+ },
165
+ { status: 400 },
166
+ );
167
+ }
168
+ try {
169
+ const values = await opts.credentialVault.getAll(connectorId);
170
+ const shape = storedCredentialShape(connector.credential, values);
171
+ if (shape.state === "missing") {
172
+ return privateJson(
173
+ {
174
+ error:
175
+ rule.mode === "multiple"
176
+ ? "configure the credentials before testing them"
177
+ : "configure the credential before testing it",
178
+ },
179
+ { status: 409 },
180
+ );
181
+ }
182
+ if (shape.state === "mismatch") {
183
+ return privateJson({ error: shape.message }, { status: 409 });
184
+ }
185
+ const storedValues = values!;
186
+ const ctx = opts.registry.contextFor(connectorId, baseUrl);
187
+ const result =
188
+ rule.mode === "multiple"
189
+ ? await connector.testCredentials!(storedValues, ctx)
190
+ : await connector.testCredential!(
191
+ // The single-value shape check above guarantees this key.
192
+ storedValues.value!,
193
+ ctx,
194
+ );
195
+ return privateJson(result);
196
+ } catch (err) {
197
+ return privateJson({ ok: false, message: msg(err) });
198
+ }
199
+ }
200
+
201
+ if (action) {
202
+ return privateJson({ error: "not found" }, { status: 404 });
203
+ }
204
+ if (request.method === "PUT") {
205
+ const input = await readCredentialInput(request, connector.credential);
206
+ if (!input.ok) return input.response;
207
+ try {
208
+ const metadata =
209
+ input.input.kind === "single"
210
+ ? await opts.credentialVault.set(
211
+ connectorId,
212
+ input.input.value,
213
+ admin.userId,
214
+ )
215
+ : await opts.credentialVault.setAll(
216
+ connectorId,
217
+ input.input.values,
218
+ admin.userId,
219
+ );
220
+ await opts.registry.invalidateStored(connectorId);
221
+ return privateJson({ credential: metadata });
222
+ } catch (err) {
223
+ return privateJson({ error: msg(err) }, { status: 400 });
224
+ }
225
+ }
226
+ if (request.method === "DELETE") {
227
+ await opts.credentialVault.delete(connectorId);
228
+ await opts.registry.invalidateStored(connectorId);
229
+ return new Response(null, {
230
+ status: 204,
231
+ headers: {
232
+ "Cache-Control": "no-store",
233
+ "Referrer-Policy": "no-referrer",
234
+ },
235
+ });
236
+ }
237
+ return privateJson({ error: "method not allowed" }, { status: 405 });
238
+ }
239
+
240
+ export async function routeCredentials(
241
+ context: RouteContext,
242
+ ): Promise<Response | null> {
243
+ const match =
244
+ /^\/ui\/credentials\/([a-z0-9_-]+)(?:\/([a-z]+))?$/.exec(context.path);
245
+ if (!match) return null;
246
+ const connectorId = match[1];
247
+ if (!connectorId) return null;
248
+ // Never opt these mutation routes into the server's wildcard CORS
249
+ // preflight behavior.
250
+ if (context.request.method === "OPTIONS") {
251
+ return privateJson({ error: "method not allowed" }, { status: 405 });
252
+ }
253
+ return handleCredentialRequest(context, connectorId, match[2]);
254
+ }