@zackbart/connecta 0.5.0 → 0.6.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 (70) hide show
  1. package/CHANGELOG.md +358 -0
  2. package/README.md +53 -12
  3. package/dist/auth/bearer.d.ts +10 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +21 -0
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +26 -1
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +161 -4
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  12. package/dist/connectors/remote-mcp.js +8 -0
  13. package/dist/connectors/remote-mcp.js.map +1 -1
  14. package/dist/credential-health.d.ts +212 -0
  15. package/dist/credential-health.d.ts.map +1 -0
  16. package/dist/credential-health.js +535 -0
  17. package/dist/credential-health.js.map +1 -0
  18. package/dist/execute.d.ts.map +1 -1
  19. package/dist/execute.js +16 -4
  20. package/dist/execute.js.map +1 -1
  21. package/dist/index.d.ts +46 -5
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +96 -13
  24. package/dist/index.js.map +1 -1
  25. package/dist/meta-tools.d.ts +56 -5
  26. package/dist/meta-tools.d.ts.map +1 -1
  27. package/dist/meta-tools.js +249 -92
  28. package/dist/meta-tools.js.map +1 -1
  29. package/dist/registry.d.ts +62 -0
  30. package/dist/registry.d.ts.map +1 -1
  31. package/dist/registry.js +85 -1
  32. package/dist/registry.js.map +1 -1
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +292 -37
  35. package/dist/server.js.map +1 -1
  36. package/dist/skills.d.ts +1 -1
  37. package/dist/skills.d.ts.map +1 -1
  38. package/dist/skills.js +1 -1
  39. package/dist/timeout.d.ts +16 -0
  40. package/dist/timeout.d.ts.map +1 -0
  41. package/dist/timeout.js +38 -0
  42. package/dist/timeout.js.map +1 -0
  43. package/dist/toolkits.d.ts +95 -1
  44. package/dist/toolkits.d.ts.map +1 -1
  45. package/dist/toolkits.js +190 -5
  46. package/dist/toolkits.js.map +1 -1
  47. package/dist/types.d.ts +70 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/ui.d.ts +35 -0
  50. package/dist/ui.d.ts.map +1 -1
  51. package/dist/ui.js +87 -3
  52. package/dist/ui.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +1 -1
  56. package/src/auth/bearer.ts +35 -1
  57. package/src/auth/clerk.ts +202 -5
  58. package/src/connectors/remote-mcp.ts +9 -0
  59. package/src/credential-health.ts +736 -0
  60. package/src/execute.ts +28 -4
  61. package/src/index.ts +176 -20
  62. package/src/meta-tools.ts +286 -109
  63. package/src/registry.ts +125 -1
  64. package/src/server.ts +349 -34
  65. package/src/skills.ts +1 -1
  66. package/src/timeout.ts +49 -0
  67. package/src/toolkits.ts +241 -6
  68. package/src/types.ts +76 -1
  69. package/src/ui.ts +98 -3
  70. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,364 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.6.0 — 2026-07-26
6
+
7
+ A feature release that makes two of 0.5.0's mechanisms protective rather than
8
+ merely organizational. Toolkits gain an identity binding, so a credential opens
9
+ the view its team was given and nothing else; connector status gains a proactive
10
+ credential liveness layer, so an expired downstream token shows up as
11
+ `auth_required` before an agent's call trips over it; and `clerkAuth` gains an
12
+ email-domain allowlist. Eight fixes on the result path and the diagnostic
13
+ surfaces sit underneath them. Nothing here is breaking — every new config key is
14
+ optional and every new type is additive — but this release is *not* runtime-inert
15
+ for a deployment that declares none of them, and the deltas are worth reading
16
+ before upgrading:
17
+
18
+ - **MCP-mode truncation now measures what it truncates.** A `kind: "mcp"`
19
+ connector's result whose size falls between the old text-only measure and the
20
+ serialized envelope came back inline in 0.5.0 and now truncates, pages, and
21
+ reports `totalBytes` in envelope bytes (issue #43, below). It is deliberate,
22
+ and it is the one change on the result path that a deployment can notice
23
+ without changing any configuration.
24
+ - **A handler returning `undefined` renders as the text `undefined`** under
25
+ `resultMode: "mcp"`, where 0.5.0 emitted a `TextContent` block with no `text`
26
+ at all (issue #42). Every serializable return is byte-identical.
27
+ - **The cheap status path reports more failures.** `list_connectors({ probe:
28
+ false })` now reads `error` for a connector whose *catalog* load keeps failing
29
+ (issue #46) and `auth_required` when a background liveness check found the
30
+ stored credential dead (issue #24). Both are the fix; both mean a connector
31
+ that read clean in 0.5.0 while being broken now reads broken.
32
+ - **Credential liveness checks are on by default.** A deployment holding an
33
+ operator-managed `credential` or a downstream OAuth grant begins making
34
+ liveness calls — its own `testCredential(s)` hook, or the OAuth
35
+ `status(ctx)` refresh — piggybacked on authenticated `/mcp` and `/ui/data`
36
+ traffic, at most one per connector per 15 minutes. No downstream *tool* is
37
+ ever called and no catalog is fetched. Setting `credentialHealth.onRequest`
38
+ to `false` turns that trigger off; the explicit `checkCredentials()` call
39
+ stays available.
40
+ - **Three textual deltas that no configuration turns off**, so anything
41
+ snapshotting them will diff: `get_result`'s tool description now documents the
42
+ `offset` domain and the codepoint realignment, reaching every deployment (its
43
+ JSON Schema is unchanged, `minimum: 0` either way); both favicon routes now
44
+ carry `X-Content-Type-Options` and a `Content-Security-Policy` header, with
45
+ the bodies byte-identical; and any deployment declaring toolkits gets a
46
+ rewritten startup warning, now split into three.
47
+
48
+ The next intentional breaking release stays reserved for issue #28.
49
+
50
+ ### Added
51
+
52
+ - **Toolkit ↔ identity binding — a credential opens the view its team was
53
+ given** (issue #37). Toolkits shipped in 0.5.0 with self-service selection:
54
+ any authenticated caller could name any toolkit, or omit `?toolkit=` and get
55
+ the whole registry. The binding closes that. It is declared **on the auth
56
+ adapter**, not in a deployment-level table keyed by identity id —
57
+ `bearerToken(secret, { subjectId: "support-team", toolkits: ["support"] })`,
58
+ and the same two options on `clerkAuth` — because a typo in such a key means
59
+ *unbound*, which fails open and hands over everything, while a typo in a
60
+ toolkit *name* throws at construction. `InboundAuth.toolkitBinding` is the
61
+ provider-wide declaration and `AuthResult.toolkitBinding` overrides it per
62
+ identity, the documented seam for an adapter mapping its own users to views;
63
+ the declaration is a **ceiling**, not a default — connecta intersects the two
64
+ and grants `unscoped` only if both do, so an adapter reading a user-writable
65
+ IdP claim cannot let a user widen their own binding. Enforcement is one point
66
+ (`resolveToolkitScope`, after the auth gate and before any `ScopedRegistry`
67
+ exists) deciding *whether* a scope is built; what a built scope contains stays
68
+ entirely in `ScopedRegistry`. For a bound identity, a toolkit outside the
69
+ binding, an undeclared or malformed name, and — without `unscoped: true` — a
70
+ connection with no `?toolkit=` are all **403, byte-identical in status and
71
+ body**, and the body names no toolkit: a team credential must not become a
72
+ directory of the org's other teams. The operator log is where the three
73
+ reasons are told apart, naming the identity, the reason and the binding, with
74
+ the same 64-character bounding and U+2028/U+2029 escaping the toolkit
75
+ rejection uses. A bound-but-not-`unscoped` identity is also refused the
76
+ deployment-wide operator surfaces — `/ui/data`, `/ui/activity`, and the
77
+ credential API — because those payloads describe every connector in the org
78
+ and a credential write reaches every view; `/health` and the open routes are
79
+ unchanged. **An unbound identity behaves exactly as 0.5.0 shipped**, and
80
+ bindings are per identity, so a legacy token beside two bound ones keeps
81
+ working. Bindings are validated in three places that each catch what the
82
+ others cannot: the adapter throws on a declaration that does not mean what it
83
+ says (`unscoped` with no `toolkits`, an empty `toolkits` without it, a name
84
+ outside the grammar, a non-array); `createConnecta` cross-checks every name
85
+ against the declared toolkits and throws on an unknown one; and **every
86
+ request re-validates the binding it is about to enforce**, because
87
+ `InboundAuth` is an open interface and neither half can be trusted from the
88
+ type — a binding that fails validation is a 403 with a log line, never a
89
+ silent drop, since dropping it would read as "unbound". `ToolkitBinding`,
90
+ `ToolkitBindingOptions` and `BearerTokenOptions` are exported. The startup
91
+ warning story changes with it (see Fixed).
92
+ - **Proactive credential liveness checks, so a dead credential surfaces before
93
+ a call fails** (issue #24). A connector's auth status previously flipped only
94
+ when something *observed* a failure — an agent's real call erroring
95
+ `auth_required`, or an operator running `list_connectors({ probe: true })` —
96
+ so a revoked token surfaced mid-task. Connecta now checks the credentials it
97
+ stores and serves the verdict from the cheap surfaces. Only two credential
98
+ shapes are eligible, each asked through the hook that exists to answer exactly
99
+ this question: an operator-managed `credential` via `testCredentials(values)`
100
+ / `testCredential(value)` (the same call /ui's Test button makes), and a
101
+ downstream OAuth grant via `status(ctx)`, whose refresh *is* the liveness
102
+ question for a token. **A check never calls a downstream tool and never
103
+ fetches a catalog**, so it cannot mutate downstream state and no destructive
104
+ tool is reachable from it, and **a connector with nothing stored is never
105
+ probed** — a new optional `Connector.hasStoredCredential` answers for
106
+ connectors holding their own credential (`remoteMcp` implements it for
107
+ oauth) and the vault answers for the rest, so a timer never starts DCR and
108
+ consent for a connector nobody has authorized. The core starts **no timers**,
109
+ because it has to run unchanged on Workers: instead there are two triggers
110
+ sharing one budget — an authenticated `/mcp` or `/ui/data` request hands a
111
+ *due* sweep to `ctx.waitUntil` (never awaited by the request, never triggered
112
+ by an unauthenticated or refused one), and the new
113
+ `Connecta.checkCredentials({ baseUrl?, force?, ids? })` is an ordinary awaited
114
+ call for the host's own scheduler — wired to a cron trigger in the Worker
115
+ example and a `setInterval(...).unref()` in the Node and Docker ones —
116
+ returning one `CredentialCheckResult` per connector considered, and never
117
+ rejecting on a connector failure.
118
+ Cost is bounded four ways: eligibility, a persisted freshness window
119
+ (`intervalSeconds`, default 900, shared across isolates so repeated status
120
+ reads never each produce a check), one traffic-triggered sweep per interval
121
+ per isolate, and a per-check deadline (`timeoutMs`, default 30 000) with at
122
+ most `concurrency` (default 4) in flight. Verdicts are persisted under
123
+ `credhealth:<connectorId>` rather than held in memory, because on Workers the
124
+ cron isolate is not the isolate answering status reads. `list_connectors`
125
+ gains `credentialCheck: { state, checkedAt, message?, authorizationUrl? }`,
126
+ present only for connectors holding a stored credential, and /ui's connector
127
+ card renders it. What a verdict may *decide* is deliberately narrow and lives
128
+ in one function: only `auth_required` ever sets the `probe: false` status, and
129
+ only while nothing better has happened since — a real call that succeeded
130
+ after `checkedAt` retires it, read deployment-wide so a sibling toolkit's
131
+ success counts. An `error` verdict is **reported and decides nothing**: a
132
+ check that timed out or got a 502 failed to *complete* and learned nothing
133
+ about the credential, so letting it set the status would flip a working
134
+ connector to `error` for a whole interval on a DNS blip. An `ok` verdict
135
+ upgrades `unknown` to `ok` and never downgrades an observed failure, while
136
+ `auth_required` outranks even a newer real-call failure — both say something
137
+ is wrong, only one carries the URL that fixes it, and the failure stays
138
+ visible as `lastError`. Recovery needs no restart and is fenced against a
139
+ check in flight: a completed `/oauth/callback/<id>` and a credential `PUT` or
140
+ `DELETE` drop the verdict and advance a per-connector generation counter
141
+ (`credhealth:gen:<id>`) that a check captures at its start and re-reads before
142
+ writing, so a check that began before consent finished cannot resurrect the
143
+ stale `auth_required` and its stale consent URL. `credentialHealth` on
144
+ `ConnectaConfig` tunes all of it; `CredentialHealthConfig`,
145
+ `CredentialCheckResult`, `CredentialCheckSkip`, `CredentialCheckState` and
146
+ `CredentialHealthRecord` are exported. Automatic re-authorization is out of
147
+ scope — refresh rotation is already the OAuth flow's job and interactive
148
+ re-consent stays manual through `authorize_connector`. **Known gap, filed not
149
+ fixed:** a probe leaves a downstream MCP session open, because
150
+ `remoteMcp().status()` connects a client that nothing closes. That has been
151
+ true of `list_connectors({ probe: true })` and `/ui/data` since long before
152
+ this release; the sweep makes it periodic rather than operator-triggered.
153
+ Closing it needs a teardown seam on the `Connector` contract, so it is issue
154
+ #66 and is named in the docs beside the feature.
155
+ - **`clerkAuth({ allowedDomains })` — "anyone @acme.com, nobody else" as one
156
+ option** (issue #58). The policy was expressible with `gate` already, but only
157
+ by hand-writing fetch-user / read-primary-email / compare-domain /
158
+ handle-the-lookup-failing in every deployment, a security-relevant idiom with
159
+ four ways to get subtly wrong. Matching is exact, case-insensitive and on the
160
+ whole domain: `acme.com` admits `dev@ACME.com` and rejects `evil-acme.com`,
161
+ `acme.com.evil.com`, `acme.co` and `mail.acme.com` — a subdomain must be
162
+ spelled out — and the address is split on its **last** `@`, so
163
+ `"dev@acme.com"@evil.com` resolves to `evil.com`. It **fails closed** on every
164
+ uncertainty (no primary email, an unverified one, a malformed address, a Clerk
165
+ lookup that throws), returning the same bare `403 {"error":"forbidden"}` a
166
+ `gate` rejection returns with no hint why, while the operator gets the reason
167
+ in the log with the domain bounded and escaped and never the local part of
168
+ anyone's address. The list is validated at construction — a non-domain, an
169
+ `@`, an empty list, a non-array, or a Unicode lookalike (internationalized
170
+ domains must be written in punycode) throws where the operator wrote it rather
171
+ than silently binding nothing. It composes with `gate`: each configured one
172
+ must pass, the allowlist is evaluated first so a caller outside the org never
173
+ reaches operator gate code, and one verdict per user covers both and rides the
174
+ existing identity cache, so it costs no more Clerk calls than `gate` alone. It
175
+ governs Clerk sign-in only — a co-configured `bearerToken` has no email to
176
+ read. Unset, no user lookup happens at all and any authenticated user is
177
+ admitted, exactly as before. Where this sits relative to the toolkit binding
178
+ is now documented explicitly: `allowedDomains`/`gate` decide **who gets into
179
+ the org**, the binding decides **what they see** once in. **Residual:** the
180
+ identity cache is a `Map` that never evicts, and an allowlist beside open
181
+ Clerk sign-up is the first configuration in which a stranger can populate it —
182
+ each denied user id costs a permanent entry for the life of the isolate. The
183
+ denial itself is correct and TTL'd; the footprint is issue #70.
184
+
185
+ ### Fixed
186
+
187
+ - **MCP-mode truncation measured text blocks but truncated the envelope, and
188
+ never fired on non-text content at all** (issue #43). The quantity that
189
+ *decided* truncation was the text blocks' byte length; what was truncated,
190
+ stashed and reported as `totalBytes` was `JSON.stringify(content, null, 2)`,
191
+ the strictly larger envelope. So the head and `totalBytes` described a string
192
+ the cap was never compared against (pinned by a test where 240 bytes of text
193
+ sit in a 700-plus-byte envelope under a 300-byte cap — it came back inline
194
+ before and truncates now), and an all-non-text result scored zero text bytes,
195
+ so a single 50 KB base64 `image` block was returned inline, unbounded, with no
196
+ `resultId` to page from. One `guardContent` now measures the same string it
197
+ stashes and counts every block. What truncation *means* differs by content: an
198
+ all-text envelope keeps the historical head plus notice, since a JSON prefix
199
+ is still readable, while an envelope carrying any non-text block is replaced
200
+ by the `{ truncated, resultId, totalBytes, hint }` notice **alone**, because
201
+ the head of a half-written base64 image is useless and leaves unparseable
202
+ block structure. Either way the full envelope is stashed and pages back whole
203
+ through `get_result`, and the ~170-byte notice sits outside the cap as it
204
+ always has on the truncated-head path. Under the cap, blocks pass through
205
+ untouched and in order — including a result whose measurement itself is
206
+ impossible: a `BigInt` or a cycle makes the envelope stringify throw, which is
207
+ caught and falls back to returning the content untouched rather than failing a
208
+ call that used to pass. **The one thing an existing `kind: "mcp"` deployment
209
+ will notice** is stated above: results between the two measures now truncate.
210
+ - **A handler returning `undefined` emitted a `TextContent` block with no
211
+ `text`** (issue #42). `JSON.stringify(undefined, null, 2)` *is* `undefined`,
212
+ not a string; `guardText` then measured `enc.encode(undefined)` — the empty
213
+ string, per the WebIDL default — took the under-cap early return, and emitted
214
+ the non-string unchanged, so clients received `{"content":[{"type":"text"}]}`,
215
+ which `TextContent` does not permit. The three guards that answer this same
216
+ question each defended differently, so they now share one
217
+ `serializeResultText`: JSON text for whatever JSON can represent, `String(
218
+ value)` for what JSON renders as `undefined`, and `guardText` normalizes at
219
+ the door so its size logic is unreachable with a non-string. A value JSON
220
+ cannot serialize at all still throws and is reported as a failure. The
221
+ semantics are now written down where `resultMode` is documented, including the
222
+ sharp edge that only a bare `undefined` renders as `undefined` — a function
223
+ renders as its **source text** and a Symbol as `Symbol(label)`, so a handler
224
+ mistakenly returning a closure puts the function's source in front of the
225
+ model.
226
+ - **`get_result`'s `offset` had none of the defense issue #32 gave `maxBytes`**
227
+ (issue #38). An in-handler `offset: NaN` propagated through `Math.max(0,
228
+ Math.trunc(...))`, sliced to nothing, and serialized as `"offset": null` with
229
+ empty text and no `nextOffset` — the result vanished silently instead of
230
+ erroring. Validity is now one shared rule spelled once for the wire schema and
231
+ once in the handler, exactly as #32 did for the cap, and an out-of-domain
232
+ value is an ordinary input-validation error; an offset past the end of the
233
+ payload stays legal and answers with an empty final page. Separately, a legal
234
+ in-range offset landing mid-codepoint emitted U+FFFD; it is now aligned
235
+ **back** to that character's first byte — re-reading a few bytes is
236
+ recoverable, silently skipping the rest of a character is not — and the offset
237
+ actually served is what the response reports. Server-produced `nextOffset`
238
+ values are already boundaries and are served exactly as given, so paging is
239
+ byte-identical.
240
+ - **A failing catalog lookup never recorded a connector health failure** (issue
241
+ #46). `call_tool`'s catalog-lookup catch returned a failure to the caller but,
242
+ unlike the execution catch a hundred lines below, never called
243
+ `recordFailure`, and neither `getTools` nor `refreshTools` compensated. A
244
+ connector whose `listTools` fails persistently — a revoked downstream grant,
245
+ the scenario the comment at that catch already named — therefore accumulated
246
+ no `consecutiveFailures` and read **clean** from `list_connectors({ probe:
247
+ false })` while every single call against it failed, which is precisely the
248
+ cheap signal an operator or agent consults to find a broken connector. Both
249
+ catalog-load sites now record: the shared `runCall` catch behind `call_tool`,
250
+ `call_destructive_tool` and `batch_call`, and the `execute_code` path's
251
+ provider build, which previously dropped a connector's whole sandbox namespace
252
+ with nothing but a `logger.warn`. The accounting deliberately stays at the
253
+ call sites rather than inside `Registry`: `registry` there is the connection's
254
+ *view*, so a toolkit-scoped session records into its own health log as well as
255
+ the deployment-wide one, and registry-level accounting would leave scoped
256
+ sessions blind while double-counting against the probe path that already
257
+ records. A warm-cache hit still records nothing in either direction — a hit is
258
+ not evidence of health.
259
+ - **The toolkits startup warning fired for a configuration that cannot have the
260
+ risk it named** (issue #45), and now tells three different stories. The check
261
+ tested `config.toolkits` for truthiness; `{}` is truthy but resolves to no
262
+ selectable toolkit at all, so a deployment configured that way was told that
263
+ "any caller can choose any toolkit" — the kind of warning that teaches
264
+ operators to skim past the ones that matter. All warnings are now keyed off
265
+ the **resolved** toolkit map, the same one `?toolkit=` resolves against, so
266
+ they cannot drift from what is actually selectable. With the binding feature
267
+ the single warning splits into three, because the fix differs in each: toolkits
268
+ with no `auth` at all (no identity exists to bind — configure `auth` first);
269
+ toolkits with `auth` but no declared binding anywhere (the shape that
270
+ organizes without protecting); and the dangerous middle, toolkits with *some*
271
+ providers bound, which names the unbound ones — the shape where an operator
272
+ believes the deployment is separated while one forgotten credential opens every
273
+ view
274
+ and every deployment-wide surface. Declaring `toolkits: [...], unscoped: true`
275
+ is how an operator credential says so and stops appearing. The open-mode "no
276
+ inbound authentication" warning remains independent of the `toolkits` value.
277
+ - **An unknown `?toolkit=` was undiagnosable from both ends** (issue #47). The
278
+ 404 is correct and its body is well worded, but mainstream MCP clients treat a
279
+ 404 on the transport endpoint as a transport failure and discard the body, and
280
+ nothing was logged — so a one-character typo in a hand-copied MCP URL gave the
281
+ user "failed to connect" and the operator nothing at all. A rejected selection
282
+ now emits an operator `logger.warn` naming the rejected value and the
283
+ configured toolkits, while the **response is byte-for-byte unchanged**: still
284
+ no enumeration, still the same bounded echo. The new channel is bounded and
285
+ unforgeable — the value is truncated to the same 64 characters the body echoes
286
+ at and escaped with `JSON.stringify` plus a hand-rolled escape for U+2028 and
287
+ U+2029, which `JSON.stringify` leaves raw — and the line is written **after**
288
+ the auth gate, so on a deployment with `auth` configured a caller the gate
289
+ rejects cannot make it log anything.
290
+ - **The dead `maxResultBytes` parameter on the meta-tool constructors is gone**
291
+ (issue #44). Removed rather than exposed: `ConnectaConfig.maxResultBytes` and
292
+ the per-connector override are already the documented answer to where a
293
+ deployment sets the cap, a third global knob would need a precedence rule
294
+ nobody asked for, and `ServerOptions` never carried the field, so nothing
295
+ production could reach it — the constructors are internal factoring and are
296
+ not part of the exported API. Cap tests now configure the deployment cap the
297
+ way `createConnecta` does, so they also pick up its normalization, plus a new
298
+ end-to-end test that a `createConnecta({ maxResultBytes })` value truncates
299
+ and pages over the wire. The effective cap for a deployment that sets nothing
300
+ is unchanged.
301
+ - **The documented `ApiOptions` and `RemoteMcpOptions` now match the source
302
+ field for field** (issue #41). Both were presented as verbatim `export
303
+ interface` listings while silently dropping real fields, which is worse than
304
+ no listing: `ApiOptions` lacked `credential`, `testCredential`,
305
+ `testCredentials` and `strictValidation` — putting the docs in direct
306
+ contradiction with the README quick start, which uses `credential` — and
307
+ `RemoteMcpOptions` lacked `requireHttps` and `logger`. Two of the omissions
308
+ are security controls, so `strictValidation` and `requireHttps` now carry
309
+ behavior, default and threat model verified against the implementation. Same
310
+ sweep: the credential field sub-shape had dropped `description`/`placeholder`,
311
+ the `validateToolInput` example omitted `failClosed`, and the `clerkAuth`
312
+ bullet claimed `authorizedParties: [connectaOrigin]` is passed to
313
+ `authenticateRequest` when the source deliberately does not — an OAuth access
314
+ token may carry no `azp` — and pins `azp` by hand for session tokens instead.
315
+ The remaining listings were checked against source and were accurate as
316
+ written. **Residual, filed not fixed:** the review that produced this pass
317
+ also found that `/ui` offers its credential Test button from the mere presence
318
+ of a test hook, without checking it matches the credential *shape* configured,
319
+ so a fully configured named-fields credential can answer "configure the
320
+ credential before testing it" (issue #55).
321
+
322
+ ### Security
323
+
324
+ - **Favicon bodies are served inertly, completing the URL-position invariant**
325
+ (issue #31). `/favicon.svg` returned an operator-supplied SVG verbatim as
326
+ `image/svg+xml` with no `X-Content-Type-Options` and no CSP, so a `<script>`
327
+ inside a branding SVG executed **on the deployment origin** the moment anyone
328
+ navigated straight to the URL — strictly more powerful than the
329
+ `favicon.href` vector 0.5.0 closed, because the payload is same-origin. Both
330
+ favicon routes now answer with `X-Content-Type-Options: nosniff` and
331
+ `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
332
+ sandbox`. Neutralizing the *response* rather than inspecting the body is what
333
+ keeps every valid static SVG byte-identical, the built-in mark included, and
334
+ inline styles stay allowed because the default mark uses one to follow the OS
335
+ colour scheme while CSS cannot script. `.ico` bodies are inert bytes and are
336
+ still served verbatim, but deliberately in scope of the same headers, so the
337
+ rule reads "every favicon route is neutralized" rather than "whichever route
338
+ happened to get attention".
339
+ - **`uiAuth.frontendApiUrl` is scheme-gated.** It was the last operator-config
340
+ value reaching a URL-valued HTML position — the `<script src>` of `/ui`'s
341
+ sign-in loader — without one. It now requires an absolute `https:` URL,
342
+ deliberately stricter than the branding gates with no `http:` and no loopback
343
+ carve-out, because nobody types this value: `clerkAuth` derives it from the
344
+ publishable key and Clerk's Frontend API is always https. A rejected value
345
+ reaches neither position on the page — the loader tag is not emitted, and the
346
+ serialized `AUTH` object is enumerated field by field so the bad URL cannot
347
+ slip in through the inline script. `/ui` still renders, reports that Clerk
348
+ could not load, and `createConnecta` logs a warning naming the provider, the
349
+ same fallback-and-warn posture the branding drops established. **Residual:**
350
+ `uiAuth.signInUrl` and `signUpUrl` are operator config that reaches the
351
+ browser as a *navigation target* rather than as a rendered attribute, so the
352
+ invariant as written does not cover them and neither does any gate; the
353
+ exposure is narrow (`/ui`'s nonce CSP blocks a `javascript:` navigation on a
354
+ CSP3 browser, the `'unsafe-inline'` legacy fallback would not) but the
355
+ exception is the cost. Issue #56.
356
+ - **`/oauth/callback/<id>` still distinguishes a real connector id from an
357
+ invented one** to an unauthenticated caller — 404 for unknown, 400 for a real
358
+ connector with a bad `state`. Nothing is authorized and `verifyState` still
359
+ holds, but the connector inventory leaks, which softens the same "not a
360
+ directory" property the toolkit binding above is built to provide.
361
+ Pre-existing, found while reviewing that binding, filed as issue #62.
362
+
5
363
  ## 0.5.0 — 2026-07-26
6
364
 
7
365
  A feature release. Toolkits, per-connector usage guides, and per-connector
package/README.md CHANGED
@@ -43,14 +43,14 @@ A tool **address** is `<connectorId>.<toolName>` (e.g. `notion.search`).
43
43
 
44
44
  | Tool | Input | Returns |
45
45
  | --- | --- | --- |
46
- | `list_connectors` | `{ probe? }` | live (`probe: true`, default) or cached health, tool count, and recent real-call observations |
46
+ | `list_connectors` | `{ probe? }` | live (`probe: true`, default) or cached health, tool count, recent real-call observations, and the last proactive credential check |
47
47
  | `skills` | `{ name? }` | lists or fetches the concise `usage` guide for choosing among the meta-tools, plus any operator-authored per-connector guide (`connector:<connectorId>`) |
48
48
  | `search_tools` | `{ query?, connector?, limit?, offset?, fullDescriptions?, includeSchemas? }` | ranked, paginated matches; optionally includes compact/raw schemas to remove a round trip |
49
49
  | `describe_tools` | `{ addresses[], format?, fullDescriptions? }` | names, descriptions, input/output schemas, and behavior annotations |
50
50
  | `call_tool` | `{ address, args?, fields?, resultMode?, timeoutMs?, maxRetries?, diagnostics? }` | invokes only tools explicitly annotated `readOnlyHint: true` |
51
51
  | `call_destructive_tool` | same as `call_tool` | invokes unannotated, write-capable, or destructive tools through a host-visible approval boundary |
52
52
  | `authorize_connector` | `{ connector, force? }` | starts (or with `force`, restarts) the downstream OAuth flow; returns the `authorizationUrl` to open |
53
- | `get_result` | `{ id, offset?, maxBytes? }` | a byte-slice page of a truncated result — `{ text, offset, nextOffset?, totalBytes }`; `maxBytes` is a whole number of bytes >= 1 |
53
+ | `get_result` | `{ id, offset?, maxBytes? }` | a byte-slice page of a truncated result — `{ text, offset, nextOffset?, totalBytes }`; `maxBytes` is a whole number of bytes >= 1 and `offset` a whole number of bytes >= 0, aligned back to a character boundary and echoed as the `offset` served |
54
54
  | `batch_call` | `{ calls, resultMode?, timeoutMs?, maxRetries? }` | 1–10 parallel calls sharing request-scoped clients, with attempts/timing/errors |
55
55
  | `execute_code` *(optional)* | `{ code }` | result + logs of bounded async JS orchestration over explicitly read-only tools; registered only when an `executor` is configured |
56
56
 
@@ -81,6 +81,7 @@ const build = (env: Env) =>
81
81
  publishableKey: env.CLERK_PUBLISHABLE_KEY,
82
82
  secretKey: env.CLERK_SECRET_KEY,
83
83
  publicUrl: env.PUBLIC_URL,
84
+ // allowedDomains: ["acme.com"], // only your org's verified emails
84
85
  }),
85
86
  ],
86
87
  connectors: [
@@ -144,13 +145,23 @@ stack): [`examples/docker/`](./examples/docker/).
144
145
  ## Toolkits — one deployment, many teams
145
146
 
146
147
  A deployment belongs to an org. Optional **toolkits** give each group of team
147
- members its own scoped view of the same registry, so different teams no longer
148
- need separate deployments:
148
+ members its own scoped view of the same registry bound to that group's
149
+ credential — so different teams no longer need separate deployments:
149
150
 
150
151
  ```ts
151
152
  createConnecta({
152
153
  connectors: [zendesk, notion, gmail],
153
- auth: bearerToken(env.CONNECTA_TOKEN),
154
+ auth: [
155
+ // Each team's credential is bound to that team's view.
156
+ bearerToken(env.SUPPORT_TOKEN, { subjectId: "support", toolkits: ["support"] }),
157
+ bearerToken(env.EXEC_TOKEN, { subjectId: "exec", toolkits: ["exec"] }),
158
+ // The operator's: every view, plus /ui — declared, not left unbound.
159
+ bearerToken(env.OPS_TOKEN, {
160
+ subjectId: "ops",
161
+ toolkits: ["support", "exec"],
162
+ unscoped: true,
163
+ }),
164
+ ],
154
165
  toolkits: {
155
166
  support: { connectors: ["zendesk", "notion"] },
156
167
  exec: {
@@ -168,12 +179,12 @@ out-of-scope connectors and tools do not exist, and an out-of-scope address
168
179
  fails identically to a nonexistent one. No `?toolkit=` ⇒ the full registry, as
169
180
  before; an unknown name is an error, never a silent fallback.
170
181
 
171
- A toolkit scopes **visibility, not identity**: any authenticated client may
172
- select any toolkit, or omit the parameter and see everything, so a credential
173
- shared by two teams gives both teams every view. Binding a member to a toolkit
174
- belongs in `auth`, which stays the access check; enforcing that binding is a
175
- deliberate follow-up
176
- ([issue #37](https://github.com/zackbart/connecta/issues/37)). Details:
182
+ `toolkits: [...]` on an auth adapter **binds** a credential to its views: the
183
+ support token cannot open `?toolkit=exec`, cannot connect unscoped (unless it also
184
+ passes `unscoped: true`), and cannot read the deployment-wide operator surfaces.
185
+ Refusal is a flat 403 at connect time, identical whether the toolkit is another
186
+ team's or does not exist at all — a mapping, not a policy engine. Leave `toolkits`
187
+ off and that credential keeps today's self-service selection. Details:
177
188
  [docs §16](./docs/documentation.md#16-toolkits-scoped-views).
178
189
 
179
190
  ## Code mode
@@ -245,6 +256,35 @@ Connecta does not bundle service-specific HTTP API connectors. Package consumers
245
256
  define them with `api()` (or implement `Connector` directly), keeping endpoint,
246
257
  credential, and tool choices in the consuming project.
247
258
 
259
+ ## Credential health
260
+
261
+ Stored credentials are checked for liveness *before* an agent's call trips over a
262
+ dead one. Connecta asks each connector holding a credential it stores — a vault
263
+ credential, or a downstream-OAuth grant — whether that credential still works,
264
+ using the connector's own `testCredential(s)` or `status()` hook. No downstream
265
+ tool is ever called. A check that finds the credential *rejected* flips the
266
+ connector to `auth_required` in `list_connectors({ probe: false })` and on
267
+ `/ui`, with the URL to open; a later success (or re-authorizing) flips it back
268
+ with no restart. A check that merely failed to complete — a timeout, a 502 from
269
+ the status endpoint — is reported but never decides the status: it learned
270
+ nothing about the credential.
271
+
272
+ Checks are triggered two ways and share one rate limit — at most one per
273
+ connector per 15 minutes by default, four in flight, 30 s each:
274
+
275
+ ```ts
276
+ // 1. Opportunistically, on authenticated traffic connecta already serves.
277
+ // On by default; nothing to wire. Disable with:
278
+ createConnecta({ connectors, credentialHealth: { onRequest: false } });
279
+
280
+ // 2. On a schedule you own — the core starts no timers, so this works the same
281
+ // on Workers (cron trigger) and Node.
282
+ async scheduled(_c, env, ctx) { ctx.waitUntil(build(env).checkCredentials()); }
283
+ setInterval(() => void connecta.checkCredentials(), 15 * 60_000).unref();
284
+ ```
285
+
286
+ Details: [docs §17](./docs/documentation.md#17-credential-health-proactive-liveness-checks).
287
+
248
288
  ## Payload-free tool activity
249
289
 
250
290
  Connecta can record which resolved downstream tools were actually invoked
@@ -281,7 +321,8 @@ a complete D1 implementation with keyset paging and a retention pass.
281
321
 
282
322
  ## Operator dashboard
283
323
 
284
- `GET /ui` is a read-only dashboard with no build step: connector health, tool
324
+ `GET /ui` is a read-only dashboard with no build step: connector health and the
325
+ last credential check, tool
285
326
  counts and descriptions with a client-side filter, downstream authorization
286
327
  links, the credential controls above, and an Activity tab when an activity store
287
328
  is configured. The shell is open because it carries no data; everything it shows
@@ -1,10 +1,17 @@
1
+ import { type ToolkitBindingOptions } from "../toolkits.js";
1
2
  import type { InboundAuth } from "../types.js";
3
+ export interface BearerTokenOptions extends ToolkitBindingOptions {
4
+ /** Stable identity for this credential, used on activity events. */
5
+ subjectId?: string;
6
+ }
2
7
  /**
3
8
  * Static bearer-token inbound auth. Constant-time compares the Bearer token
4
9
  * against `secret`. Checked BEFORE the Clerk gate in the server; a mismatch
5
10
  * falls through so a co-configured Clerk provider can still admit the request.
11
+ *
12
+ * `toolkits` binds the token to named toolkits (§16): one `bearerToken(...)` per
13
+ * team credential, each naming the view that team may open. Omit it and the
14
+ * token stays unbound — every declared toolkit plus the full registry.
6
15
  */
7
- export declare function bearerToken(secret: string, options?: {
8
- subjectId?: string;
9
- }): InboundAuth;
16
+ export declare function bearerToken(secret: string, options?: BearerTokenOptions): InboundAuth;
10
17
  //# sourceMappingURL=bearer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAiB3D;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GACnC,WAAW,CA4Bb"}
1
+ {"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAiB3D,MAAM,WAAW,kBAAmB,SAAQ,qBAAqB;IAC/D,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAiDb"}
@@ -1,3 +1,4 @@
1
+ import { resolveToolkitBinding, } from "../toolkits.js";
1
2
  const encoder = new TextEncoder();
2
3
  /** Constant-time byte comparison. Differing lengths still iterate to reduce leak. */
3
4
  function timingSafeEqual(a, b) {
@@ -17,11 +18,31 @@ function timingSafeEqual(a, b) {
17
18
  * Static bearer-token inbound auth. Constant-time compares the Bearer token
18
19
  * against `secret`. Checked BEFORE the Clerk gate in the server; a mismatch
19
20
  * falls through so a co-configured Clerk provider can still admit the request.
21
+ *
22
+ * `toolkits` binds the token to named toolkits (§16): one `bearerToken(...)` per
23
+ * team credential, each naming the view that team may open. Omit it and the
24
+ * token stays unbound — every declared toolkit plus the full registry.
20
25
  */
21
26
  export function bearerToken(secret, options = {}) {
22
27
  const secretBytes = encoder.encode(secret);
28
+ const toolkitBinding = resolveToolkitBinding(options.subjectId
29
+ ? `bearerToken (subjectId "${options.subjectId}")`
30
+ : "bearerToken", options);
31
+ if (toolkitBinding && !options.subjectId) {
32
+ // A bound token stands for one team, and both surfaces that report on it —
33
+ // the 403 refusal log (§16) and activity events (§15) — can only say
34
+ // "bearer" without a subjectId. With several bound tokens that makes an
35
+ // operator unable to tell which credential was refused, or whose call
36
+ // succeeded. console.warn (as the Clerk adapter does) because an adapter is
37
+ // constructed before `createConnecta` has a logger to hand it.
38
+ console.warn("[connecta] bearerToken is bound to toolkits " +
39
+ `(${toolkitBinding.toolkits.join(", ") || "unscoped only"}) but has no ` +
40
+ "subjectId: refusal logs and activity events cannot say which " +
41
+ "credential they came from. Pass { subjectId: \"<team>\" }.");
42
+ }
23
43
  return {
24
44
  kind: "bearer",
45
+ ...(toolkitBinding ? { toolkitBinding } : {}),
25
46
  authorize(request) {
26
47
  const header = request.headers.get("authorization") ?? "";
27
48
  const match = /^Bearer\s+(.+)$/i.exec(header);
@@ -1 +1 @@
1
- {"version":3,"file":"bearer.js","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"AAEA,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;AAElC,qFAAqF;AACrF,SAAS,eAAe,CAAC,CAAa,EAAE,CAAa;IACnD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,IAAI,CAAC,GAAG,CAAC,CAAC;QACV,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;QACvC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE;YAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3D,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACpD,OAAO,CAAC,KAAK,CAAC,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,MAAc,EACd,UAAkC,EAAE;IAEpC,MAAM,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC3C,OAAO;QACL,IAAI,EAAE,QAAQ;QACd,SAAS,CAAC,OAAO;YACf,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;YAC1D,MAAM,KAAK,GAAG,kBAAkB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC9C,IAAI,KAAK,IAAI,eAAe,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,EAAE,CAAC;gBACpE,OAAO;oBACL,EAAE,EAAE,IAAI;oBACR,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBAC/D,CAAC;YACJ,CAAC;YACD,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,QAAQ,EAAE,IAAI,QAAQ,CACpB,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,EACzC;oBACE,MAAM,EAAE,GAAG;oBACX,OAAO,EAAE;wBACP,cAAc,EAAE,kBAAkB;wBAClC,kBAAkB,EAAE,QAAQ;qBAC7B;iBACF,CACF;aACF,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"bearer.js","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"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"}
@@ -1,11 +1,24 @@
1
1
  import { createClerkClient } from "@clerk/backend";
2
+ import { type ToolkitBindingOptions } from "../toolkits.js";
2
3
  import type { InboundAuth } from "../types.js";
3
4
  type ClerkClient = ReturnType<typeof createClerkClient>;
4
- export interface ClerkAuthOptions {
5
+ export interface ClerkAuthOptions extends ToolkitBindingOptions {
5
6
  publishableKey: string;
6
7
  secretKey: string;
7
8
  /** Public base URL of this deployment. Defaults to the request origin. */
8
9
  publicUrl?: string;
10
+ /**
11
+ * Email domains this deployment admits, e.g. `["acme.com"]`. An
12
+ * authenticated user whose verified primary email is not on one of them is
13
+ * rejected exactly like a `gate` rejection. Matching is exact on the whole
14
+ * domain and case-insensitive: `acme.com` admits neither `evil-acme.com` nor
15
+ * `mail.acme.com` — spell a subdomain out to allow it. Entries must be ASCII
16
+ * (punycode for an internationalized domain) and are validated at
17
+ * construction. Absent ⇒ every authenticated user passes this check, as
18
+ * before the option existed. Governs Clerk sign-in only: a co-configured
19
+ * `bearerToken` has no email to read and is admitted without a domain check.
20
+ */
21
+ allowedDomains?: readonly string[];
9
22
  /** Optional allow-list hook. Return false to reject an authenticated user. */
10
23
  gate?: (userId: string, clerk: ClerkClient) => boolean | Promise<boolean>;
11
24
  /** Advertised scopes in protected-resource metadata. */
@@ -15,6 +28,18 @@ export interface ClerkAuthOptions {
15
28
  /** Optional hosted Account Portal sign-up URL for `/ui`. */
16
29
  signUpUrl?: string;
17
30
  }
31
+ /**
32
+ * Clerk inbound auth.
33
+ *
34
+ * `allowedDomains` and `gate` decide WHO is admitted (both must pass);
35
+ * `toolkits` decides WHICH view the admitted user gets.
36
+ *
37
+ * `toolkits` binds every user this provider admits to those toolkits (§16). For
38
+ * a per-team split, configure one `clerkAuth(...)` per team — the same keys, a
39
+ * `gate` naming that team's users, and that team's `toolkits`. The server tries
40
+ * providers in order and the first that admits the user supplies the binding, so
41
+ * a user one gate rejects falls through to the next.
42
+ */
18
43
  export declare function clerkAuth(opts: ClerkAuthOptions): InboundAuth;
19
44
  export {};
20
45
  //# sourceMappingURL=clerk.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../../src/auth/clerk.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAE3D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1E,wDAAwD;IACxD,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA6BD,wBAAgB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA8K7D"}
1
+ {"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../../src/auth/clerk.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAE3D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAExD,MAAM,WAAW,gBAAiB,SAAQ,qBAAqB;IAC7D,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1E,wDAAwD;IACxD,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA0ID;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA0O7D"}