@loomcycle/client 1.112.0 → 1.114.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/README.md +285 -29
- package/dist/cjs/client.js +545 -18
- package/dist/cjs/errors.js +119 -15
- package/dist/cjs/fetch-helpers.js +39 -8
- package/dist/cjs/index.js +53 -8
- package/dist/cjs/interactive.js +5 -1
- package/dist/cjs/signature.js +119 -0
- package/dist/client.d.ts +393 -29
- package/dist/client.js +545 -18
- package/dist/errors.d.ts +92 -14
- package/dist/errors.js +116 -14
- package/dist/fetch-helpers.d.ts +3 -0
- package/dist/fetch-helpers.js +40 -9
- package/dist/index.d.ts +51 -9
- package/dist/index.js +49 -8
- package/dist/interactive.js +5 -1
- package/dist/signature.d.ts +73 -0
- package/dist/signature.js +115 -0
- package/dist/types.d.ts +1308 -17
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* getAgent(agentId): Promise<Agent>
|
|
15
15
|
* getRun(runId): Promise<Agent>
|
|
16
16
|
* cancelAgent(agentId, opts?): Promise<CancelAgentResult>
|
|
17
|
-
* listUserAgents(userId, opts?): Promise<
|
|
17
|
+
* listUserAgents(userId, opts?): Promise<AgentList>
|
|
18
18
|
* listWalkRuns(walkId, opts?): Promise<WalkRunsPage>
|
|
19
19
|
* deleteRunTree(runId, opts?): Promise<RunTreeDeletion> // needs runs:delete
|
|
20
20
|
* deleteSession(sessionId, {cascade: "children"}): Promise<RunTreeDeletion>
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* resumeRuntime(): Promise<ResumeResult>
|
|
36
36
|
* getRuntimeState(): Promise<RuntimeStateResponse>
|
|
37
37
|
* resolveProbe(opts?): Promise<ResolverMatrix>
|
|
38
|
+
* listModels(): Promise<ModelAliasesResponse> // configured model aliases
|
|
39
|
+
* getRouting(): Promise<RoutingResponse> // per user_tier × tier cascade
|
|
40
|
+
* listProviderModels(providerId): Promise<ProviderModelsResponse> // admin token only
|
|
38
41
|
*
|
|
39
42
|
* // Snapshot lifecycle (v0.8.17/8.18)
|
|
40
43
|
* createSnapshot(opts?): Promise<SnapshotCreateResponse>
|
|
@@ -57,6 +60,10 @@
|
|
|
57
60
|
* backfillEmbeddings(scope, scopeId, opts?): Promise<MemoryBackfillResponse> // embeds rows that have none
|
|
58
61
|
* purgeStaleEmbeddings(scope, scopeId, opts?): Promise<MemoryPurgeResponse> // DELETES; dry_run defaults true
|
|
59
62
|
*
|
|
63
|
+
* // Memory / document change feeds (RFC CD Part C; SSE; substrate:tenant)
|
|
64
|
+
* streamMemoryChanges(opts?): AsyncIterable<ChangeFeedFrame> // feed | change | error (non-fatal); resume with opts.since = last seq
|
|
65
|
+
* streamDocumentChanges(opts?): AsyncIterable<ChangeFeedFrame>
|
|
66
|
+
*
|
|
60
67
|
* // Interruption (v0.8.16; decline RFC BH)
|
|
61
68
|
* listUserInterrupts(userId, opts?): Promise<InterruptListResponse>
|
|
62
69
|
* listRunInterrupts(runId, opts?): Promise<InterruptListResponse>
|
|
@@ -72,7 +79,27 @@
|
|
|
72
79
|
* mcpServerDef(input): Promise<SubstrateToolResponse>
|
|
73
80
|
* mcpServerDefVerify(name, sha): Promise<MCPServerDefVerifyResult> // v0.18.0
|
|
74
81
|
* ensureMcpServer(opts): Promise<EnsureMcpServerResult> // v0.18.0 — idempotent register-if-changed
|
|
75
|
-
* scheduleDef(input): Promise<SubstrateToolResponse>
|
|
82
|
+
* scheduleDef(input): Promise<SubstrateToolResponse> // input: ScheduleDefInput | SubstrateToolInput
|
|
83
|
+
* webhookDef(input): Promise<SubstrateToolResponse> // input: WebhookDefInput | SubstrateToolInput
|
|
84
|
+
* hookDef(input): Promise<SubstrateToolResponse> // input: HookDefInput | SubstrateToolInput
|
|
85
|
+
* ensureHookDef(name, overlay, opts?): Promise<EnsureHookDefResult> // idempotent register-if-changed
|
|
86
|
+
* createHookDef / forkHookDef(name, overlay, opts?) // overlay strictly typed, no loose fallback
|
|
87
|
+
* createScheduleDef / forkScheduleDef(name, overlay, opts?) // ditto (overlay open: types of known keys checked)
|
|
88
|
+
* createWebhookDef / forkWebhookDef(name, overlay, opts?) // ditto
|
|
89
|
+
*
|
|
90
|
+
* // Def name enumeration (tenant-scoped; opts.tenant = admin focus)
|
|
91
|
+
* listAgentDefNames / listSkillDefNames / listHookDefNames / listMcpServerDefNames /
|
|
92
|
+
* listScheduleDefNames / listA2AServerCardDefNames / listA2AAgentDefNames /
|
|
93
|
+
* listWebhookDefNames / listMemoryBackendDefNames / listDocumentSourceDefNames
|
|
94
|
+
* (opts?): Promise<DefNamesResponse>
|
|
95
|
+
* listOperatorTokenDefNames(opts?): Promise<OperatorTokenDefNamesResponse> // admin token only
|
|
96
|
+
*
|
|
97
|
+
* // Schedules admin
|
|
98
|
+
* listAllSchedules(opts?): Promise<ScheduleListResponse> // static yaml + ScheduleDefs, merged
|
|
99
|
+
* getScheduleState(defId): Promise<ScheduleStateView>
|
|
100
|
+
* runScheduleNow(defId): Promise<ScheduleRunNowResult> // fires on the next tick
|
|
101
|
+
* pauseSchedule(defId): Promise<SchedulePauseResult> // indefinite
|
|
102
|
+
* resumeSchedule(defId): Promise<SchedulePauseResult>
|
|
76
103
|
*
|
|
77
104
|
* // Dynamic filesystem volumes (v0.35.0 — RFC AH; tenant-confined)
|
|
78
105
|
* volumeDef(input): Promise<SubstrateToolResponse> // create/get/list/delete/purge
|
|
@@ -83,8 +110,8 @@
|
|
|
83
110
|
* listTeams(): Promise<ListTeamsResponse>
|
|
84
111
|
* renderTeamDiagram(name, opts?): Promise<TeamDiagram>
|
|
85
112
|
* getTeamDef(defId): Promise<TeamDefDetail>
|
|
86
|
-
* createTeam(name, overlay): Promise<CreatedTeam>
|
|
87
|
-
* forkTeam(name, overlay): Promise<CreatedTeam>
|
|
113
|
+
* createTeam(name, overlay, opts?): Promise<CreatedTeam> // opts: promote (default true) / description
|
|
114
|
+
* forkTeam(name, overlay, opts?): Promise<CreatedTeam> // opts: promote / parentDefId / description
|
|
88
115
|
* deleteTeam(name): Promise<{name, deleted}>
|
|
89
116
|
* runTeam({name|defId, input}): Promise<TeamRunResult>
|
|
90
117
|
* runTeam({..., mode:"detach"}): Promise<TeamRunDetached> // the handle, now — the walk runs on
|
|
@@ -98,6 +125,14 @@
|
|
|
98
125
|
* // History — browse/search/annotate past chats (RFC BE; tenant-confined)
|
|
99
126
|
* history(input): Promise<HistoryToolResponse> // list/get/search/rename/annotate/pin/archive/recap/resume
|
|
100
127
|
*
|
|
128
|
+
* // Evaluation — score runs and read the scores back, outside a run
|
|
129
|
+
* evaluation(input): Promise<…> // submit/get/list_for_run/list_for_def/aggregate
|
|
130
|
+
* submitEvaluation(runId, score, opts?): Promise<EvaluationSubmitResult>
|
|
131
|
+
* getEvaluation(evalId): Promise<EvaluationRecord>
|
|
132
|
+
* listEvaluationsForRun(runId, opts?): Promise<EvaluationListForRunResult>
|
|
133
|
+
* listEvaluationsForDef(defId, opts?): Promise<EvaluationListForDefResult>
|
|
134
|
+
* aggregateEvaluations(defId, opts?): Promise<EvaluationAggregate>
|
|
135
|
+
*
|
|
101
136
|
* // Library v2 enumeration (v0.10.3 — yaml+substrate merged)
|
|
102
137
|
* listLibraryAgents(): Promise<LibraryListResponse<LibraryAgentDefinition>>
|
|
103
138
|
* listLibrarySkills(): Promise<LibraryListResponse<LibrarySkillDefinition>>
|
|
@@ -114,13 +149,18 @@
|
|
|
114
149
|
* full HTTP-status → typed-error mapping table):
|
|
115
150
|
* LoomcycleError, AgentNotFoundError, SessionNotFoundError,
|
|
116
151
|
* SessionBusyError, AgentIDInUseError, BackpressureError,
|
|
117
|
-
*
|
|
152
|
+
* RateLimitedError (subclass of BackpressureError),
|
|
153
|
+
* PerUserQuotaExhaustedError, AuthError, InsufficientScopeError (a 403
|
|
154
|
+
* naming the scope the token lacks), UnavailableError,
|
|
155
|
+
* InvalidArgumentError,
|
|
118
156
|
* PauseNotConfiguredError (subclass of UnavailableError),
|
|
119
157
|
* AlreadyPausingError, NotPausedError, SnapshotNotFoundError,
|
|
120
158
|
* SnapshotTooLargeError, RequestTooLargeError + PromptTooLargeError
|
|
121
159
|
* (both subclasses of SnapshotTooLargeError), TokenLimitExceededError
|
|
122
|
-
* (
|
|
123
|
-
* SubstrateToolRefusedError (v0.8.22)
|
|
160
|
+
* (NOT a BackpressureError: a hard budget, not retryable),
|
|
161
|
+
* SnapshotVersionError, SubstrateToolRefusedError (v0.8.22).
|
|
162
|
+
* Every one carries `retryable` (true only for a refusal that clears
|
|
163
|
+
* on its own).
|
|
124
164
|
*
|
|
125
165
|
* Transport: HTTP+SSE. Auth: Bearer token via the Authorization
|
|
126
166
|
* header. Designed for Node ≥18 (engines pinned); Bun/Deno likely
|
|
@@ -131,5 +171,6 @@
|
|
|
131
171
|
*/
|
|
132
172
|
export { LoomcycleClient } from "./client.js";
|
|
133
173
|
export { InteractiveSession } from "./interactive.js";
|
|
174
|
+
export { verifyLoomcycleSignature, verifyLoomcycleDelivery } from "./signature.js";
|
|
134
175
|
export { ClientToolHost, clientToolsURL, CLIENT_TOOL_SUBPROTOCOL } from "./client-tools.js";
|
|
135
|
-
export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, NotFoundError, InvalidArgumentError, ChannelCursorRegressionError, LoomcycleError, NotPausedError, PauseNotConfiguredError, PerUserQuotaExhaustedError, PromptTooLargeError, RequestTooLargeError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, TokenLimitExceededError, UnavailableError, } from "./errors.js";
|
|
176
|
+
export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, NotFoundError, InsufficientScopeError, InvalidArgumentError, ChannelCursorRegressionError, LoomcycleError, NotPausedError, PauseNotConfiguredError, PerUserQuotaExhaustedError, PromptTooLargeError, RateLimitedError, RequestTooLargeError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, TokenLimitExceededError, UnavailableError, } from "./errors.js";
|
package/dist/interactive.js
CHANGED
|
@@ -46,7 +46,11 @@ export class InteractiveSession {
|
|
|
46
46
|
else if (ev.type === "session" && ev.session_id) {
|
|
47
47
|
this.sessionId = ev.session_id;
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// A queued/admitted frame reports the run's admission, not activity in
|
|
50
|
+
// its conversation, so it leaves awaitingInput as it was.
|
|
51
|
+
if (ev.type !== "queued" && ev.type !== "admitted") {
|
|
52
|
+
this.awaitingInput = ev.type === "awaiting_input";
|
|
53
|
+
}
|
|
50
54
|
yield ev;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifying a signed loomcycle hook delivery.
|
|
3
|
+
*
|
|
4
|
+
* A HookDef whose webhook body names a `signing_secret` signs every call it
|
|
5
|
+
* makes. The call carries
|
|
6
|
+
*
|
|
7
|
+
* X-Loomcycle-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
|
|
8
|
+
*
|
|
9
|
+
* where the MAC is keyed by the secret and computed over `<t>.<raw body>` —
|
|
10
|
+
* the exact bytes of the request body. Only the body is signed: the body's
|
|
11
|
+
* `delivery_id` (a retry keeps it) is what a receiver dedupes on, and its
|
|
12
|
+
* `hook_name` / `owner` say which hook sent it. `X-Loomcycle-Delivery` and
|
|
13
|
+
* `X-Loomcycle-Hook` are UNSIGNED copies of those two fields — a replayer can
|
|
14
|
+
* change them — so never dedupe or authorize on them.
|
|
15
|
+
* {@link verifyLoomcycleDelivery} verifies and returns the body's identity.
|
|
16
|
+
*
|
|
17
|
+
* Web Crypto only (`crypto.subtle`), so the same function runs in Node and in
|
|
18
|
+
* a browser or edge runtime. It is async because Web Crypto is.
|
|
19
|
+
*/
|
|
20
|
+
/** Options for {@link verifyLoomcycleSignature}. */
|
|
21
|
+
export interface VerifySignatureOptions {
|
|
22
|
+
/** How far `t` may be from now, either way, in seconds. Default 300 — the
|
|
23
|
+
* window loomcycle's own receiver uses. A larger window lets a captured
|
|
24
|
+
* delivery be replayed for longer. */
|
|
25
|
+
toleranceSec?: number;
|
|
26
|
+
/** The current time in milliseconds since the epoch, as `Date.now()`
|
|
27
|
+
* returns. For tests; defaults to `Date.now()`. */
|
|
28
|
+
now?: number;
|
|
29
|
+
/** The Web Crypto implementation to use. Defaults to
|
|
30
|
+
* `globalThis.crypto.subtle`, which Node 19+ and every browser have; on
|
|
31
|
+
* Node 18 pass `require("node:crypto").webcrypto.subtle`. */
|
|
32
|
+
subtle?: SubtleCrypto;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reports whether `header` (the `X-Loomcycle-Signature` value) is a valid
|
|
36
|
+
* signature of `rawBody` under `secret`, made within the tolerance of now.
|
|
37
|
+
*
|
|
38
|
+
* `rawBody` must be the request body exactly as received — the string or the
|
|
39
|
+
* bytes, before any JSON parsing. A body parsed and re-serialized will not
|
|
40
|
+
* match: key order, spacing and escapes all change the bytes. A string is
|
|
41
|
+
* hashed as its UTF-8 encoding.
|
|
42
|
+
*
|
|
43
|
+
* Fails closed: a missing or repeated-garbage header, a `t` that is not an
|
|
44
|
+
* integer, a stale or future `t`, a `v1` that is not hex, or a wrong MAC all
|
|
45
|
+
* return `false`, with no reason given. The MAC is compared by Web Crypto's
|
|
46
|
+
* HMAC verify, which compares in constant time. Throws only when no Web Crypto
|
|
47
|
+
* implementation is available (a configuration problem, not a bad request).
|
|
48
|
+
*/
|
|
49
|
+
export declare function verifyLoomcycleSignature(rawBody: string | Uint8Array, header: string, secret: string, opts?: VerifySignatureOptions): Promise<boolean>;
|
|
50
|
+
/** A delivery {@link verifyLoomcycleDelivery} verified: what to dedupe on and
|
|
51
|
+
* which hook sent it, read from the signed body, plus the parsed body. */
|
|
52
|
+
export interface VerifiedDelivery<T = Record<string, unknown>> {
|
|
53
|
+
/** The body's `delivery_id`. Dedupe on this — keep each id at least as long
|
|
54
|
+
* as the tolerance window — never on the `X-Loomcycle-Delivery` header,
|
|
55
|
+
* which is an unsigned copy a replayer can change. */
|
|
56
|
+
deliveryId: string;
|
|
57
|
+
/** The body's `hook_name`: the HookDef that sent it. The `X-Loomcycle-Hook`
|
|
58
|
+
* header is an unsigned copy. */
|
|
59
|
+
hookName: string;
|
|
60
|
+
/** The body's `owner` (`agent:<name>`, `team:<name>`, `channel:<name>`). */
|
|
61
|
+
owner: string;
|
|
62
|
+
/** The parsed body. */
|
|
63
|
+
body: T;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* {@link verifyLoomcycleSignature}, then parse the verified body and return
|
|
67
|
+
* the identity a receiver should act on: its `delivery_id`, `hook_name` and
|
|
68
|
+
* `owner`. Resolves `null` when the signature does not verify, or the body is
|
|
69
|
+
* not a JSON object carrying a non-empty string `delivery_id` and `hook_name`
|
|
70
|
+
* — a signed delivery always has both. Deduplicating on `deliveryId` stays the
|
|
71
|
+
* caller's job.
|
|
72
|
+
*/
|
|
73
|
+
export declare function verifyLoomcycleDelivery<T = Record<string, unknown>>(rawBody: string | Uint8Array, header: string, secret: string, opts?: VerifySignatureOptions): Promise<VerifiedDelivery<T> | null>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifying a signed loomcycle hook delivery.
|
|
3
|
+
*
|
|
4
|
+
* A HookDef whose webhook body names a `signing_secret` signs every call it
|
|
5
|
+
* makes. The call carries
|
|
6
|
+
*
|
|
7
|
+
* X-Loomcycle-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
|
|
8
|
+
*
|
|
9
|
+
* where the MAC is keyed by the secret and computed over `<t>.<raw body>` —
|
|
10
|
+
* the exact bytes of the request body. Only the body is signed: the body's
|
|
11
|
+
* `delivery_id` (a retry keeps it) is what a receiver dedupes on, and its
|
|
12
|
+
* `hook_name` / `owner` say which hook sent it. `X-Loomcycle-Delivery` and
|
|
13
|
+
* `X-Loomcycle-Hook` are UNSIGNED copies of those two fields — a replayer can
|
|
14
|
+
* change them — so never dedupe or authorize on them.
|
|
15
|
+
* {@link verifyLoomcycleDelivery} verifies and returns the body's identity.
|
|
16
|
+
*
|
|
17
|
+
* Web Crypto only (`crypto.subtle`), so the same function runs in Node and in
|
|
18
|
+
* a browser or edge runtime. It is async because Web Crypto is.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_TOLERANCE_SEC = 300;
|
|
21
|
+
const HEX_RE = /^(?:[0-9a-fA-F]{2})+$/;
|
|
22
|
+
// What Go's strconv.ParseInt accepts, so a header loomcycle's own receiver
|
|
23
|
+
// would accept is accepted here too.
|
|
24
|
+
const UNIX_RE = /^[+-]?\d+$/;
|
|
25
|
+
/**
|
|
26
|
+
* Reports whether `header` (the `X-Loomcycle-Signature` value) is a valid
|
|
27
|
+
* signature of `rawBody` under `secret`, made within the tolerance of now.
|
|
28
|
+
*
|
|
29
|
+
* `rawBody` must be the request body exactly as received — the string or the
|
|
30
|
+
* bytes, before any JSON parsing. A body parsed and re-serialized will not
|
|
31
|
+
* match: key order, spacing and escapes all change the bytes. A string is
|
|
32
|
+
* hashed as its UTF-8 encoding.
|
|
33
|
+
*
|
|
34
|
+
* Fails closed: a missing or repeated-garbage header, a `t` that is not an
|
|
35
|
+
* integer, a stale or future `t`, a `v1` that is not hex, or a wrong MAC all
|
|
36
|
+
* return `false`, with no reason given. The MAC is compared by Web Crypto's
|
|
37
|
+
* HMAC verify, which compares in constant time. Throws only when no Web Crypto
|
|
38
|
+
* implementation is available (a configuration problem, not a bad request).
|
|
39
|
+
*/
|
|
40
|
+
export async function verifyLoomcycleSignature(rawBody, header, secret, opts = {}) {
|
|
41
|
+
const subtle = opts.subtle ?? globalThis.crypto?.subtle;
|
|
42
|
+
if (!subtle) {
|
|
43
|
+
throw new TypeError("verifyLoomcycleSignature: no Web Crypto implementation; pass opts.subtle (on Node 18: require('node:crypto').webcrypto.subtle)");
|
|
44
|
+
}
|
|
45
|
+
if (typeof header !== "string" || typeof secret !== "string" || secret === "") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
let ts = "";
|
|
49
|
+
let macHex = "";
|
|
50
|
+
for (const raw of header.split(",")) {
|
|
51
|
+
const part = raw.trim();
|
|
52
|
+
if (part.startsWith("t="))
|
|
53
|
+
ts = part.slice(2);
|
|
54
|
+
else if (part.startsWith("v1="))
|
|
55
|
+
macHex = part.slice(3);
|
|
56
|
+
}
|
|
57
|
+
if (!UNIX_RE.test(ts) || !HEX_RE.test(macHex)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const t = Number(ts);
|
|
61
|
+
if (!Number.isSafeInteger(t)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const tolerance = opts.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
|
|
65
|
+
const nowSec = (opts.now ?? Date.now()) / 1000;
|
|
66
|
+
if (!(tolerance >= 0) || Math.abs(nowSec - t) > tolerance) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const enc = new TextEncoder();
|
|
70
|
+
const body = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
|
|
71
|
+
// The MAC covers t exactly as written in the header, then ".", then the body.
|
|
72
|
+
const prefix = enc.encode(ts + ".");
|
|
73
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
74
|
+
signed.set(prefix, 0);
|
|
75
|
+
signed.set(body, prefix.length);
|
|
76
|
+
const mac = new Uint8Array(macHex.length / 2);
|
|
77
|
+
for (let i = 0; i < mac.length; i++) {
|
|
78
|
+
mac[i] = parseInt(macHex.slice(i * 2, i * 2 + 2), 16);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const key = await subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
82
|
+
return await subtle.verify("HMAC", key, mac, signed);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* {@link verifyLoomcycleSignature}, then parse the verified body and return
|
|
90
|
+
* the identity a receiver should act on: its `delivery_id`, `hook_name` and
|
|
91
|
+
* `owner`. Resolves `null` when the signature does not verify, or the body is
|
|
92
|
+
* not a JSON object carrying a non-empty string `delivery_id` and `hook_name`
|
|
93
|
+
* — a signed delivery always has both. Deduplicating on `deliveryId` stays the
|
|
94
|
+
* caller's job.
|
|
95
|
+
*/
|
|
96
|
+
export async function verifyLoomcycleDelivery(rawBody, header, secret, opts = {}) {
|
|
97
|
+
if (!(await verifyLoomcycleSignature(rawBody, header, secret, opts))) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
let body;
|
|
101
|
+
try {
|
|
102
|
+
body = JSON.parse(typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const { delivery_id: deliveryId, hook_name: hookName, owner } = body;
|
|
111
|
+
if (typeof deliveryId !== "string" || deliveryId === "" || typeof hookName !== "string" || hookName === "") {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return { deliveryId, hookName, owner: typeof owner === "string" ? owner : "", body: body };
|
|
115
|
+
}
|