@zackbart/connecta 0.24.3 → 0.25.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 (145) hide show
  1. package/AGENTS.md +58 -30
  2. package/CHANGELOG.md +317 -1
  3. package/README.md +5 -6
  4. package/dist/advertised-schema.js +53 -0
  5. package/dist/auth/downstream-oauth.js +490 -106
  6. package/dist/branding.d.ts +31 -2
  7. package/dist/branding.js +116 -8
  8. package/dist/call-admission.js +139 -113
  9. package/dist/catalog-service.js +141 -105
  10. package/dist/catalog.js +258 -33
  11. package/dist/concurrency.js +24 -23
  12. package/dist/connector-scope.js +6 -37
  13. package/dist/connectors/api.d.ts +3 -3
  14. package/dist/connectors/api.js +10 -2
  15. package/dist/connectors/guarded-fetch.js +34 -4
  16. package/dist/connectors/remote-mcp.d.ts +6 -5
  17. package/dist/connectors/remote-mcp.js +325 -245
  18. package/dist/credential-contract.d.ts +9 -0
  19. package/dist/credential-rules.js +2 -2
  20. package/dist/credentials.d.ts +6 -1
  21. package/dist/credentials.js +45 -3
  22. package/dist/errors.d.ts +11 -3
  23. package/dist/errors.js +2 -1
  24. package/dist/execute.js +363 -257
  25. package/dist/executor-admission.js +67 -50
  26. package/dist/executors/quickjs-runtime.js +4 -2
  27. package/dist/executors/quickjs.js +251 -210
  28. package/dist/fix-prompt.js +37 -0
  29. package/dist/index.d.ts +11 -2
  30. package/dist/index.js +63 -21
  31. package/dist/invocation.js +324 -285
  32. package/dist/meta-tools.js +304 -114
  33. package/dist/oauth-callback-outcome.js +94 -0
  34. package/dist/oauth-sealing.js +29 -0
  35. package/dist/operator-ui/fix-prompts.js +87 -0
  36. package/dist/operator-ui/generated.js +2 -2
  37. package/dist/operator-ui/model.d.ts +46 -1
  38. package/dist/operator-ui/setup-commands.js +53 -0
  39. package/dist/operator-ui/view.js +109 -2
  40. package/dist/program-source.js +205 -0
  41. package/dist/providers/cloudflare.d.ts +14 -2
  42. package/dist/providers/cloudflare.js +107 -16
  43. package/dist/providers/linear.d.ts +26 -4
  44. package/dist/providers/linear.js +19 -4
  45. package/dist/providers/mixpanel.d.ts +16 -3
  46. package/dist/providers/mixpanel.js +13 -2
  47. package/dist/providers/notion.d.ts +8 -1
  48. package/dist/providers/notion.js +85 -12
  49. package/dist/providers/revenuecat.d.ts +30 -4
  50. package/dist/providers/revenuecat.js +70 -5
  51. package/dist/providers/stripe.d.ts +10 -3
  52. package/dist/providers/stripe.js +30 -4
  53. package/dist/providers/vercel.js +12 -2
  54. package/dist/registry.d.ts +13 -5
  55. package/dist/registry.js +317 -258
  56. package/dist/routes/activity.js +120 -137
  57. package/dist/routes/credentials.js +174 -163
  58. package/dist/routes/mcp.js +129 -70
  59. package/dist/routes/oauth-management.js +60 -71
  60. package/dist/routes/oauth.js +44 -16
  61. package/dist/routes/operator.js +82 -0
  62. package/dist/routes/shared.d.ts +2 -0
  63. package/dist/routes/ui-data.js +210 -0
  64. package/dist/routes/ui.js +64 -32
  65. package/dist/runtime/admission.js +43 -0
  66. package/dist/runtime/call-admission.js +67 -0
  67. package/dist/runtime/connector-scope.js +72 -0
  68. package/dist/runtime/run.js +174 -0
  69. package/dist/runtime/services.js +130 -0
  70. package/dist/runtime/storage.js +66 -0
  71. package/dist/server.js +111 -89
  72. package/dist/skills.js +1 -1
  73. package/dist/storage/file.js +39 -2
  74. package/dist/storage/memory.js +28 -16
  75. package/dist/timeout.js +17 -47
  76. package/dist/types.d.ts +64 -7
  77. package/dist/ui.d.ts +16 -2
  78. package/dist/ui.js +68 -176
  79. package/dist/url-safety.js +157 -0
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/documentation/architecture.md +368 -182
  83. package/documentation/auth.md +277 -176
  84. package/documentation/code-mode.md +479 -329
  85. package/documentation/meta-tools.md +494 -417
  86. package/ethos.md +44 -47
  87. package/examples/worker/AGENTS.md +2 -1
  88. package/examples/worker/README.md +62 -14
  89. package/examples/worker/src/d1-storage.ts +116 -0
  90. package/examples/worker/src/index.ts +18 -21
  91. package/examples/worker/wrangler.jsonc +16 -4
  92. package/package.json +11 -4
  93. package/templates/node/.env.example +3 -3
  94. package/templates/node/AGENTS.md +5 -4
  95. package/templates/node/README.md +2 -1
  96. package/templates/node/package.json +1 -1
  97. package/templates/node/src/index.ts +23 -22
  98. package/dist/auth/downstream-oauth.d.ts +0 -200
  99. package/dist/catalog-fingerprint.d.ts +0 -19
  100. package/dist/catalog-limits.d.ts +0 -9
  101. package/dist/catalog-service.d.ts +0 -263
  102. package/dist/catalog.d.ts +0 -87
  103. package/dist/concurrency.d.ts +0 -3
  104. package/dist/connectors/guarded-fetch.d.ts +0 -92
  105. package/dist/credential-rules.d.ts +0 -85
  106. package/dist/execute.d.ts +0 -154
  107. package/dist/executor-result.d.ts +0 -10
  108. package/dist/executors/quickjs-child.d.ts +0 -1
  109. package/dist/executors/quickjs-protocol.d.ts +0 -67
  110. package/dist/favicon.d.ts +0 -1
  111. package/dist/identity.d.ts +0 -4
  112. package/dist/invocation.d.ts +0 -74
  113. package/dist/mcp-result.d.ts +0 -8
  114. package/dist/meta-tools.d.ts +0 -129
  115. package/dist/operator-ui/generated.d.ts +0 -2
  116. package/dist/operator-ui/view.d.ts +0 -151
  117. package/dist/result-shapes.d.ts +0 -13
  118. package/dist/retired-toolkits.d.ts +0 -2
  119. package/dist/routes/activity.d.ts +0 -2
  120. package/dist/routes/credentials.d.ts +0 -2
  121. package/dist/routes/mcp.d.ts +0 -10
  122. package/dist/routes/oauth-management.d.ts +0 -2
  123. package/dist/routes/oauth.d.ts +0 -2
  124. package/dist/routes/ui.d.ts +0 -2
  125. package/dist/server.d.ts +0 -9
  126. package/dist/skills.d.ts +0 -44
  127. package/dist/timeout.d.ts +0 -21
  128. package/dist/tool-safety.d.ts +0 -9
  129. package/documentation/call-admission.md +0 -158
  130. package/documentation/cloudflare.md +0 -471
  131. package/documentation/connector-guides.md +0 -176
  132. package/documentation/connectors.md +0 -431
  133. package/documentation/linear.md +0 -193
  134. package/documentation/mixpanel.md +0 -160
  135. package/documentation/notion.md +0 -308
  136. package/documentation/operations.md +0 -359
  137. package/documentation/operator-ui.md +0 -135
  138. package/documentation/optional-modules-upgrade.md +0 -243
  139. package/documentation/provider-conventions.md +0 -729
  140. package/documentation/request-admission.md +0 -204
  141. package/documentation/revenuecat.md +0 -305
  142. package/documentation/storage-and-credentials.md +0 -254
  143. package/documentation/stripe.md +0 -262
  144. package/documentation/upgrading.md +0 -768
  145. package/documentation/vercel.md +0 -241
@@ -0,0 +1,174 @@
1
+ // The one place an Effect becomes a Promise.
2
+ //
3
+ // Connecta's published surface is Promise-shaped and stays that way; Effect is
4
+ // an implementation detail behind it. Every edge where a fiber starts runs
5
+ // through this module, so the rules for that crossing live in one file instead
6
+ // of being re-derived at each call site — and test/purity.test.ts fails if any
7
+ // other file under src/ calls Effect.run*, runFork, forkDaemon, or
8
+ // ManagedRuntime.make.
9
+ //
10
+ // Two things the stock runners get wrong for this codebase:
11
+ //
12
+ // - The default MixedScheduler yields through setImmediate, which vitest's
13
+ // fake timers replace. A fiber that yields under vi.useFakeTimers() then
14
+ // waits for a timer tick nobody advances. Yielding through a microtask keeps
15
+ // fibers moving under fakes, and Workers never had setImmediate to begin
16
+ // with. The cost is that a fiber never yields to I/O, which is why CPU-heavy
17
+ // work stays out of Effect loops.
18
+ // - Effect.runPromise rejects an interrupted fiber with a generic "All fibers
19
+ // interrupted without error". Callers of withDeadline today see the abort
20
+ // reason the caller chose, and runEdge keeps it that way.
21
+ import { Cause, Context, Duration, Effect, Exit, ManagedRuntime, Scheduler, } from "effect";
22
+ /** Yield to the scheduler through the microtask queue, never a timer. */
23
+ function setMicrotask(task) {
24
+ let cancelled = false;
25
+ queueMicrotask(() => {
26
+ if (!cancelled)
27
+ task();
28
+ });
29
+ return () => {
30
+ cancelled = true;
31
+ };
32
+ }
33
+ /** The scheduler every edge-run fiber uses; see the header for why. */
34
+ export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
35
+ const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
36
+ function abortReason(signal) {
37
+ return signal?.reason ?? new DOMException("aborted", "AbortError");
38
+ }
39
+ /**
40
+ * Turn a failed Exit back into the value a Promise caller would have seen.
41
+ *
42
+ * An interrupt caused by the caller's signal wins, as the abort does in
43
+ * withDeadline's race. Otherwise the first typed failure is rethrown as is,
44
+ * then the first defect — never a wrapper, because callers check `instanceof`,
45
+ * `name`, and `message` on connecta's own error classes.
46
+ */
47
+ function rethrow(cause, signal) {
48
+ const reasons = cause.reasons;
49
+ if (signal?.aborted && reasons.some(Cause.isInterruptReason)) {
50
+ throw abortReason(signal);
51
+ }
52
+ const failure = reasons.find(Cause.isFailReason);
53
+ if (failure)
54
+ throw failure.error;
55
+ const defect = reasons.find(Cause.isDieReason);
56
+ if (defect)
57
+ throw defect.defect;
58
+ throw abortReason(signal);
59
+ }
60
+ const disposedRuntimes = new WeakSet();
61
+ /** Wrap a layer as an EdgeRuntime without building it. */
62
+ export function makeEdgeRuntime(layer) {
63
+ return ManagedRuntime.make(layer);
64
+ }
65
+ /**
66
+ * Release a runtime's layer resources. Idempotent, and never rejects: a
67
+ * finalizer that fails has nobody left to report to.
68
+ */
69
+ export async function disposeEdgeRuntime(runtime) {
70
+ if (disposedRuntimes.has(runtime))
71
+ return;
72
+ disposedRuntimes.add(runtime);
73
+ // Through runExit rather than runtime.dispose(), whose runner yields on
74
+ // Effect's default scheduler — the one vitest's fake timers freeze.
75
+ await runExit(runtime.disposeEffect);
76
+ }
77
+ /**
78
+ * The effect with the runtime's services provided, building them first when
79
+ * no run has yet. The build runs on the calling fiber's scheduler, so it is
80
+ * the microtask scheduler here, as everything else is.
81
+ */
82
+ function provideRuntime(effect, runtime) {
83
+ if (disposedRuntimes.has(runtime)) {
84
+ return Effect.die(new Error("This Connecta has been closed."));
85
+ }
86
+ const built = runtime.cachedContext;
87
+ if (built)
88
+ return Effect.provideContext(effect, built);
89
+ return Effect.flatMap(runtime.contextEffect, (context) => Effect.provideContext(effect, context));
90
+ }
91
+ export async function runEdge(effect, options = {}) {
92
+ const exit = await runExit(options.runtime
93
+ ? provideRuntime(effect, options.runtime)
94
+ // Without a runtime the overloads admit only an effect that requires
95
+ // nothing, so there is nothing to provide.
96
+ : effect, options.signal ? { signal: options.signal } : undefined);
97
+ if (Exit.isSuccess(exit))
98
+ return exit.value;
99
+ return rethrow(exit.cause, options.signal);
100
+ }
101
+ /**
102
+ * Fail with the signal's abort reason once it aborts; never succeed.
103
+ *
104
+ * Race it against work that should stop when the signal does. A signal
105
+ * aborted without a reason fails with an AbortError, as `fetch` would.
106
+ */
107
+ export function fromSignal(signal) {
108
+ return Effect.callback((resume) => {
109
+ // Remove the listener on every path, firing included. `once` already
110
+ // drops it from a real AbortSignal, but the cleanup below runs only on
111
+ // interruption, and a signal-shaped wrapper sees no removal otherwise.
112
+ const onAbort = () => {
113
+ signal.removeEventListener("abort", onAbort);
114
+ resume(Effect.fail(abortReason(signal)));
115
+ };
116
+ if (signal.aborted) {
117
+ onAbort();
118
+ return;
119
+ }
120
+ signal.addEventListener("abort", onAbort, { once: true });
121
+ return Effect.sync(() => signal.removeEventListener("abort", onAbort));
122
+ });
123
+ }
124
+ /**
125
+ * The Effect form of withDeadline (src/timeout.ts), with the same contract.
126
+ *
127
+ * The operation gets a signal of its own. The caller's signal is forwarded to
128
+ * it, and the deadline aborts it with the labelled `timeoutError` *before* the
129
+ * operation's fiber is interrupted, so work that honors the signal sees the
130
+ * same reason the caller does. Either abort fails the effect with that reason:
131
+ * the caller's, or `timeoutError`. The signal is always aborted on exit, as
132
+ * withDeadline's `finally` does.
133
+ *
134
+ * The error channel is `unknown` because an abort reason is whatever the
135
+ * caller chose.
136
+ */
137
+ export function withDeadlineEffect(operation, options) {
138
+ return Effect.suspend(() => {
139
+ const controller = new AbortController();
140
+ const forwardAbort = () => controller.abort(options.signal?.reason);
141
+ options.signal?.addEventListener("abort", forwardAbort, { once: true });
142
+ if (options.signal?.aborted)
143
+ forwardAbort();
144
+ // One race, not a race nested in a race: every contender is a forked
145
+ // fiber, and forks are most of what a deadline costs (P1-S02 measured
146
+ // about 2µs per raceFirst). Fork order is unchanged — operation, abort,
147
+ // timer — and the timer still aborts the signal before anything is
148
+ // interrupted, so the abort contender wins with the labelled reason.
149
+ const contenders = [
150
+ Effect.suspend(() => operation(controller.signal)),
151
+ fromSignal(controller.signal),
152
+ ];
153
+ if (options.timeoutMs !== undefined) {
154
+ contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
155
+ }
156
+ return Effect.raceAllFirst(contenders).pipe(Effect.ensuring(Effect.sync(() => {
157
+ controller.abort();
158
+ options.signal?.removeEventListener("abort", forwardAbort);
159
+ })));
160
+ });
161
+ }
162
+ /**
163
+ * The only sanctioned fire-and-forget.
164
+ *
165
+ * On Workers the work is handed to `ctx.waitUntil`, so the platform keeps the
166
+ * invocation alive until it settles rather than freezing it mid-flight. The
167
+ * returned promise never rejects: the outcome is the effect's own business,
168
+ * and an unhandled rejection from background work helps nobody.
169
+ */
170
+ export function detach(effect, ctx) {
171
+ const settled = runExit(effect).then(() => { }, () => { });
172
+ ctx?.waitUntil?.(settled);
173
+ return settled;
174
+ }
@@ -0,0 +1,130 @@
1
+ // The core's services, as Effect code sees them.
2
+ //
3
+ // createConnecta resolves storage, the vault, activity history, logging, and
4
+ // the rest of its configuration once, at construction. This module gives each
5
+ // of those a service key and assembles them into the layer behind that
6
+ // Connecta's runtime (an EdgeRuntime, src/runtime/run.ts), so an Effect
7
+ // program reads them from its context rather than from arguments threaded
8
+ // through every call. The shells — Registry, CatalogService, and the rest —
9
+ // stay constructible from plain arguments, as the tests construct them. The
10
+ // registry's storage programs read Storage and Logger too, but from their own
11
+ // partition rather than this runtime (see src/runtime/storage.ts for why).
12
+ //
13
+ // Every import from an optional module is type-only. An omitted activity
14
+ // module is a no-op recorder that builds no event and calls nothing, and the
15
+ // UI, vault, and activity implementations stay out of the root import graph
16
+ // (test/purity.test.ts).
17
+ import { Context, Effect, Layer } from "effect";
18
+ import { makeEdgeRuntime } from "./run.js";
19
+ /**
20
+ * The deployment's KVStorage: `config.storage`, or the memoryStorage() that
21
+ * stands in for it. `compareAndSet` is present exactly when the adapter
22
+ * implements it, so a caller that needs atomicity checks for it here.
23
+ */
24
+ export class Storage extends Context.Service()("connecta/Storage") {
25
+ }
26
+ /**
27
+ * The credential vault, or undefined when the deployment configures none —
28
+ * a required service whose value may be absent, not a default, so a program
29
+ * run without it provided fails to typecheck instead of finding no vault.
30
+ */
31
+ export class Vault extends Context.Service()("connecta/Vault") {
32
+ }
33
+ /**
34
+ * Connecta's diagnostic logger, already resolved: the configured Logger, a
35
+ * no-op for `"silent"`, or console output prefixed `[connecta]`. Effect's
36
+ * own logger is never used (test/purity.test.ts), because it honors neither
37
+ * `"silent"` nor the line format a deployment greps for.
38
+ */
39
+ export class Logger extends Context.Service()("connecta/Logger") {
40
+ }
41
+ /** Payload-free activity history, or a recorder that records nothing. */
42
+ export class ActivityRecorder extends Context.Service()("connecta/ActivityRecorder") {
43
+ }
44
+ /** What createConnecta resolved at construction. */
45
+ export class ResolvedConfig extends Context.Service()("connecta/ResolvedConfig") {
46
+ }
47
+ /**
48
+ * The request's hook for work that may outlive its response (a Worker's
49
+ * `ctx.waitUntil`). Per request, not per Connecta, so it is never part of the
50
+ * runtime: a request provides it with `Effect.provideService`, and a program
51
+ * outside any request sees undefined and keeps its work in the foreground.
52
+ */
53
+ export const DeferredWork = Context.Reference("connecta/DeferredWork", { defaultValue: () => undefined });
54
+ /**
55
+ * Resolve `ConnectaConfig.logger`. `"silent"` discards every line; an omitted
56
+ * logger writes to the console with the `[connecta]` prefix.
57
+ */
58
+ export function resolveLogger(logger) {
59
+ if (logger === "silent") {
60
+ return { debug() { }, info() { }, warn() { }, error() { } };
61
+ }
62
+ return logger ?? {
63
+ debug: (...a) => console.debug("[connecta]", ...a),
64
+ info: (...a) => console.info("[connecta]", ...a),
65
+ warn: (...a) => console.warn("[connecta]", ...a),
66
+ error: (...a) => console.error("[connecta]", ...a),
67
+ };
68
+ }
69
+ const NO_ACTIVITY = {
70
+ enabled: false,
71
+ recordTool: () => Effect.void,
72
+ recordDrift: () => Effect.void,
73
+ };
74
+ function activityRecorder(module, serverInfo, logger) {
75
+ const deployment = module.deploymentId !== undefined
76
+ ? { deploymentId: module.deploymentId }
77
+ : {};
78
+ // A custom module's recorder that throws is logged like a store that
79
+ // throws: activity never changes the outcome of the work it describes.
80
+ const bestEffort = (record) => Effect.sync(() => {
81
+ try {
82
+ record();
83
+ }
84
+ catch (error) {
85
+ logger.warn("[connecta] activity record failed", error);
86
+ }
87
+ });
88
+ return {
89
+ enabled: true,
90
+ recordTool: (request, input) => DeferredWork.use((defer) => bestEffort(() => module.recordTool({
91
+ sink: module.store,
92
+ recordTool: module.recordTool,
93
+ actor: request.actor,
94
+ requestId: request.requestId,
95
+ serverInfo,
96
+ ...deployment,
97
+ ...(defer ? { defer } : {}),
98
+ logger,
99
+ }, input))),
100
+ recordDrift: (input) => bestEffort(() => module.recordDrift({ sink: module.store, serverInfo, ...deployment, logger }, input)),
101
+ };
102
+ }
103
+ /**
104
+ * The layer behind a Connecta's runtime. Every service is a value resolved
105
+ * before this is called, so building the layer only assembles a context: it
106
+ * starts no fiber and touches no storage.
107
+ */
108
+ function coreServices(options) {
109
+ const activity = options.activity
110
+ ? activityRecorder(options.activity, options.config.serverInfo, options.logger)
111
+ : NO_ACTIVITY;
112
+ return Layer.succeedContext(Context.make(Storage, options.storage).pipe(Context.add(Vault, options.vault), Context.add(Logger, options.logger), Context.add(ActivityRecorder, activity), Context.add(ResolvedConfig, options.config)));
113
+ }
114
+ // Keyed by the root Registry: it is the per-Connecta object every internal
115
+ // caller already holds, and a Registry built directly (as the tests build
116
+ // them) simply has no runtime.
117
+ const runtimes = new WeakMap();
118
+ /**
119
+ * Create the runtime for the Connecta that owns `registry`. Runs nothing:
120
+ * the layer is built by the first run that needs it (see EdgeRuntime).
121
+ */
122
+ export function createCoreRuntime(registry, options) {
123
+ const runtime = makeEdgeRuntime(coreServices(options));
124
+ runtimes.set(registry, runtime);
125
+ return runtime;
126
+ }
127
+ /** The runtime createConnecta made for this root registry, if it made one. */
128
+ export function coreRuntime(registry) {
129
+ return runtimes.get(registry);
130
+ }
@@ -0,0 +1,66 @@
1
+ // The Storage service, as the registry's programs use it.
2
+ //
3
+ // KVStorage stays Promise-shaped: it is what a deployment author implements,
4
+ // and nobody should need Effect to write a storage adapter. The operations
5
+ // below lift one call each into an effect that reads the adapter from the
6
+ // Storage service and fails with whatever the adapter threw, unwrapped, so a
7
+ // warning built from it reads exactly as it did.
8
+ //
9
+ // Which Storage a program sees is the registry's decision, not the runtime's.
10
+ // The Connecta runtime (src/runtime/services.ts) is keyed by the root
11
+ // Registry, and that is the wrong key here: a personal registry's storage is
12
+ // the root's namespaced to `principal:<key>:`, so handing its programs the
13
+ // runtime's Storage would read and write another partition. Each Registry —
14
+ // root, personal, or one a test built from plain arguments — therefore
15
+ // provides its own storage and logger to the programs it runs
16
+ // (`runOnPartition`). For the root registry those are the very values the
17
+ // runtime holds, because createConnecta passes the same two objects to both.
18
+ // A scoped view holds no storage of its own: it reaches Storage through the
19
+ // registry it delegates to, and the result stash is always the root's,
20
+ // because its capacity is runtime-wide.
21
+ import { Context, Effect } from "effect";
22
+ import { runEdge } from "./run.js";
23
+ import { Logger, Storage } from "./services.js";
24
+ /** Read one key; null when absent. */
25
+ export function storageGet(key) {
26
+ return Storage.use((storage) => Effect.tryPromise({
27
+ // Promise.resolve keeps an adapter that answers synchronously working,
28
+ // as `await` did; a synchronous throw is caught either way.
29
+ try: () => Promise.resolve(storage.get(key)),
30
+ catch: (error) => error,
31
+ }));
32
+ }
33
+ /** Write one key, with the adapter's optional TTL. */
34
+ export function storageSet(key, value, options) {
35
+ return Storage.use((storage) => Effect.tryPromise({
36
+ try: () => Promise.resolve(storage.set(key, value, options)),
37
+ catch: (error) => error,
38
+ }));
39
+ }
40
+ /** Delete one key. */
41
+ export function storageDelete(key) {
42
+ return Storage.use((storage) => Effect.tryPromise({
43
+ try: () => Promise.resolve(storage.delete(key)),
44
+ catch: (error) => error,
45
+ }));
46
+ }
47
+ // One context per partition object, built on first use. A registry's options
48
+ // object lives exactly as long as the registry, so the WeakMap never holds a
49
+ // partition past its owner.
50
+ const partitions = new WeakMap();
51
+ function partitionContext(partition) {
52
+ let context = partitions.get(partition);
53
+ if (!context) {
54
+ context = Context.make(Storage, partition.storage).pipe(Context.add(Logger, partition.logger));
55
+ partitions.set(partition, context);
56
+ }
57
+ return context;
58
+ }
59
+ /**
60
+ * Run a registry program against the registry's own storage and logger, at a
61
+ * Promise boundary. Resolves and rejects as runEdge does: the original error,
62
+ * never a wrapper.
63
+ */
64
+ export function runOnPartition(effect, partition) {
65
+ return runEdge(Effect.provideContext(effect, partitionContext(partition)));
66
+ }
package/dist/server.js CHANGED
@@ -1,18 +1,119 @@
1
+ import { Effect } from "effect";
1
2
  import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
2
3
  import { isAdmittingExecutor } from "./executor-admission.js";
3
4
  import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
4
5
  import { routeOAuthCallback, } from "./routes/oauth.js";
6
+ import { runEdge } from "./runtime/run.js";
5
7
  import { withSecurityHeaders, } from "./routes/shared.js";
6
8
  /**
7
9
  * Build the Web-standard fetch handler.
8
10
  *
9
11
  * Route ordering is the contract: private mutation routes precede wildcard
10
12
  * OPTIONS, and the security wrapper is applied to every response, including 404s.
13
+ *
14
+ * Each request is one fiber tied to `request.signal`: when its caller leaves,
15
+ * the request stops waiting on whatever it was waiting on, and what it holds
16
+ * is released (routes/mcp.ts). It runs on no Connecta runtime, because
17
+ * `/health` and a closed deployment's 503 must keep answering after
18
+ * `close()` has disposed that runtime.
11
19
  */
12
20
  export function createFetchHandler(opts) {
13
21
  const { auth, publicUrl, registry } = opts;
14
22
  const routeMcp = createMcpRoute(opts);
15
- return async function fetch(request, runtimeContext) {
23
+ // The first provider's metadata answer for this request, if any has one.
24
+ const metadata = (request, baseUrl) => Effect.gen(function* () {
25
+ for (const provider of auth) {
26
+ const handleMetadata = provider.handleMetadata?.bind(provider);
27
+ if (handleMetadata) {
28
+ const response = yield* Effect.promise(async () => handleMetadata(request, baseUrl));
29
+ if (response)
30
+ return response;
31
+ }
32
+ }
33
+ return null;
34
+ });
35
+ const health = async () => {
36
+ // The executor is required, so code admission always has a shape to
37
+ // report: either the executor's own pool or the fallback controller
38
+ // wrapped around it at construction.
39
+ const codeAdmission = isAdmittingExecutor(opts.executor)
40
+ ? opts.executor.admissionSnapshot?.()
41
+ : undefined;
42
+ return Response.json({
43
+ status: "ok",
44
+ connectors: registry.listConnectors().length,
45
+ server: opts.serverInfo,
46
+ // Which sandbox, not how it is tuned: `connecta doctor` reports the
47
+ // executor it just exercised rather than assuming one, and a
48
+ // deployment whose executor identifies as nothing omits the key
49
+ // instead of inviting a guess (#368).
50
+ ...(opts.executorName !== undefined
51
+ ? { executor: { name: opts.executorName } }
52
+ : {}),
53
+ // Counts only, from refreshes that already happened — the endpoint
54
+ // asks no downstream anything, and `connecta doctor` reads it to
55
+ // report a stale allowlist without a probe of its own (#343).
56
+ // Stable 64-bit hashes preserve that shape without publishing ids.
57
+ catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
58
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
59
+ const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
60
+ return [key, report];
61
+ }))),
62
+ admission: {
63
+ policy: "global-fifo",
64
+ requests: opts.requestAdmission.snapshot(),
65
+ code: codeAdmission ?? { managedByExecutor: true },
66
+ downstreamCalls: {
67
+ policy: "connector-partitioned-per-runtime",
68
+ aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
69
+ },
70
+ reservedRoutes: [
71
+ "/health",
72
+ ...(opts.ui?.reservedPaths ?? []),
73
+ ...(opts.ui && opts.activity?.list ? ["/activity"] : []),
74
+ ],
75
+ },
76
+ ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
77
+ });
78
+ };
79
+ const route = (context) => Effect.gen(function* () {
80
+ const { request, path, baseUrl } = context;
81
+ // Private mutations own OPTIONS so they never inherit wildcard CORS.
82
+ const ui = opts.ui;
83
+ if (ui) {
84
+ const uiResponse = yield* Effect.promise(() => ui.handle(context));
85
+ if (uiResponse)
86
+ return uiResponse;
87
+ }
88
+ if (request.method === "OPTIONS") {
89
+ const preflight = yield* routeMcp.handle(context);
90
+ if (preflight)
91
+ return preflight;
92
+ const response = yield* metadata(request, baseUrl);
93
+ if (response)
94
+ return response;
95
+ return new Response(null, {
96
+ status: 204,
97
+ headers: MCP_CORS_HEADERS,
98
+ });
99
+ }
100
+ if (path.startsWith("/.well-known/")) {
101
+ const response = yield* metadata(request, baseUrl);
102
+ if (response)
103
+ return response;
104
+ return new Response("Not Found", { status: 404 });
105
+ }
106
+ if (path === "/health")
107
+ return yield* Effect.promise(health);
108
+ const oauthCallback = yield* routeOAuthCallback(context);
109
+ if (oauthCallback)
110
+ return oauthCallback;
111
+ const mcp = yield* routeMcp.handle(context);
112
+ if (mcp)
113
+ return mcp;
114
+ return new Response("Not Found", { status: 404 });
115
+ });
116
+ const serve = (request, runtimeContext) => Effect.suspend(() => {
16
117
  const url = new URL(request.url);
17
118
  const baseUrl = publicUrl ?? url.origin;
18
119
  const path = url.pathname;
@@ -20,8 +121,9 @@ export function createFetchHandler(opts) {
20
121
  ? runtimeContext.waitUntil.bind(runtimeContext)
21
122
  : undefined;
22
123
  const originRefusal = routeMcp.rejectOrigin(request);
23
- if (originRefusal)
24
- return withSecurityHeaders(originRefusal, url, path);
124
+ if (originRefusal) {
125
+ return Effect.succeed(withSecurityHeaders(originRefusal, url, path));
126
+ }
25
127
  // Container and orchestrator probes reach /health over plain HTTP on
26
128
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
27
129
  // the public origin would make an internal liveness check depend on
@@ -43,10 +145,10 @@ export function createFetchHandler(opts) {
43
145
  target.pathname = path === "/ui" ? "/" : url.pathname;
44
146
  target.search = url.search;
45
147
  target.hash = "";
46
- return withSecurityHeaders(new Response(null, {
148
+ return Effect.succeed(withSecurityHeaders(new Response(null, {
47
149
  status: 308,
48
150
  headers: { Location: target.toString() },
49
- }), url, path);
151
+ }), url, path));
50
152
  }
51
153
  const context = {
52
154
  request,
@@ -57,89 +159,9 @@ export function createFetchHandler(opts) {
57
159
  defer,
58
160
  runtimeContext,
59
161
  };
60
- const route = async () => {
61
- // Private mutations own OPTIONS so they never inherit wildcard CORS.
62
- const uiResponse = await opts.ui?.handle(context);
63
- if (uiResponse)
64
- return uiResponse;
65
- if (request.method === "OPTIONS") {
66
- const preflight = await routeMcp.handle(context);
67
- if (preflight)
68
- return preflight;
69
- for (const provider of auth) {
70
- if (provider.handleMetadata) {
71
- const response = await provider.handleMetadata(request, baseUrl);
72
- if (response)
73
- return response;
74
- }
75
- }
76
- return new Response(null, {
77
- status: 204,
78
- headers: MCP_CORS_HEADERS,
79
- });
80
- }
81
- if (path.startsWith("/.well-known/")) {
82
- for (const provider of auth) {
83
- if (provider.handleMetadata) {
84
- const response = await provider.handleMetadata(request, baseUrl);
85
- if (response)
86
- return response;
87
- }
88
- }
89
- return new Response("Not Found", { status: 404 });
90
- }
91
- if (path === "/health") {
92
- // The executor is required, so code admission always has a shape to
93
- // report: either the executor's own pool or the fallback controller
94
- // wrapped around it at construction.
95
- const codeAdmission = isAdmittingExecutor(opts.executor)
96
- ? opts.executor.admissionSnapshot?.()
97
- : undefined;
98
- return Response.json({
99
- status: "ok",
100
- connectors: registry.listConnectors().length,
101
- server: opts.serverInfo,
102
- // Which sandbox, not how it is tuned: `connecta doctor` reports the
103
- // executor it just exercised rather than assuming one, and a
104
- // deployment whose executor identifies as nothing omits the key
105
- // instead of inviting a guess (#368).
106
- ...(opts.executorName !== undefined
107
- ? { executor: { name: opts.executorName } }
108
- : {}),
109
- // Counts only, from refreshes that already happened — the endpoint
110
- // asks no downstream anything, and `connecta doctor` reads it to
111
- // report a stale allowlist without a probe of its own (#343).
112
- // Stable 64-bit hashes preserve that shape without publishing ids.
113
- catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
114
- const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
115
- const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
116
- return [key, report];
117
- }))),
118
- admission: {
119
- policy: "global-fifo",
120
- requests: opts.requestAdmission.snapshot(),
121
- code: codeAdmission ?? { managedByExecutor: true },
122
- downstreamCalls: {
123
- policy: "connector-partitioned-per-runtime",
124
- aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
125
- },
126
- reservedRoutes: [
127
- "/health",
128
- ...(opts.ui?.reservedPaths ?? []),
129
- ...(opts.ui && opts.activity?.list ? ["/activity"] : []),
130
- ],
131
- },
132
- ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
133
- });
134
- }
135
- const oauthCallback = await routeOAuthCallback(context);
136
- if (oauthCallback)
137
- return oauthCallback;
138
- const mcp = await routeMcp.handle(context);
139
- if (mcp)
140
- return mcp;
141
- return new Response("Not Found", { status: 404 });
142
- };
143
- return withSecurityHeaders(await route(), url, path);
162
+ return Effect.map(route(context), (response) => withSecurityHeaders(response, url, path));
163
+ });
164
+ return function fetch(request, runtimeContext) {
165
+ return runEdge(serve(request, runtimeContext), { signal: request.signal });
144
166
  };
145
167
  }
package/dist/skills.js CHANGED
@@ -47,7 +47,7 @@ Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`detail
47
47
 
48
48
  For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. Every call makes one attempt; use the returned error classification and retry hint to decide whether to reissue. \`diagnostics: true\` adds timing.
49
49
 
50
- \`get_result({ id, offset?, maxBytes? })\` returns \`{ text, offset, nextOffset?, totalBytes }\` for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1 and defaults to the deployment cap; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the response reports the served offset. Follow \`nextOffset\` to reassemble pages. An unknown or expired id is an error.
50
+ \`get_result({ id, offset?, maxBytes? })\` returns a one-line JSON header \`{ resultId, offset, bytes, totalBytes, hasMore, nextAction? }\`, a newline, and then the page as raw text, for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1, defaults to the result's cap, and is clamped to it; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the header reports the served offset. Follow \`nextAction\` until \`hasMore\` is false to reassemble pages. An unknown or expired id is an error.
51
51
 
52
52
  Limits: 20 host calls per run and a 15-second deadline per host call.
53
53
 
@@ -259,8 +259,9 @@ export function fileStorage(path, opts = {}) {
259
259
  throw new Error(`[connecta] state file ${path} is closed.`);
260
260
  };
261
261
  const logger = opts.logger ?? console;
262
- // The state file holds downstream OAuth access/refresh tokens in cleartext,
263
- // so keep it owner-only. Repair is best-effort: chmod is a no-op or throws on
262
+ // With a sealing vault, downstream OAuth tokens here are ciphertext; without
263
+ // one they are cleartext, and OAuth flow state is plaintext either way. Keep
264
+ // the file owner-only regardless. Repair is best-effort: chmod is a no-op or throws on
264
265
  // non-POSIX filesystems, and a loose mode must never keep the store from
265
266
  // starting.
266
267
  const tighten = () => {
@@ -377,5 +378,41 @@ export function fileStorage(path, opts = {}) {
377
378
  .filter((key) => Boolean(fresh(key)) && key.startsWith(prefix))
378
379
  .sort();
379
380
  },
381
+ // Atomic twice over: the lock makes this process the only writer of the
382
+ // file, and nothing between the comparison and the persisted rename
383
+ // yields, so no other call on this store can interleave. A write that
384
+ // cannot be persisted is rolled back so memory never claims a value the
385
+ // file does not hold.
386
+ async compareAndSet(key, expected, next, opts) {
387
+ assertOpen();
388
+ lock.assertHeld();
389
+ const previous = fresh(key);
390
+ if ((previous?.value ?? null) !== expected)
391
+ return false;
392
+ if (next === null) {
393
+ if (!previous)
394
+ return true;
395
+ delete data[key];
396
+ }
397
+ else {
398
+ data[key] = {
399
+ value: next,
400
+ ...(opts?.ttlSeconds
401
+ ? { exp: Date.now() + opts.ttlSeconds * 1000 }
402
+ : {}),
403
+ };
404
+ }
405
+ try {
406
+ persist();
407
+ }
408
+ catch (error) {
409
+ if (previous)
410
+ data[key] = previous;
411
+ else
412
+ delete data[key];
413
+ throw error;
414
+ }
415
+ return true;
416
+ },
380
417
  };
381
418
  }