@swirls/looms 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/dist/actor/cell.d.ts +21 -2
- package/dist/actor/cell.js +15 -3
- package/dist/actor/host.d.ts +10 -2
- package/dist/actor/host.js +27 -4
- package/dist/actor/index.d.ts +2 -1
- package/dist/actor/index.js +1 -1
- package/dist/actor/routing.d.ts +3 -0
- package/dist/actor/routing.js +13 -2
- package/dist/agent/debugger/agent-chat.js +3 -30
- package/dist/agent/debugger/streaming-text.d.ts +6 -0
- package/dist/agent/debugger/streaming-text.js +33 -0
- package/dist/agent/definitions-store.d.ts +1 -1
- package/dist/agent/definitions-store.js +1 -1
- package/dist/agent/definitions.d.ts +48 -0
- package/dist/agent/definitions.js +44 -0
- package/dist/agent/effects.d.ts +184 -0
- package/dist/agent/effects.js +172 -40
- package/dist/agent/events.d.ts +89 -0
- package/dist/agent/events.js +25 -0
- package/dist/agent/module.d.ts +819 -407
- package/dist/agent/module.js +5 -1
- package/dist/agent/scope.d.ts +88 -0
- package/dist/agent/threads.js +39 -3
- package/dist/agent/types.d.ts +4 -0
- package/dist/approval/effects.d.ts +40 -0
- package/dist/approval/module.d.ts +80 -0
- package/dist/client/client.d.ts +468 -119
- package/dist/client/client.js +214 -21
- package/dist/client/index.d.ts +1 -1
- package/dist/client/sse.d.ts +1 -1
- package/dist/client/sse.js +2 -2
- package/dist/cloudflare/durable-object.d.ts +66 -2
- package/dist/cloudflare/durable-object.js +96 -1
- package/dist/cloudflare/event-store.d.ts +7 -3
- package/dist/cloudflare/event-store.js +16 -3
- package/dist/cloudflare/index.d.ts +7 -1
- package/dist/cloudflare/index.js +7 -1
- package/dist/cloudflare/memory-queue.d.ts +18 -0
- package/dist/cloudflare/memory-queue.js +53 -0
- package/dist/cloudflare/queue-consumer.d.ts +52 -0
- package/dist/cloudflare/queue-consumer.js +102 -0
- package/dist/cloudflare/queue-message.d.ts +31 -0
- package/dist/cloudflare/queue-message.js +33 -0
- package/dist/cloudflare/queue-pull.d.ts +62 -0
- package/dist/cloudflare/queue-pull.js +130 -0
- package/dist/cloudflare/queue-worker.d.ts +39 -0
- package/dist/cloudflare/queue-worker.js +62 -0
- package/dist/cloudflare/r2-blob-store.d.ts +33 -0
- package/dist/cloudflare/r2-blob-store.js +60 -0
- package/dist/cloudflare/router.d.ts +4 -1
- package/dist/cloudflare/router.js +8 -3
- package/dist/cloudflare/websocket.d.ts +27 -0
- package/dist/cloudflare/websocket.js +215 -0
- package/dist/core/attempts.d.ts +8 -0
- package/dist/core/attempts.js +27 -0
- package/dist/core/blob-offload.d.ts +21 -0
- package/dist/core/blob-offload.js +143 -0
- package/dist/core/blob-store.d.ts +45 -0
- package/dist/core/blob-store.js +164 -0
- package/dist/core/blobs.d.ts +37 -0
- package/dist/core/blobs.js +160 -0
- package/dist/core/definition-resolver.d.ts +40 -0
- package/dist/core/definition-resolver.js +129 -0
- package/dist/core/definition-store.d.ts +6 -0
- package/dist/core/definition-store.js +37 -1
- package/dist/core/effects.d.ts +86 -3
- package/dist/core/effects.js +99 -3
- package/dist/core/envelope.d.ts +9 -0
- package/dist/core/envelope.js +4 -0
- package/dist/core/event-codec.js +3 -0
- package/dist/core/fold.js +83 -1
- package/dist/core/ids.d.ts +2 -0
- package/dist/core/ids.js +4 -0
- package/dist/core/index.d.ts +10 -0
- package/dist/core/index.js +9 -0
- package/dist/core/launcher.d.ts +97 -0
- package/dist/core/launcher.js +67 -0
- package/dist/core/module-scope.d.ts +7 -3
- package/dist/core/module-scope.js +3 -0
- package/dist/core/module.d.ts +10 -0
- package/dist/core/projection.d.ts +5 -4
- package/dist/core/projection.js +4 -0
- package/dist/core/protocol.d.ts +36 -1
- package/dist/core/protocol.js +35 -0
- package/dist/core/published.d.ts +2 -0
- package/dist/core/published.js +4 -1
- package/dist/{react → core}/register.d.ts +9 -8
- package/dist/core/replay.d.ts +96 -0
- package/dist/core/run-context.d.ts +33 -0
- package/dist/core/run-context.js +23 -0
- package/dist/core/sqlite-store.js +57 -5
- package/dist/core/state.d.ts +60 -0
- package/dist/core/state.js +19 -0
- package/dist/core/store.d.ts +6 -0
- package/dist/core/store.js +7 -0
- package/dist/core/subscription.d.ts +97 -0
- package/dist/core/subscription.js +224 -0
- package/dist/core/thread.d.ts +7 -0
- package/dist/core/thread.js +1 -0
- package/dist/core/trace.d.ts +21 -0
- package/dist/core/trace.js +45 -0
- package/dist/core/worker-callback.d.ts +25 -1
- package/dist/debugger/app/assets/{index-CgDzWJg7.css → index-B7GdT1UA.css} +1 -1
- package/dist/debugger/app/assets/index-C31SnCuN.js +22 -0
- package/dist/debugger/app/assets/routes-DPA3O5AF.js +47 -0
- package/dist/debugger/app/index.html +2 -2
- package/dist/debugger/components/event-inspector.d.ts +3 -1
- package/dist/debugger/components/event-inspector.js +25 -4
- package/dist/debugger/components/event-stream.d.ts +4 -1
- package/dist/debugger/components/event-stream.js +2 -2
- package/dist/debugger/components/run-tree.d.ts +5 -2
- package/dist/debugger/components/run-tree.js +10 -4
- package/dist/debugger/context.d.ts +8 -1
- package/dist/debugger/context.js +15 -3
- package/dist/debugger/events/protocol-summarize.js +18 -3
- package/dist/debugger/index.d.ts +1 -1
- package/dist/debugger/index.js +1 -1
- package/dist/debugger/shell/debugger-shell.js +1 -1
- package/dist/debugger/shell/events-pane.js +4 -3
- package/dist/debugger/styles.css +1 -0
- package/dist/evaluator/definitions-store.d.ts +3 -4
- package/dist/evaluator/definitions-store.js +3 -3
- package/dist/evaluator/definitions.d.ts +4 -1
- package/dist/evaluator/definitions.js +5 -2
- package/dist/evaluator/effects.d.ts +41 -0
- package/dist/evaluator/effects.js +6 -2
- package/dist/evaluator/module.d.ts +84 -0
- package/dist/evaluator/module.js +7 -3
- package/dist/evaluator/threads.d.ts +2 -0
- package/dist/evaluator/threads.js +4 -0
- package/dist/projectors/event-log.d.ts +87 -0
- package/dist/projectors/event-log.js +396 -0
- package/dist/projectors/index-model.d.ts +12 -0
- package/dist/projectors/index-model.js +12 -3
- package/dist/projectors/memory.js +7 -0
- package/dist/projectors/postgres.d.ts +1 -0
- package/dist/projectors/postgres.js +32 -15
- package/dist/projectors/schema.d.ts +5 -0
- package/dist/projectors/schema.js +26 -1
- package/dist/projectors/sqlite.js +47 -19
- package/dist/projectors/with-projectors.d.ts +7 -1
- package/dist/projectors/with-projectors.js +13 -3
- package/dist/react/derived.d.ts +26 -8
- package/dist/react/derived.js +23 -26
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +2 -2
- package/dist/react/materialize.js +5 -1
- package/dist/react/react.d.ts +16 -9
- package/dist/react/react.js +47 -23
- package/dist/react/store.d.ts +14 -6
- package/dist/react/store.js +104 -12
- package/dist/react/tables.d.ts +4 -1
- package/dist/runtime/cancellation.d.ts +2 -1
- package/dist/runtime/cancellation.js +35 -2
- package/dist/runtime/child-run.d.ts +10 -0
- package/dist/runtime/child-run.js +58 -0
- package/dist/runtime/deadline-transition-planner.js +11 -2
- package/dist/runtime/dispatch-effect.d.ts +2 -2
- package/dist/runtime/dispatch-effect.js +43 -19
- package/dist/runtime/errors.d.ts +14 -1
- package/dist/runtime/errors.js +10 -0
- package/dist/runtime/filesystem-blob-store.d.ts +6 -0
- package/dist/runtime/filesystem-blob-store.js +59 -0
- package/dist/runtime/helpers.d.ts +15 -6
- package/dist/runtime/helpers.js +46 -6
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +3 -1
- package/dist/runtime/local-effect-executor.d.ts +11 -2
- package/dist/runtime/local-effect-executor.js +80 -4
- package/dist/runtime/looms.d.ts +38 -4
- package/dist/runtime/looms.js +17 -3
- package/dist/runtime/repository.d.ts +5 -4
- package/dist/runtime/repository.js +5 -2
- package/dist/runtime/retry-policy.d.ts +5 -0
- package/dist/runtime/retry-policy.js +10 -2
- package/dist/runtime/run-start.d.ts +1 -1
- package/dist/runtime/run-start.js +10 -0
- package/dist/runtime/runtime.d.ts +2 -2
- package/dist/runtime/runtime.js +191 -68
- package/dist/runtime/server/auth.d.ts +3 -0
- package/dist/runtime/server/fetch-handler.d.ts +2 -2
- package/dist/runtime/server/fetch-handler.js +35 -4
- package/dist/runtime/server/grant.d.ts +49 -0
- package/dist/runtime/server/grant.js +177 -0
- package/dist/runtime/server/routes/effects.js +47 -5
- package/dist/runtime/server/routes/runs.js +6 -1
- package/dist/runtime/server/run-route.d.ts +18 -0
- package/dist/runtime/server/run-route.js +26 -0
- package/dist/runtime/server.d.ts +3 -1
- package/dist/runtime/server.js +3 -1
- package/dist/runtime/sse.js +26 -4
- package/dist/runtime/subscription.d.ts +8 -0
- package/dist/runtime/subscription.js +23 -0
- package/dist/runtime/types.d.ts +87 -8
- package/dist/runtime/wake-engine.d.ts +4 -2
- package/dist/runtime/wake-engine.js +57 -6
- package/dist/runtime/worker-callbacks.d.ts +12 -2
- package/dist/runtime/worker-callbacks.js +102 -72
- package/dist/runtime/worker-effect-dispatch.d.ts +14 -5
- package/dist/runtime/worker-effect-dispatch.js +84 -18
- package/dist/schedule/debugger/index.d.ts +4 -0
- package/dist/schedule/debugger/index.js +11 -0
- package/dist/schedule/debugger/schedule-view.d.ts +3 -0
- package/dist/schedule/debugger/schedule-view.js +68 -0
- package/dist/schedule/debugger/summarize.d.ts +2 -0
- package/dist/schedule/debugger/summarize.js +43 -0
- package/dist/schedule/definitions-store.d.ts +10 -0
- package/dist/schedule/definitions-store.js +8 -0
- package/dist/schedule/definitions.d.ts +57 -0
- package/dist/schedule/definitions.js +30 -0
- package/dist/schedule/effects.d.ts +756 -0
- package/dist/schedule/effects.js +244 -0
- package/dist/schedule/ensure.d.ts +55 -0
- package/dist/schedule/ensure.js +109 -0
- package/dist/schedule/events.d.ts +116 -0
- package/dist/schedule/events.js +49 -0
- package/dist/schedule/index.d.ts +11 -0
- package/dist/schedule/index.js +11 -0
- package/dist/schedule/module.d.ts +1474 -0
- package/dist/schedule/module.js +22 -0
- package/dist/schedule/projections.d.ts +55 -0
- package/dist/schedule/projections.js +88 -0
- package/dist/schedule/scope.d.ts +121 -0
- package/dist/schedule/scope.js +7 -0
- package/dist/schedule/signals.d.ts +25 -0
- package/dist/schedule/signals.js +33 -0
- package/dist/schedule/specs.d.ts +59 -0
- package/dist/schedule/specs.js +231 -0
- package/dist/schedule/threads.d.ts +25 -0
- package/dist/schedule/threads.js +259 -0
- package/dist/testing/corpus-module.d.ts +34 -0
- package/dist/testing/corpus-module.js +87 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +5 -6
- package/dist/testing/replay-corpus.d.ts +22 -0
- package/dist/testing/replay-corpus.js +50 -0
- package/dist/workflow/definitions-store.d.ts +1 -1
- package/dist/workflow/definitions-store.js +1 -1
- package/dist/workflow/definitions.d.ts +14 -0
- package/dist/workflow/effects.d.ts +122 -0
- package/dist/workflow/effects.js +16 -6
- package/dist/workflow/events.d.ts +42 -0
- package/dist/workflow/events.js +4 -0
- package/dist/workflow/module.d.ts +345 -32
- package/dist/workflow/module.js +7 -3
- package/dist/workflow/scope.d.ts +42 -0
- package/dist/workflow/threads.d.ts +72 -0
- package/dist/workflow/threads.js +106 -38
- package/package.json +11 -2
- package/dist/debugger/app/assets/index-BevQYa-U.js +0 -22
- package/dist/debugger/app/assets/routes-B17Om93i.js +0 -47
- /package/dist/{react → core}/register.js +0 -0
package/dist/runtime/runtime.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Clock, Effect, ManagedRuntime, Predicate } from 'effect';
|
|
2
|
-
import { composeModules, createEvent, createThreadId, createRunId, DEFAULT_DEFINITION_VERSION, DEFAULT_SNAPSHOT_EVERY,
|
|
2
|
+
import { cachedDefinitionResolver, chainDefinitionResolvers, composeModules, createEvent, createThreadId, createRunId, DEFAULT_DEFINITION_VERSION, DEFAULT_SNAPSHOT_EVERY, staticDefinitionResolver, descendantThreadIds, EventStoreTag, foldRun, hasEntries, isRunTerminal, unavailableRunLauncher, planFork, project, readTraceContext, replayTo, sameStringRecord, summarizeRun, } from '@swirls/looms/core';
|
|
3
3
|
import { createCancellation } from './cancellation.js';
|
|
4
4
|
import { nextRuntimeDeadline } from './deadline-transition-planner.js';
|
|
5
|
-
import { ForkConflictError, RunNotFinishedError, RunNotFoundError, RunTerminalError, RuntimeExecutionError, } from './errors.js';
|
|
5
|
+
import { ForkConflictError, RunNotFinishedError, RunNotFoundError, RunTerminalError, RuntimeExecutionError, SecretContextConflictError, StartRunConflictError, } from './errors.js';
|
|
6
6
|
import { admitExternalEvents, moduleServices, resolveSnapshotStore, RunCursorCache, threadStartedEvents, UnknownDefinitionError, validateAndCreateEvents, waitSatisfiedEvents, } from './helpers.js';
|
|
7
7
|
import { createRunIngress } from './ingress.js';
|
|
8
8
|
import { notifyObserver } from './observer.js';
|
|
@@ -12,6 +12,7 @@ import { assertSameDurableStart } from './run-start.js';
|
|
|
12
12
|
import { createWakeEngine } from './wake-engine.js';
|
|
13
13
|
import { createTimeoutScheduler } from './wake-scheduler.js';
|
|
14
14
|
import { createWorkerCallbacks } from './worker-callbacks.js';
|
|
15
|
+
import { assertWorkerDeadlines } from './worker-effect-dispatch.js';
|
|
15
16
|
export { createTimeoutScheduler } from './wake-scheduler.js';
|
|
16
17
|
export { DuplicateEffectDispatchError, ForkConflictError, MaxWakeIterationsError, RunNotFinishedError, RunNotFoundError, RunTerminalError, StartRunConflictError, } from './errors.js';
|
|
17
18
|
export { RunOverloadedError } from './ingress.js';
|
|
@@ -33,10 +34,64 @@ const DEFAULT_RUN_CACHE_SIZE = 0;
|
|
|
33
34
|
const RESCAN_CONCURRENCY = 16;
|
|
34
35
|
const DEFAULT_KEEP_SNAPSHOTS = 1;
|
|
35
36
|
const DEFAULT_MAX_PENDING_RUN_OPERATIONS = 256;
|
|
37
|
+
function startedPayload(input) {
|
|
38
|
+
const payload = {
|
|
39
|
+
rootThreadId: input.threadId,
|
|
40
|
+
kind: input.kind,
|
|
41
|
+
definitionName: input.definitionName,
|
|
42
|
+
definitionVersion: input.definitionVersion,
|
|
43
|
+
input: input.input ?? null,
|
|
44
|
+
requestedThreadId: input.requestedThreadId,
|
|
45
|
+
};
|
|
46
|
+
if (input.launchedBy) {
|
|
47
|
+
payload.launchedBy = input.launchedBy;
|
|
48
|
+
}
|
|
49
|
+
if (input.attributes && hasEntries(input.attributes)) {
|
|
50
|
+
payload.attributes = input.attributes;
|
|
51
|
+
}
|
|
52
|
+
if (input.trace) {
|
|
53
|
+
payload.trace = input.trace;
|
|
54
|
+
}
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
function reconcileRunSecrets(store, runId, requested) {
|
|
58
|
+
const next = requested ?? {};
|
|
59
|
+
return Effect.gen(function* () {
|
|
60
|
+
const stored = store.runSecrets;
|
|
61
|
+
if (!stored) {
|
|
62
|
+
if (hasEntries(next)) {
|
|
63
|
+
return yield* new RuntimeExecutionError('This event store cannot persist a secret context');
|
|
64
|
+
}
|
|
65
|
+
return yield* Effect.void;
|
|
66
|
+
}
|
|
67
|
+
const current = yield* stored.get(runId);
|
|
68
|
+
if (sameStringRecord(current, next) || requested === undefined) {
|
|
69
|
+
return yield* Effect.void;
|
|
70
|
+
}
|
|
71
|
+
if (!hasEntries(current)) {
|
|
72
|
+
return yield* stored.put(runId, next);
|
|
73
|
+
}
|
|
74
|
+
return yield* new SecretContextConflictError(runId);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
36
77
|
export function createRuntime(options) {
|
|
78
|
+
if (options.localLauncher && !options.store && !options.launcher) {
|
|
79
|
+
throw new Error('localLauncher requires options.store');
|
|
80
|
+
}
|
|
37
81
|
const registry = composeModules(options.modules);
|
|
38
|
-
|
|
39
|
-
const
|
|
82
|
+
assertWorkerDeadlines(registry, options.worker);
|
|
83
|
+
const staticResolver = staticDefinitionResolver(registry.definitions.map((definition) => ({
|
|
84
|
+
...definition.value,
|
|
85
|
+
kind: definition.kind,
|
|
86
|
+
name: definition.name,
|
|
87
|
+
version: definition.version,
|
|
88
|
+
})));
|
|
89
|
+
const definitionResolver = cachedDefinitionResolver(options.definitionResolver
|
|
90
|
+
? chainDefinitionResolvers([staticResolver, options.definitionResolver])
|
|
91
|
+
: staticResolver);
|
|
92
|
+
for (const module of options.modules) {
|
|
93
|
+
module.bindHost?.({ definitionResolver });
|
|
94
|
+
}
|
|
40
95
|
// Module composition erases the heterogeneous service identifier union at this host boundary.
|
|
41
96
|
// oxlint-disable-next-line effecttsgo/any-unknown-in-error-context
|
|
42
97
|
const services = ManagedRuntime.make(moduleServices(options.modules));
|
|
@@ -58,6 +113,7 @@ export function createRuntime(options) {
|
|
|
58
113
|
runCache,
|
|
59
114
|
executionContexts,
|
|
60
115
|
observer: options.observer,
|
|
116
|
+
definitionResolver,
|
|
61
117
|
});
|
|
62
118
|
const { commit, loadCursor } = repository;
|
|
63
119
|
const signalAdmitted = (runId, events, signalOpts) => Effect.gen(function* () {
|
|
@@ -77,12 +133,17 @@ export function createRuntime(options) {
|
|
|
77
133
|
const batch = yield* validateAndCreateEvents(registry.catalogs, runId, filteredEvents.map((input) => ({
|
|
78
134
|
...input,
|
|
79
135
|
idempotencyKey: input.idempotencyKey ?? signalKey,
|
|
80
|
-
})), { origin: { type: 'external' } });
|
|
81
|
-
|
|
136
|
+
})), { origin: { type: 'external' }, threadId: current.state.rootThreadId });
|
|
137
|
+
const terminal = isRunTerminal(current.state);
|
|
138
|
+
const settlementOnly = filteredEvents.every((event) => event.type === 'runtime.child.settled');
|
|
139
|
+
if (terminal && !settlementOnly) {
|
|
82
140
|
return yield* new RunTerminalError(runId, current.state.status);
|
|
83
141
|
}
|
|
84
142
|
const folded = foldRun(batch, registry, { runId, initial: current.state });
|
|
85
|
-
const
|
|
143
|
+
const forWaits = terminal || current.state.closeWaiting
|
|
144
|
+
? batch.filter((event) => event.type !== 'runtime.child.settled')
|
|
145
|
+
: batch;
|
|
146
|
+
const satisfiedEvents = waitSatisfiedEvents(folded, forWaits).map((input) => createEvent(runId, input));
|
|
86
147
|
return {
|
|
87
148
|
events: [...batch, ...satisfiedEvents],
|
|
88
149
|
value: current.state,
|
|
@@ -94,19 +155,100 @@ export function createRuntime(options) {
|
|
|
94
155
|
}
|
|
95
156
|
return committed.value;
|
|
96
157
|
});
|
|
158
|
+
const commitStart = (args, runId) => Effect.gen(function* () {
|
|
159
|
+
const definitionVersion = args.definitionVersion ?? DEFAULT_DEFINITION_VERSION;
|
|
160
|
+
const resolved = yield* definitionResolver.resolve(args.kind, args.definitionName, definitionVersion);
|
|
161
|
+
if (!resolved) {
|
|
162
|
+
return yield* new UnknownDefinitionError(args.kind, args.definitionName, definitionVersion);
|
|
163
|
+
}
|
|
164
|
+
const store = yield* EventStoreTag;
|
|
165
|
+
observe({ type: 'run.start', runId, at: currentTimeMillis() });
|
|
166
|
+
const threadId = args.threadId ?? createThreadId();
|
|
167
|
+
const startedEvents = yield* threadStartedEvents(definitionResolver, {
|
|
168
|
+
kind: args.kind,
|
|
169
|
+
definitionName: args.definitionName,
|
|
170
|
+
definitionVersion,
|
|
171
|
+
input: args.input ?? null,
|
|
172
|
+
threadId,
|
|
173
|
+
parentThreadId: null,
|
|
174
|
+
});
|
|
175
|
+
const started = startedEvents[0];
|
|
176
|
+
const startedInput = started && Predicate.isObject(started.payload)
|
|
177
|
+
? (started.payload.input ?? null)
|
|
178
|
+
: (args.input ?? null);
|
|
179
|
+
const requestedIdentity = {
|
|
180
|
+
kind: args.kind,
|
|
181
|
+
definitionName: args.definitionName,
|
|
182
|
+
definitionVersion,
|
|
183
|
+
input: startedInput,
|
|
184
|
+
requestedThreadId: args.threadId ?? null,
|
|
185
|
+
idempotencyKey: args.idempotencyKey ?? null,
|
|
186
|
+
launchedBy: args.launchedBy ?? null,
|
|
187
|
+
correlationId: args.correlationId ?? null,
|
|
188
|
+
attributes: args.attributes ?? {},
|
|
189
|
+
};
|
|
190
|
+
const origin = args.launchedBy
|
|
191
|
+
? { type: 'run', runId: args.launchedBy.runId }
|
|
192
|
+
: { type: 'system' };
|
|
193
|
+
const startPayload = startedPayload({
|
|
194
|
+
threadId,
|
|
195
|
+
kind: args.kind,
|
|
196
|
+
definitionName: args.definitionName,
|
|
197
|
+
definitionVersion,
|
|
198
|
+
input: startedInput,
|
|
199
|
+
requestedThreadId: args.threadId ?? null,
|
|
200
|
+
launchedBy: args.launchedBy,
|
|
201
|
+
attributes: args.attributes,
|
|
202
|
+
trace: args.trace && readTraceContext(args.trace.traceparent, args.trace.tracestate),
|
|
203
|
+
});
|
|
204
|
+
if (hasEntries(args.secrets ?? {}) && !store.runSecrets) {
|
|
205
|
+
return yield* new RuntimeExecutionError('This event store cannot persist a secret context');
|
|
206
|
+
}
|
|
207
|
+
const committed = yield* commit(store, runId, (existing) => existing.seq > 0
|
|
208
|
+
? assertSameDurableStart(runId, existing.state, requestedIdentity).pipe(Effect.map((rootThreadId) => ({
|
|
209
|
+
events: [],
|
|
210
|
+
value: { threadId: rootThreadId, state: existing.state },
|
|
211
|
+
})))
|
|
212
|
+
: validateAndCreateEvents(registry.catalogs, runId, [
|
|
213
|
+
{
|
|
214
|
+
type: 'runtime.run.started',
|
|
215
|
+
payload: startPayload,
|
|
216
|
+
threadId: null,
|
|
217
|
+
idempotencyKey: args.idempotencyKey,
|
|
218
|
+
},
|
|
219
|
+
...startedEvents,
|
|
220
|
+
].map((event) => ({ ...event, correlationId: args.correlationId })), { origin }).pipe(Effect.map((batch) => ({
|
|
221
|
+
events: batch,
|
|
222
|
+
value: {
|
|
223
|
+
threadId,
|
|
224
|
+
state: foldRun(batch, registry, { runId, initial: existing.state }),
|
|
225
|
+
},
|
|
226
|
+
idempotencyKey: args.idempotencyKey ? `start:${args.idempotencyKey}` : undefined,
|
|
227
|
+
}))));
|
|
228
|
+
yield* reconcileRunSecrets(store, runId, args.secrets);
|
|
229
|
+
if (!committed.append?.deduplicated) {
|
|
230
|
+
return committed.value;
|
|
231
|
+
}
|
|
232
|
+
const durable = yield* loadCursor(store, runId);
|
|
233
|
+
const rootThreadId = yield* assertSameDurableStart(runId, durable.state, requestedIdentity);
|
|
234
|
+
return { threadId: rootThreadId, state: durable.state };
|
|
235
|
+
});
|
|
97
236
|
let scheduler;
|
|
237
|
+
let launcher;
|
|
98
238
|
let runtime;
|
|
99
239
|
const wakeEngine = createWakeEngine({
|
|
100
240
|
registry,
|
|
101
241
|
services,
|
|
102
|
-
definitions,
|
|
103
242
|
executionContexts,
|
|
104
243
|
repository,
|
|
105
244
|
snapshotEvery,
|
|
106
245
|
maxWakeIterations,
|
|
246
|
+
maxInFlightWorkerEffects: options.maxInFlightWorkerEffects,
|
|
107
247
|
worker: options.worker,
|
|
108
248
|
observer: options.observer,
|
|
249
|
+
definitionResolver,
|
|
109
250
|
scheduler: () => scheduler,
|
|
251
|
+
launcher: () => launcher,
|
|
110
252
|
});
|
|
111
253
|
const workerCallbacks = createWorkerCallbacks({
|
|
112
254
|
registry,
|
|
@@ -123,7 +265,15 @@ export function createRuntime(options) {
|
|
|
123
265
|
observer: options.observer,
|
|
124
266
|
wake: wakeEngine,
|
|
125
267
|
withRunIngress,
|
|
268
|
+
launcher: () => launcher,
|
|
126
269
|
});
|
|
270
|
+
const appendAndWake = (runId, events, signalOpts) => withRunIngress(runId, signalAdmitted(runId, events, signalOpts)).pipe(Effect.flatMap((state) => {
|
|
271
|
+
if (signalOpts?.coalesce && executionContexts.get(runId).waking) {
|
|
272
|
+
executionContexts.get(runId).wakeAgain = true;
|
|
273
|
+
return Effect.succeed(state);
|
|
274
|
+
}
|
|
275
|
+
return runtime.wake(runId);
|
|
276
|
+
}));
|
|
127
277
|
runtime = {
|
|
128
278
|
modules: options.modules,
|
|
129
279
|
registry,
|
|
@@ -228,70 +378,28 @@ export function createRuntime(options) {
|
|
|
228
378
|
recoverDeadlines: Effect.suspend(() => runtime.rescanTimers),
|
|
229
379
|
startRun: (args) => {
|
|
230
380
|
const runId = args.runId ?? createRunId();
|
|
231
|
-
return withRunIngress(runId, Effect.
|
|
232
|
-
const definitionVersion = args.definitionVersion ?? DEFAULT_DEFINITION_VERSION;
|
|
233
|
-
if (!definitions.get(definitionKey(args.kind, args.definitionName, definitionVersion))) {
|
|
234
|
-
return yield* new UnknownDefinitionError(args.kind, args.definitionName, definitionVersion);
|
|
235
|
-
}
|
|
236
|
-
const store = yield* EventStoreTag;
|
|
237
|
-
observe({ type: 'run.start', runId, at: currentTimeMillis() });
|
|
238
|
-
const threadId = args.threadId ?? createThreadId();
|
|
239
|
-
const startedEvents = yield* threadStartedEvents(definitions, {
|
|
240
|
-
kind: args.kind,
|
|
241
|
-
definitionName: args.definitionName,
|
|
242
|
-
definitionVersion,
|
|
243
|
-
input: args.input ?? null,
|
|
244
|
-
threadId,
|
|
245
|
-
parentThreadId: null,
|
|
246
|
-
});
|
|
247
|
-
const started = startedEvents[0];
|
|
248
|
-
const startedInput = started && Predicate.isObject(started.payload)
|
|
249
|
-
? (started.payload.input ?? null)
|
|
250
|
-
: (args.input ?? null);
|
|
251
|
-
const requestedIdentity = {
|
|
252
|
-
kind: args.kind,
|
|
253
|
-
definitionName: args.definitionName,
|
|
254
|
-
definitionVersion,
|
|
255
|
-
input: startedInput,
|
|
256
|
-
requestedThreadId: args.threadId ?? null,
|
|
257
|
-
idempotencyKey: args.idempotencyKey ?? null,
|
|
258
|
-
};
|
|
259
|
-
const committed = yield* commit(store, runId, (existing) => existing.seq > 0
|
|
260
|
-
? assertSameDurableStart(runId, existing.state, requestedIdentity).pipe(Effect.map((rootThreadId) => ({ events: [], value: rootThreadId })))
|
|
261
|
-
: validateAndCreateEvents(registry.catalogs, runId, [
|
|
262
|
-
{
|
|
263
|
-
type: 'runtime.run.started',
|
|
264
|
-
payload: {
|
|
265
|
-
rootThreadId: threadId,
|
|
266
|
-
kind: args.kind,
|
|
267
|
-
definitionName: args.definitionName,
|
|
268
|
-
definitionVersion,
|
|
269
|
-
input: startedInput,
|
|
270
|
-
requestedThreadId: args.threadId ?? null,
|
|
271
|
-
},
|
|
272
|
-
threadId: null,
|
|
273
|
-
idempotencyKey: args.idempotencyKey,
|
|
274
|
-
},
|
|
275
|
-
...startedEvents,
|
|
276
|
-
], { origin: { type: 'system' } }).pipe(Effect.map((batch) => ({
|
|
277
|
-
events: batch,
|
|
278
|
-
value: threadId,
|
|
279
|
-
idempotencyKey: args.idempotencyKey
|
|
280
|
-
? `start:${args.idempotencyKey}`
|
|
281
|
-
: undefined,
|
|
282
|
-
}))));
|
|
283
|
-
if (!committed.append?.deduplicated) {
|
|
284
|
-
return committed.value;
|
|
285
|
-
}
|
|
286
|
-
const durable = yield* loadCursor(store, runId);
|
|
287
|
-
return yield* assertSameDurableStart(runId, durable.state, requestedIdentity);
|
|
288
|
-
})).pipe(Effect.flatMap((threadId) => runtime.wake(runId).pipe(Effect.map((state) => ({
|
|
381
|
+
return withRunIngress(runId, commitStart(args, runId)).pipe(Effect.flatMap((started) => runtime.wake(runId).pipe(Effect.map((state) => ({
|
|
289
382
|
runId,
|
|
290
|
-
threadId: state.rootThreadId ?? threadId,
|
|
383
|
+
threadId: state.rootThreadId ?? started.threadId,
|
|
291
384
|
state,
|
|
292
385
|
})))));
|
|
293
386
|
},
|
|
294
|
-
|
|
387
|
+
launchRun: (args) => {
|
|
388
|
+
const runId = args.runId ?? createRunId();
|
|
389
|
+
return withRunIngress(runId, commitStart(args, runId)).pipe(Effect.flatMap((started) => Effect.gen(function* () {
|
|
390
|
+
if (!isRunTerminal(started.state)) {
|
|
391
|
+
const now = yield* Clock.currentTimeMillis;
|
|
392
|
+
yield* scheduler.schedule(runId, now);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
runId,
|
|
396
|
+
threadId: started.state.rootThreadId ?? started.threadId,
|
|
397
|
+
state: started.state,
|
|
398
|
+
};
|
|
399
|
+
})));
|
|
400
|
+
},
|
|
401
|
+
signal: (runId, events, signalOpts) => admitExternalEvents(events).pipe(Effect.flatMap((admitted) => appendAndWake(runId, admitted, signalOpts))),
|
|
402
|
+
accept: (runId, events, signalOpts) => appendAndWake(runId, events, signalOpts),
|
|
295
403
|
cancel: cancellation,
|
|
296
404
|
fork: (sourceRunId, forkOptions) => {
|
|
297
405
|
const runId = forkOptions?.runId ?? createRunId();
|
|
@@ -319,6 +427,8 @@ export function createRuntime(options) {
|
|
|
319
427
|
? Effect.succeed({ events: [], value: undefined })
|
|
320
428
|
: Effect.fail(new ForkConflictError(runId, sourceRunId));
|
|
321
429
|
});
|
|
430
|
+
const parentSecrets = store.runSecrets ? yield* store.runSecrets.get(sourceRunId) : {};
|
|
431
|
+
yield* reconcileRunSecrets(store, runId, hasEntries(parentSecrets) ? parentSecrets : undefined);
|
|
322
432
|
return plan.rootThreadId;
|
|
323
433
|
})).pipe(Effect.flatMap((threadId) => runtime.wake(runId).pipe(Effect.map((state) => ({ runId, threadId, state })))));
|
|
324
434
|
},
|
|
@@ -332,5 +442,18 @@ export function createRuntime(options) {
|
|
|
332
442
|
.wake(runId)
|
|
333
443
|
.pipe(Effect.provideService(EventStoreTag, options.store), Effect.tapError(Effect.logError), Effect.ignore)
|
|
334
444
|
: Effect.void);
|
|
445
|
+
const store = options.store;
|
|
446
|
+
launcher =
|
|
447
|
+
options.launcher ??
|
|
448
|
+
(options.localLauncher && store
|
|
449
|
+
? {
|
|
450
|
+
launch: (args) => runtime.launchRun(args).pipe(Effect.provideService(EventStoreTag, store), Effect.catchIf((error) => error instanceof StartRunConflictError, () => Effect.void), Effect.asVoid),
|
|
451
|
+
status: (runId) => runtime.getRun(runId).pipe(Effect.provideService(EventStoreTag, store), Effect.map((state) => state.rootThreadId === null ? null : { status: state.status })),
|
|
452
|
+
signal: (runId, events, signalOptions) => runtime
|
|
453
|
+
.accept(runId, events, { ...signalOptions, coalesce: true })
|
|
454
|
+
.pipe(Effect.provideService(EventStoreTag, store), Effect.asVoid),
|
|
455
|
+
cancel: (runId) => runtime.cancel(runId).pipe(Effect.provideService(EventStoreTag, store), Effect.asVoid),
|
|
456
|
+
}
|
|
457
|
+
: unavailableRunLauncher);
|
|
335
458
|
return runtime;
|
|
336
459
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
|
+
import type { RunAttributes } from '@swirls/looms/core';
|
|
2
3
|
/**
|
|
3
4
|
* What a route can do. `public` routes (health) are never passed to `authorize`; `read` and
|
|
4
5
|
* `write` cover run state, `worker` covers external worker callbacks, and `operations` covers
|
|
@@ -11,6 +12,8 @@ export interface RouteInfo {
|
|
|
11
12
|
/** Stable route name, such as `runs.signal` or `effects.retry`. */
|
|
12
13
|
readonly name: string;
|
|
13
14
|
readonly runId?: string;
|
|
15
|
+
/** Present when the run has labels. Safe for the authorizer to branch on. */
|
|
16
|
+
readonly attributes?: RunAttributes;
|
|
14
17
|
}
|
|
15
18
|
export type AuthDecision = {
|
|
16
19
|
readonly allowed: true;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EventStore } from '@swirls/looms/core';
|
|
2
2
|
import type { LoomsRuntime } from '../runtime';
|
|
3
3
|
import { type Authorize } from './auth';
|
|
4
4
|
import { type DebuggerMount } from './debugger-host';
|
|
@@ -7,7 +7,7 @@ export interface FetchHandlerOptions {
|
|
|
7
7
|
readonly store: EventStore;
|
|
8
8
|
/**
|
|
9
9
|
* Decides whether a request may use a route. Defaults to open reads and writes with worker and
|
|
10
|
-
* operations routes unavailable; use `bearerAuth` or your own policy to change that.
|
|
10
|
+
* operations routes unavailable; use `bearerAuth`, `grantAuth`, or your own policy to change that.
|
|
11
11
|
*/
|
|
12
12
|
readonly authorize?: Authorize;
|
|
13
13
|
/** Debugger UI served beside the API and authorized as `{ access: 'read', name: 'debugger' }`. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Effect, Predicate } from 'effect';
|
|
1
|
+
import { Data, Effect, Option, Predicate, Schema } from 'effect';
|
|
2
|
+
import { hasEntries, RunAttributesSchema } from '@swirls/looms/core';
|
|
2
3
|
import { authResponse, defaultAuthorize } from './auth.js';
|
|
3
4
|
import { debuggerPath, serveDebugger } from './debugger-host.js';
|
|
4
5
|
import { acceptsEventStream, isLoomsApiPath, toErrorResponse } from './http.js';
|
|
@@ -7,6 +8,7 @@ import { definitionRoutes } from './routes/definitions.js';
|
|
|
7
8
|
import { effectRoutes } from './routes/effects.js';
|
|
8
9
|
import { operationRoutes } from './routes/operations.js';
|
|
9
10
|
import { runRoutes } from './routes/runs.js';
|
|
11
|
+
import { readStoredAttributes } from './run-route.js';
|
|
10
12
|
function createRoutes(options) {
|
|
11
13
|
return [
|
|
12
14
|
...definitionRoutes(options),
|
|
@@ -15,13 +17,42 @@ function createRoutes(options) {
|
|
|
15
17
|
...operationRoutes(options),
|
|
16
18
|
];
|
|
17
19
|
}
|
|
18
|
-
|
|
20
|
+
const StartAttributesSchema = Schema.Struct({
|
|
21
|
+
attributes: Schema.optional(RunAttributesSchema),
|
|
22
|
+
});
|
|
23
|
+
class UnreadableStartBody extends Data.TaggedError('UnreadableStartBody') {
|
|
24
|
+
}
|
|
25
|
+
const noAttributes = {};
|
|
26
|
+
function readRequestAttributes(req) {
|
|
27
|
+
return Effect.tryPromise({
|
|
28
|
+
try: () => req.clone().json(),
|
|
29
|
+
catch: (cause) => new UnreadableStartBody({ cause }),
|
|
30
|
+
}).pipe(Effect.map((body) => Option.match(Schema.decodeUnknownOption(StartAttributesSchema)(body), {
|
|
31
|
+
onNone: () => noAttributes,
|
|
32
|
+
onSome: (decoded) => decoded.attributes ?? noAttributes,
|
|
33
|
+
})), Effect.catchTag('UnreadableStartBody', () => Effect.succeed(noAttributes)));
|
|
34
|
+
}
|
|
35
|
+
function authorizeRoute(req, matched, authorize, runtime, store) {
|
|
19
36
|
const { access, name } = matched.route;
|
|
20
37
|
if (access === 'public') {
|
|
21
38
|
return Effect.succeed(null);
|
|
22
39
|
}
|
|
23
40
|
const runId = matched.params.runId;
|
|
24
|
-
return
|
|
41
|
+
return Effect.gen(function* () {
|
|
42
|
+
const stored = Predicate.isString(runId)
|
|
43
|
+
? yield* readStoredAttributes(runtime, store, runId)
|
|
44
|
+
: noAttributes;
|
|
45
|
+
const requested = matched.route.name === 'runs.start' ? yield* readRequestAttributes(req) : noAttributes;
|
|
46
|
+
const attributes = hasEntries(stored) ? stored : requested;
|
|
47
|
+
const route = Predicate.isString(runId)
|
|
48
|
+
? hasEntries(attributes)
|
|
49
|
+
? { access, name, runId, attributes }
|
|
50
|
+
: { access, name, runId }
|
|
51
|
+
: hasEntries(attributes)
|
|
52
|
+
? { access, name, attributes }
|
|
53
|
+
: { access, name };
|
|
54
|
+
return yield* authorize(req, route).pipe(Effect.map(authResponse));
|
|
55
|
+
});
|
|
25
56
|
}
|
|
26
57
|
/** Whether the fetch handler answers `pathname`: the HTTP API or the debugger mount. */
|
|
27
58
|
export function handlesPath(pathname, mount) {
|
|
@@ -49,7 +80,7 @@ export function createFetchHandler(options) {
|
|
|
49
80
|
if (!matched) {
|
|
50
81
|
return new Response('Not Found', { status: 404 });
|
|
51
82
|
}
|
|
52
|
-
const denied = yield* authorizeRoute(req, matched, authorize);
|
|
83
|
+
const denied = yield* authorizeRoute(req, matched, authorize, options.runtime, options.store);
|
|
53
84
|
if (denied) {
|
|
54
85
|
return denied;
|
|
55
86
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { type Authorize } from './auth';
|
|
3
|
+
export type GrantFailure = 'malformed' | 'signature' | 'expired' | 'claims';
|
|
4
|
+
declare const GrantError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
5
|
+
readonly _tag: "GrantError";
|
|
6
|
+
} & Readonly<A>;
|
|
7
|
+
export declare class GrantError extends GrantError_base<{
|
|
8
|
+
readonly reason: GrantFailure;
|
|
9
|
+
}> {
|
|
10
|
+
}
|
|
11
|
+
/** Claims carried by a run grant. `exp` is an exclusive expiry in Unix milliseconds. */
|
|
12
|
+
export interface RunGrant {
|
|
13
|
+
readonly runId: string;
|
|
14
|
+
readonly principal: string;
|
|
15
|
+
readonly access: 'read';
|
|
16
|
+
readonly exp: number;
|
|
17
|
+
}
|
|
18
|
+
export interface GrantAuthOptions {
|
|
19
|
+
readonly secret: string;
|
|
20
|
+
/** Clock used for expiry. Defaults to `Date.now`. */
|
|
21
|
+
readonly now?: () => number;
|
|
22
|
+
/**
|
|
23
|
+
* Extra time after `exp` that still verifies. Covers clock skew between the issuer and the host.
|
|
24
|
+
* Defaults to 0.
|
|
25
|
+
*/
|
|
26
|
+
readonly clockSkewMs?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Signs a read grant for one run. The token is URL-safe and is also a legal WebSocket subprotocol
|
|
30
|
+
* value, so a browser can send it as a query parameter or as `Sec-WebSocket-Protocol`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function issueRunGrant(secret: string, claims: RunGrant): Effect.Effect<string, GrantError>;
|
|
33
|
+
/** Verifies the HMAC and expiry. A grant for another run fails later, when a route is checked. */
|
|
34
|
+
export declare function verifyRunGrant(secret: string, token: string, now: number, clockSkewMs?: number): Effect.Effect<RunGrant, GrantError>;
|
|
35
|
+
/**
|
|
36
|
+
* Authorizes a read of the granted run. The token comes from the `grant` query parameter, or, when
|
|
37
|
+
* that is absent, from a `v1.` WebSocket subprotocol. Writes, worker callbacks, operations, and
|
|
38
|
+
* reads of any other run are refused. A host that accepts the subprotocol has to echo it on the
|
|
39
|
+
* WebSocket response; see `grantSubprotocol`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function grantAuth(options: GrantAuthOptions): Authorize;
|
|
42
|
+
/** The grant token on this request, if the client sent one. */
|
|
43
|
+
export declare function runGrantToken(request: Request): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The `v1.` grant the client offered as a WebSocket subprotocol. The upgrade response must echo it,
|
|
46
|
+
* or the browser fails the handshake.
|
|
47
|
+
*/
|
|
48
|
+
export declare function grantSubprotocol(request: Request): string | undefined;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Data, Effect, Option, Schema } from 'effect';
|
|
2
|
+
import { authAllowed, authDenied } from './auth.js';
|
|
3
|
+
const textEncoder = new TextEncoder();
|
|
4
|
+
const textDecoder = new TextDecoder();
|
|
5
|
+
const unauthorized = authDenied(401, 'Unauthorized');
|
|
6
|
+
const forbidden = authDenied(403, 'Forbidden');
|
|
7
|
+
const GrantPayloadSchema = Schema.Struct({
|
|
8
|
+
v: Schema.Literal(1),
|
|
9
|
+
runId: Schema.String,
|
|
10
|
+
principal: Schema.String,
|
|
11
|
+
access: Schema.Literal('read'),
|
|
12
|
+
exp: Schema.Finite,
|
|
13
|
+
});
|
|
14
|
+
const GrantPayloadJson = Schema.fromJsonString(GrantPayloadSchema);
|
|
15
|
+
const encodeGrantPayload = Schema.encodeSync(GrantPayloadJson);
|
|
16
|
+
const decodeGrantPayload = Schema.decodeUnknownOption(GrantPayloadJson);
|
|
17
|
+
export class GrantError extends Data.TaggedError('GrantError') {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Signs a read grant for one run. The token is URL-safe and is also a legal WebSocket subprotocol
|
|
21
|
+
* value, so a browser can send it as a query parameter or as `Sec-WebSocket-Protocol`.
|
|
22
|
+
*/
|
|
23
|
+
export function issueRunGrant(secret, claims) {
|
|
24
|
+
return Effect.gen(function* () {
|
|
25
|
+
const payload = grantPayload(claims);
|
|
26
|
+
if (!payload) {
|
|
27
|
+
return yield* new GrantError({ reason: 'claims' });
|
|
28
|
+
}
|
|
29
|
+
const json = yield* Effect.try({
|
|
30
|
+
try: () => encodeGrantPayload(payload),
|
|
31
|
+
catch: () => new GrantError({ reason: 'claims' }),
|
|
32
|
+
});
|
|
33
|
+
const encoded = encodeBase64Url(textEncoder.encode(json));
|
|
34
|
+
const key = yield* importHmacKey(secret);
|
|
35
|
+
const signature = yield* Effect.tryPromise({
|
|
36
|
+
try: () => crypto.subtle.sign('HMAC', key, textEncoder.encode(encoded)),
|
|
37
|
+
catch: () => new GrantError({ reason: 'signature' }),
|
|
38
|
+
});
|
|
39
|
+
return `v1.${encoded}.${encodeBase64Url(new Uint8Array(signature))}`;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Verifies the HMAC and expiry. A grant for another run fails later, when a route is checked. */
|
|
43
|
+
export function verifyRunGrant(secret, token, now, clockSkewMs = 0) {
|
|
44
|
+
return Effect.gen(function* () {
|
|
45
|
+
const parts = token.split('.');
|
|
46
|
+
if (parts.length !== 3 || parts[0] !== 'v1') {
|
|
47
|
+
return yield* new GrantError({ reason: 'malformed' });
|
|
48
|
+
}
|
|
49
|
+
const encoded = parts[1] ?? '';
|
|
50
|
+
const signature = decodeBase64Url(parts[2] ?? '');
|
|
51
|
+
const payloadBytes = decodeBase64Url(encoded);
|
|
52
|
+
if (!signature || !payloadBytes) {
|
|
53
|
+
return yield* new GrantError({ reason: 'malformed' });
|
|
54
|
+
}
|
|
55
|
+
const key = yield* importHmacKey(secret);
|
|
56
|
+
const authentic = yield* Effect.tryPromise({
|
|
57
|
+
try: () => crypto.subtle.verify('HMAC', key, signature, textEncoder.encode(encoded)),
|
|
58
|
+
catch: () => new GrantError({ reason: 'signature' }),
|
|
59
|
+
});
|
|
60
|
+
if (!authentic) {
|
|
61
|
+
return yield* new GrantError({ reason: 'signature' });
|
|
62
|
+
}
|
|
63
|
+
const decoded = decodeGrantPayload(textDecoder.decode(payloadBytes));
|
|
64
|
+
if (Option.isNone(decoded) ||
|
|
65
|
+
decoded.value.runId.length === 0 ||
|
|
66
|
+
decoded.value.principal.length === 0) {
|
|
67
|
+
return yield* new GrantError({ reason: 'claims' });
|
|
68
|
+
}
|
|
69
|
+
const skew = finiteSkew(clockSkewMs);
|
|
70
|
+
if (decoded.value.exp <= now - skew) {
|
|
71
|
+
return yield* new GrantError({ reason: 'expired' });
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
runId: decoded.value.runId,
|
|
75
|
+
principal: decoded.value.principal,
|
|
76
|
+
access: decoded.value.access,
|
|
77
|
+
exp: decoded.value.exp,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Authorizes a read of the granted run. The token comes from the `grant` query parameter, or, when
|
|
83
|
+
* that is absent, from a `v1.` WebSocket subprotocol. Writes, worker callbacks, operations, and
|
|
84
|
+
* reads of any other run are refused. A host that accepts the subprotocol has to echo it on the
|
|
85
|
+
* WebSocket response; see `grantSubprotocol`.
|
|
86
|
+
*/
|
|
87
|
+
export function grantAuth(options) {
|
|
88
|
+
const now = options.now ?? Date.now;
|
|
89
|
+
const skew = finiteSkew(options.clockSkewMs);
|
|
90
|
+
return (request, route) => Effect.gen(function* () {
|
|
91
|
+
const token = runGrantToken(request);
|
|
92
|
+
if (token === undefined) {
|
|
93
|
+
return unauthorized;
|
|
94
|
+
}
|
|
95
|
+
return yield* verifyRunGrant(options.secret, token, now(), skew).pipe(Effect.map((grant) => grantDecision(grant, route)), Effect.catchTag('GrantError', () => Effect.succeed(unauthorized)));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** The grant token on this request, if the client sent one. */
|
|
99
|
+
export function runGrantToken(request) {
|
|
100
|
+
const query = new URL(request.url).searchParams.get('grant');
|
|
101
|
+
if (query) {
|
|
102
|
+
return query;
|
|
103
|
+
}
|
|
104
|
+
return grantSubprotocol(request);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The `v1.` grant the client offered as a WebSocket subprotocol. The upgrade response must echo it,
|
|
108
|
+
* or the browser fails the handshake.
|
|
109
|
+
*/
|
|
110
|
+
export function grantSubprotocol(request) {
|
|
111
|
+
return websocketGrant(request.headers.get('sec-websocket-protocol'));
|
|
112
|
+
}
|
|
113
|
+
function grantDecision(grant, route) {
|
|
114
|
+
if (route.access !== 'read' || route.runId !== grant.runId) {
|
|
115
|
+
return forbidden;
|
|
116
|
+
}
|
|
117
|
+
return authAllowed;
|
|
118
|
+
}
|
|
119
|
+
function grantPayload(claims) {
|
|
120
|
+
if (claims.runId.length === 0 || claims.principal.length === 0 || !Number.isFinite(claims.exp)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
v: 1,
|
|
125
|
+
runId: claims.runId,
|
|
126
|
+
principal: claims.principal,
|
|
127
|
+
access: 'read',
|
|
128
|
+
exp: claims.exp,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function finiteSkew(clockSkewMs) {
|
|
132
|
+
if (clockSkewMs === undefined || !Number.isFinite(clockSkewMs) || clockSkewMs < 0) {
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
return clockSkewMs;
|
|
136
|
+
}
|
|
137
|
+
function websocketGrant(header) {
|
|
138
|
+
if (header === null) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
for (const part of header.split(',')) {
|
|
142
|
+
const protocol = part.trim();
|
|
143
|
+
if (protocol.startsWith('v1.')) {
|
|
144
|
+
return protocol;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
function importHmacKey(secret) {
|
|
150
|
+
return Effect.tryPromise({
|
|
151
|
+
try: () => crypto.subtle.importKey('raw', textEncoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']),
|
|
152
|
+
catch: () => new GrantError({ reason: 'signature' }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function encodeBase64Url(bytes) {
|
|
156
|
+
let binary = '';
|
|
157
|
+
for (const byte of bytes) {
|
|
158
|
+
binary += String.fromCharCode(byte);
|
|
159
|
+
}
|
|
160
|
+
let encoded = btoa(binary).replaceAll('+', '-').replaceAll('/', '_');
|
|
161
|
+
while (encoded.endsWith('=')) {
|
|
162
|
+
encoded = encoded.slice(0, -1);
|
|
163
|
+
}
|
|
164
|
+
return encoded;
|
|
165
|
+
}
|
|
166
|
+
function decodeBase64Url(value) {
|
|
167
|
+
if (value.length === 0 || value.length % 4 === 1 || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const padded = value + '='.repeat((4 - (value.length % 4)) % 4);
|
|
171
|
+
const binary = atob(padded.replaceAll('-', '+').replaceAll('_', '/'));
|
|
172
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
173
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
174
|
+
bytes[index] = binary.charCodeAt(index);
|
|
175
|
+
}
|
|
176
|
+
return bytes;
|
|
177
|
+
}
|