@substrat-run/vertical-host 0.148.0 → 0.149.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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The outbound calls a request made, on its invocation line (#2192).
3
+ *
4
+ * A request that failed BECAUSE of an upstream answer — an OAuth callback whose token
5
+ * exchange was refused — used to show a single `request` span: the call that actually
6
+ * failed was nowhere on the timeline. So the platform's entry records every `fetch` the
7
+ * request makes, the way it records its store calls (#2245): the request runs in an
8
+ * `AsyncLocalStorage` holding its record, and a wrapped `globalThis.fetch` adds each call to
9
+ * whichever record is current. Module code cannot fetch (boundary-lint R3); what is recorded
10
+ * is the vertical's host code — its connectors and harness routes.
11
+ *
12
+ * ## What is written, and what never is
13
+ *
14
+ * The host, the method, the status, the time to the response headers, when in the request
15
+ * the call started, and how it ended. Never a header, a body, a query string, a fragment or
16
+ * userinfo, and the path only after {@link redactOutboundPath}: a path segment can be a
17
+ * credential (an API key, a bot token), and a log line is read by a wider audience, for
18
+ * longer, than the call ever had.
19
+ *
20
+ * A refusal by the PLATFORM — the egress worker answering a host the vertical never declared —
21
+ * is recorded as `refused`, read off `x-substrat-egress: refused`. To the vertical's code it is
22
+ * a bare 403, the same as the destination's own; on the line the two read differently.
23
+ *
24
+ * ## What it does not see
25
+ *
26
+ * - A `fetch` made inside a Durable Object (a consumer, a schedule, the drain): no request's
27
+ * record is current there.
28
+ * - A `fetch` still running after the line was written (`waitUntil` work).
29
+ * - A reference to `fetch` a bundle captured while its modules were evaluated: the entry
30
+ * installs the wrapper after that, because the vertical's import is evaluated first.
31
+ *
32
+ * And one property to know: a call is attributed to the request whose `AsyncLocalStorage`
33
+ * context it runs in, as a store call is. A call a promise shared across requests makes lands on
34
+ * the line of the request it runs under.
35
+ *
36
+ * ## It cannot fail the call
37
+ *
38
+ * Outside a request the wrapper hands the call straight to the runtime's `fetch`, and inside
39
+ * one it passes the arguments on untouched — the body is never read, the signal never
40
+ * replaced — and hands back the runtime's own response or rejection. Recording is advisory: a
41
+ * throw from it is swallowed, never the call's.
42
+ */
43
+ import { type OutboundCallTally } from '@substrat-run/kernel/invocation-line';
44
+ /**
45
+ * The response header the egress worker marks its own refusals with — its `EGRESS_HEADER`
46
+ * (`apps/vertical-egress/src/worker.ts`). Spelled twice, because the worker is not a package
47
+ * this one can import; `outbound-calls.test.ts` holds the two equal.
48
+ */
49
+ export declare const EGRESS_REFUSAL_HEADER = "x-substrat-egress";
50
+ /**
51
+ * Whether one path segment is kept as it is. Default-deny: a segment of at most 40 characters
52
+ * is kept ONLY when it is lowercase words (at most 4, each at most 16 letters, joined by `-` or
53
+ * `_`), optionally with a short file extension, an integer of at most 10 digits, or a version token (`v1`, `v2.1`). Everything else
54
+ * is redacted — every UUID and ULID, any segment mixing letters and digits (`oauth2`, `abc1234`),
55
+ * anything with an uppercase letter (an owner or a repository named `Acme`), and any longer
56
+ * number. An id in a path is not needed to diagnose a failed call: the host, the method, the
57
+ * status, the outcome and the path's shape are.
58
+ *
59
+ * What still survives, stated: a lowercase-letter word of up to 16 letters (per part) and an
60
+ * integer of up to 10 digits are written as they are, whatever they mean to the destination.
61
+ */
62
+ export declare function keepsSegment(segment: string): boolean;
63
+ /**
64
+ * The path of an outbound URL as the line writes it: each segment that {@link keepsSegment}
65
+ * does not keep is `[redacted]`, and the result is cut at {@link PATH_MAX} characters. The
66
+ * query string and fragment are not part of a pathname and so never reach it.
67
+ */
68
+ export declare function redactOutboundPath(pathname: string): string;
69
+ /** The tally a request's calls go on: the invocation record, or anything shaped like it. */
70
+ export type OutboundRecording = OutboundCallTally;
71
+ /** Note when the request a recording belongs to started. Never throws. */
72
+ export declare function markRequestStart(recording: OutboundRecording, started: number): void;
73
+ /** What the wrapper needs from the platform: the recording of the request now running. */
74
+ export type CurrentRecording = () => OutboundRecording | undefined;
75
+ type FetchFn = (input: unknown, init?: unknown) => Promise<{
76
+ status: number;
77
+ headers?: {
78
+ get?(name: string): string | null;
79
+ };
80
+ }>;
81
+ /**
82
+ * Wrap `fetch` so each call made while a request's recording is current is added to it.
83
+ * Exported for the tests; the platform installs it with {@link installOutboundRecorder}.
84
+ */
85
+ export declare function recordingFetch<F extends FetchFn>(original: F, current: CurrentRecording): F;
86
+ /**
87
+ * Put the recorder in front of the isolate's `fetch`, once. A `fetch` that already carries the
88
+ * mark — installed by this request's entry, a previous one, or another copy of this module — is
89
+ * left as it is, so evaluating the entry again never wraps twice. Something that replaced
90
+ * `globalThis.fetch` since (a test's mock) is wrapped in turn. Never throws.
91
+ */
92
+ export declare function installOutboundRecorder(current: CurrentRecording): void;
93
+ export {};
94
+ //# sourceMappingURL=outbound-calls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound-calls.d.ts","sourceRoot":"","sources":["../src/outbound-calls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,sCAAsC,CAAC;AAE9C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAyBzD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CASrD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM3D;AAiCD,4FAA4F;AAC5F,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,CAAC;AAelD,0EAA0E;AAC1E,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAMpF;AAED,0FAA0F;AAC1F,MAAM,MAAM,gBAAgB,GAAG,MAAM,iBAAiB,GAAG,SAAS,CAAC;AA0CnE,KAAK,OAAO,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE;QAAE,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,CAAC,CAAC;AAiBhI;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,OAAO,EAAE,gBAAgB,GAAG,CAAC,CAsD3F;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CASvE"}
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The outbound calls a request made, on its invocation line (#2192).
3
+ *
4
+ * A request that failed BECAUSE of an upstream answer — an OAuth callback whose token
5
+ * exchange was refused — used to show a single `request` span: the call that actually
6
+ * failed was nowhere on the timeline. So the platform's entry records every `fetch` the
7
+ * request makes, the way it records its store calls (#2245): the request runs in an
8
+ * `AsyncLocalStorage` holding its record, and a wrapped `globalThis.fetch` adds each call to
9
+ * whichever record is current. Module code cannot fetch (boundary-lint R3); what is recorded
10
+ * is the vertical's host code — its connectors and harness routes.
11
+ *
12
+ * ## What is written, and what never is
13
+ *
14
+ * The host, the method, the status, the time to the response headers, when in the request
15
+ * the call started, and how it ended. Never a header, a body, a query string, a fragment or
16
+ * userinfo, and the path only after {@link redactOutboundPath}: a path segment can be a
17
+ * credential (an API key, a bot token), and a log line is read by a wider audience, for
18
+ * longer, than the call ever had.
19
+ *
20
+ * A refusal by the PLATFORM — the egress worker answering a host the vertical never declared —
21
+ * is recorded as `refused`, read off `x-substrat-egress: refused`. To the vertical's code it is
22
+ * a bare 403, the same as the destination's own; on the line the two read differently.
23
+ *
24
+ * ## What it does not see
25
+ *
26
+ * - A `fetch` made inside a Durable Object (a consumer, a schedule, the drain): no request's
27
+ * record is current there.
28
+ * - A `fetch` still running after the line was written (`waitUntil` work).
29
+ * - A reference to `fetch` a bundle captured while its modules were evaluated: the entry
30
+ * installs the wrapper after that, because the vertical's import is evaluated first.
31
+ *
32
+ * And one property to know: a call is attributed to the request whose `AsyncLocalStorage`
33
+ * context it runs in, as a store call is. A call a promise shared across requests makes lands on
34
+ * the line of the request it runs under.
35
+ *
36
+ * ## It cannot fail the call
37
+ *
38
+ * Outside a request the wrapper hands the call straight to the runtime's `fetch`, and inside
39
+ * one it passes the arguments on untouched — the body is never read, the signal never
40
+ * replaced — and hands back the runtime's own response or rejection. Recording is advisory: a
41
+ * throw from it is swallowed, never the call's.
42
+ */
43
+ import { tallyOutboundCall, } from '@substrat-run/kernel/invocation-line';
44
+ /**
45
+ * The response header the egress worker marks its own refusals with — its `EGRESS_HEADER`
46
+ * (`apps/vertical-egress/src/worker.ts`). Spelled twice, because the worker is not a package
47
+ * this one can import; `outbound-calls.test.ts` holds the two equal.
48
+ */
49
+ export const EGRESS_REFUSAL_HEADER = 'x-substrat-egress';
50
+ /** The scope-store transport's host: those calls are already named, as `storeCalls` (#2245). */
51
+ const STORE_CALL_HOST = 'store.substrat.internal';
52
+ /** What a redacted segment is written as. */
53
+ const REDACTED = '[redacted]';
54
+ /** A path longer than this is cut, after redaction. */
55
+ const PATH_MAX = 200;
56
+ /**
57
+ * Lowercase words, at most 4 of them joined by `-` or `_`, each at most 16 letters:
58
+ * `messages`, `oauth_token`, `access-token`. Past four parts a segment is a token chunked by
59
+ * dashes, not a name.
60
+ */
61
+ const WORDS = /^[a-z]{1,16}(?:[-_][a-z]{1,16}){0,3}$/;
62
+ /** The same words with a short file extension: `me.json`, `token.json`, `clip.mp4`. */
63
+ const WORDS_WITH_EXTENSION = /^[a-z]{1,16}(?:[-_][a-z]{1,16}){0,3}\.[a-z][a-z0-9]{0,4}$/;
64
+ /** No kept segment is longer than this, whatever it is made of. */
65
+ const SEGMENT_MAX = 40;
66
+ /** An integer of at most 10 digits: a page number, a small numeric id. */
67
+ const SMALL_INTEGER = /^[0-9]{1,10}$/;
68
+ /** A version: `v1`, `v2.1`, `v10.0.3`. */
69
+ const VERSION = /^v[0-9]{1,3}(?:\.[0-9]{1,3}){0,2}$/;
70
+ /**
71
+ * Whether one path segment is kept as it is. Default-deny: a segment of at most 40 characters
72
+ * is kept ONLY when it is lowercase words (at most 4, each at most 16 letters, joined by `-` or
73
+ * `_`), optionally with a short file extension, an integer of at most 10 digits, or a version token (`v1`, `v2.1`). Everything else
74
+ * is redacted — every UUID and ULID, any segment mixing letters and digits (`oauth2`, `abc1234`),
75
+ * anything with an uppercase letter (an owner or a repository named `Acme`), and any longer
76
+ * number. An id in a path is not needed to diagnose a failed call: the host, the method, the
77
+ * status, the outcome and the path's shape are.
78
+ *
79
+ * What still survives, stated: a lowercase-letter word of up to 16 letters (per part) and an
80
+ * integer of up to 10 digits are written as they are, whatever they mean to the destination.
81
+ */
82
+ export function keepsSegment(segment) {
83
+ if (segment.length > SEGMENT_MAX)
84
+ return false;
85
+ return (segment === '' ||
86
+ WORDS.test(segment) ||
87
+ WORDS_WITH_EXTENSION.test(segment) ||
88
+ SMALL_INTEGER.test(segment) ||
89
+ VERSION.test(segment));
90
+ }
91
+ /**
92
+ * The path of an outbound URL as the line writes it: each segment that {@link keepsSegment}
93
+ * does not keep is `[redacted]`, and the result is cut at {@link PATH_MAX} characters. The
94
+ * query string and fragment are not part of a pathname and so never reach it.
95
+ */
96
+ export function redactOutboundPath(pathname) {
97
+ const path = pathname
98
+ .split('/')
99
+ .map((segment) => (keepsSegment(segment) ? segment : REDACTED))
100
+ .join('/');
101
+ return path.length > PATH_MAX ? `${path.slice(0, PATH_MAX)}…` : path;
102
+ }
103
+ /**
104
+ * A thrown value's class, when it reads as one — a capitalised name of letters only, ending in
105
+ * `Error` or `Exception` (`TypeError`, `TimeoutError`, `DOMException`) — and `Error` otherwise.
106
+ * Never its message. The name comes from whatever threw, a wrapper library included, so anything
107
+ * else it holds is not written.
108
+ */
109
+ const ERROR_CLASS = /^[A-Z][A-Za-z]{0,30}(?:Error|Exception)$/;
110
+ function errorName(e) {
111
+ const name = e?.name;
112
+ return typeof name === 'string' && ERROR_CLASS.test(name) ? name : 'Error';
113
+ }
114
+ /**
115
+ * How a call that answered ended. `refused` only for the platform's own refusal: a 403 carrying
116
+ * exactly `x-substrat-egress: refused`. The egress worker strips the mark from every answer it
117
+ * passes through, and the status is required too, so a destination cannot make its own answer
118
+ * read as the platform's.
119
+ */
120
+ function answeredOutcome(response) {
121
+ if (response.status === 403 && response.headers?.get?.(EGRESS_REFUSAL_HEADER) === 'refused')
122
+ return 'refused';
123
+ return response.status >= 400 ? 'http-error' : 'ok';
124
+ }
125
+ /** How a call that rejected ended, and the class to name when it is `failed`. */
126
+ function rejectedOutcome(e) {
127
+ const name = errorName(e);
128
+ if (name === 'TimeoutError')
129
+ return { outcome: 'timeout' };
130
+ if (name === 'AbortError')
131
+ return { outcome: 'aborted' };
132
+ return { outcome: 'failed', reason: name };
133
+ }
134
+ /**
135
+ * When each recording's request started — what a call's `atMs` is measured from. Kept beside
136
+ * the record rather than on it, since the record is handed to the vertical's handler chain, and
137
+ * under a GLOBAL symbol, since the copy that starts a request and the copy that recorded the
138
+ * call may be different module instances (the stamp registry's reason).
139
+ */
140
+ const SINCE = Symbol.for('substrat.outbound-since');
141
+ function sinceMap() {
142
+ const g = globalThis;
143
+ return (g[SINCE] ??= new WeakMap());
144
+ }
145
+ /** Note when the request a recording belongs to started. Never throws. */
146
+ export function markRequestStart(recording, started) {
147
+ try {
148
+ sinceMap().set(recording, started);
149
+ }
150
+ catch {
151
+ /* calls then sit at the request's start */
152
+ }
153
+ }
154
+ /** The destination of a `fetch`'s arguments, read without touching a body. `null` when unreadable. */
155
+ function destinationOf(input, init) {
156
+ const fromRequest = typeof input === 'object' && input !== null && !(input instanceof URL);
157
+ const raw = fromRequest ? input.url : input;
158
+ let url;
159
+ try {
160
+ url = new URL(String(raw));
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ const initMethod = init?.method;
166
+ const requestMethod = fromRequest ? input.method : undefined;
167
+ const method = typeof initMethod === 'string' ? initMethod : typeof requestMethod === 'string' ? requestMethod : 'GET';
168
+ return { url, method: method.toUpperCase().slice(0, 16) };
169
+ }
170
+ /** Add one finished call to the recording. Never throws. */
171
+ function record(recording, destination, startedAt, end) {
172
+ try {
173
+ const call = {
174
+ host: destination.url.host.toLowerCase(),
175
+ path: redactOutboundPath(destination.url.pathname),
176
+ method: destination.method,
177
+ status: end.status,
178
+ outcome: end.outcome,
179
+ durationMs: Math.max(0, Date.now() - startedAt),
180
+ atMs: Math.max(0, startedAt - (sinceMap().get(recording) ?? startedAt)),
181
+ ...(end.reason !== undefined ? { reason: end.reason } : {}),
182
+ };
183
+ tallyOutboundCall(recording, call);
184
+ }
185
+ catch {
186
+ // Recording is advisory: a call that cannot be recorded is one missing entry.
187
+ }
188
+ }
189
+ /** The mark a wrapped `fetch` carries, shared by every copy of this module in the isolate. */
190
+ const WRAPPED = Symbol.for('substrat.outbound-fetch');
191
+ /**
192
+ * Set while a recorder is handing a call on, synchronously, and cleared before it returns.
193
+ *
194
+ * Something that patches `globalThis.fetch` after the recorder is installed (instrumentation
195
+ * that installs lazily) is wrapped again by the next request's install, so one call can pass
196
+ * through two recorders: recorder → patch → recorder → runtime. The inner one sees this set and
197
+ * hands the call straight on, so the call is recorded once, by the outermost recorder. It spans
198
+ * only the synchronous part of the call, so concurrent calls never see each other's: a patch that
199
+ * reaches the inner recorder only after an `await` is still recorded twice.
200
+ */
201
+ const IN_CALL = Symbol.for('substrat.outbound-fetch.in-call');
202
+ /**
203
+ * Wrap `fetch` so each call made while a request's recording is current is added to it.
204
+ * Exported for the tests; the platform installs it with {@link installOutboundRecorder}.
205
+ */
206
+ export function recordingFetch(original, current) {
207
+ const wrapped = function fetch(input, init) {
208
+ const g = globalThis;
209
+ // An outer recorder is already recording this call: hand it on, unrecorded.
210
+ if (g[IN_CALL] === true)
211
+ return original.call(globalThis, input, init);
212
+ let recording;
213
+ let destination = null;
214
+ try {
215
+ recording = current();
216
+ if (recording)
217
+ destination = destinationOf(input, init);
218
+ }
219
+ catch {
220
+ recording = undefined;
221
+ }
222
+ // The runtime's `fetch` is called on `globalThis` — workerd refuses any other receiver.
223
+ if (!recording || !destination || destination.url.hostname.toLowerCase() === STORE_CALL_HOST) {
224
+ return original.call(globalThis, input, init);
225
+ }
226
+ const rec = recording;
227
+ const dest = destination;
228
+ const startedAt = Date.now();
229
+ let pending;
230
+ g[IN_CALL] = true;
231
+ try {
232
+ pending = original.call(globalThis, input, init);
233
+ }
234
+ finally {
235
+ g[IN_CALL] = false;
236
+ }
237
+ return pending.then((response) => {
238
+ let status = null;
239
+ let outcome = 'failed';
240
+ try {
241
+ status = response.status;
242
+ outcome = answeredOutcome(response);
243
+ }
244
+ catch {
245
+ /* recorded as far as it was read */
246
+ }
247
+ record(rec, dest, startedAt, { status, outcome });
248
+ return response;
249
+ }, (e) => {
250
+ let end = { outcome: 'failed', reason: 'Error' };
251
+ try {
252
+ end = rejectedOutcome(e);
253
+ }
254
+ catch {
255
+ /* recorded as a plain failure */
256
+ }
257
+ record(rec, dest, startedAt, { status: null, ...end });
258
+ throw e;
259
+ });
260
+ };
261
+ Object.defineProperty(wrapped, WRAPPED, { value: true });
262
+ return wrapped;
263
+ }
264
+ /**
265
+ * Put the recorder in front of the isolate's `fetch`, once. A `fetch` that already carries the
266
+ * mark — installed by this request's entry, a previous one, or another copy of this module — is
267
+ * left as it is, so evaluating the entry again never wraps twice. Something that replaced
268
+ * `globalThis.fetch` since (a test's mock) is wrapped in turn. Never throws.
269
+ */
270
+ export function installOutboundRecorder(current) {
271
+ try {
272
+ const g = globalThis;
273
+ const existing = g.fetch;
274
+ if (typeof existing !== 'function' || existing[WRAPPED])
275
+ return;
276
+ g.fetch = recordingFetch(existing, current);
277
+ }
278
+ catch {
279
+ // A runtime whose `fetch` cannot be replaced simply has no outbound calls on the line.
280
+ }
281
+ }
282
+ //# sourceMappingURL=outbound-calls.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound-calls.js","sourceRoot":"","sources":["../src/outbound-calls.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"peer-client.d.ts","sourceRoot":"","sources":["../src/peer-client.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,WAAW,UAAU;IACzB,uEAAuE;IACvE,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAC5G;AAED,MAAM,WAAW,iBAAiB;IAChC,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAaD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAkCxF"}
1
+ {"version":3,"file":"peer-client.d.ts","sourceRoot":"","sources":["../src/peer-client.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,WAAW,UAAU;IACzB,uEAAuE;IACvE,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAC5G;AAED,MAAM,WAAW,iBAAiB;IAChC,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAaD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAqCxF"}
@@ -1,5 +1,6 @@
1
1
  import { globalFetch } from '@substrat-run/kernel';
2
- import { PEER_CALL_URL, peerCallResponse, substratError } from '@substrat-run/contracts';
2
+ import { currentInvocationId } from '@substrat-run/kernel/invocation-line';
3
+ import { PARENT_REQUEST_ID_HEADER, parentRequestIdOf, PEER_CALL_URL, peerCallResponse, substratError, } from '@substrat-run/contracts';
3
4
  /** What a refused peer call throws: the platform's code and the reason, not a bare status. */
4
5
  const REFUSAL_BY_STATUS = {
5
6
  400: 'validation_failed',
@@ -14,9 +15,12 @@ export function peerClient(vertical, options = {}) {
14
15
  const call = options.fetch ?? globalFetch;
15
16
  return {
16
17
  async invoke(operation, input, invokeOptions) {
18
+ // #2192: the request this call is made from, so the target's line can name it as its
19
+ // parent. Absent outside a stamped request, and on a platform entry that predates it.
20
+ const parent = parentRequestIdOf(currentInvocationId());
17
21
  const response = await call(PEER_CALL_URL, {
18
22
  method: 'POST',
19
- headers: { 'content-type': 'application/json' },
23
+ headers: { 'content-type': 'application/json', ...(parent ? { [PARENT_REQUEST_ID_HEADER]: parent } : {}) },
20
24
  body: JSON.stringify({
21
25
  vertical,
22
26
  operation,
@@ -1 +1 @@
1
- {"version":3,"file":"peer-client.js","sourceRoot":"","sources":["../src/peer-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAkB,MAAM,yBAAyB,CAAC;AAqCzG,8FAA8F;AAC9F,MAAM,iBAAiB,GAA8B;IACnD,GAAG,EAAE,mBAAmB;IACxB,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,UAAU;IACf,GAAG,EAAE,cAAc;IACnB,GAAG,EAAE,aAAa;IAClB,GAAG,EAAE,aAAa;CACnB,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,QAAgB,EAAE,OAAO,GAAsB,EAAE;IAC1E,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,IAAI,WAAW,CAAC;IAC1C,OAAO;QACL,KAAK,CAAC,MAAM,CAAI,SAAiB,EAAE,KAAe,EAAE,aAA2C;YAC7F,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE;gBACzC,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,QAAQ;oBACR,SAAS;oBACT,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;oBACzC,GAAG,CAAC,aAAa,EAAE,cAAc,KAAK,SAAS;wBAC7C,CAAC,CAAC,EAAE;wBACJ,CAAC,CAAC,EAAE,cAAc,EAAE,aAAa,CAAC,cAAc,EAAE,CAAC;iBACtD,CAAC;aACH,CAAC,CAAC;YACH,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAiD,CAAC;YACvG,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,+EAA+E;gBAC/E,oFAAoF;gBACpF,+EAA+E;gBAC/E,4BAA4B;gBAC5B,MAAM,aAAa,CACjB,iBAAiB,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,UAAU,EAChD,OAAO,IAAI,EAAE,KAAK,KAAK,QAAQ;oBAC7B,CAAC,CAAC,IAAI,CAAC,KAAK;oBACZ,CAAC,CAAC,gBAAgB,QAAQ,kBAAkB,QAAQ,CAAC,MAAM,GAAG,CACjE,CAAC;YACJ,CAAC;YACD,MAAM,MAAM,GAAG,gBAAgB,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YAChD,IAAI,CAAC,MAAM,CAAC,OAAO;gBAAE,MAAM,aAAa,CAAC,aAAa,EAAE,+BAA+B,QAAQ,GAAG,CAAC,CAAC;YACpG,OAAO,MAAM,CAAC,IAAI,CAAC,MAAW,CAAC;QACjC,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"peer-client.js","sourceRoot":"","sources":["../src/peer-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,gBAAgB,EAChB,aAAa,GAEd,MAAM,yBAAyB,CAAC;AAqCjC,8FAA8F;AAC9F,MAAM,iBAAiB,GAA8B;IACnD,GAAG,EAAE,mBAAmB;IACxB,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,UAAU;IACf,GAAG,EAAE,cAAc;IACnB,GAAG,EAAE,aAAa;IAClB,GAAG,EAAE,aAAa;CACnB,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,QAAgB,EAAE,OAAO,GAAsB,EAAE;IAC1E,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,IAAI,WAAW,CAAC;IAC1C,OAAO;QACL,KAAK,CAAC,MAAM,CAAI,SAAiB,EAAE,KAAe,EAAE,aAA2C;YAC7F,qFAAqF;YACrF,sFAAsF;YACtF,MAAM,MAAM,GAAG,iBAAiB,CAAC,mBAAmB,EAAE,CAAC,CAAC;YACxD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE;gBACzC,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,wBAAwB,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE;gBAC1G,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,QAAQ;oBACR,SAAS;oBACT,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;oBACzC,GAAG,CAAC,aAAa,EAAE,cAAc,KAAK,SAAS;wBAC7C,CAAC,CAAC,EAAE;wBACJ,CAAC,CAAC,EAAE,cAAc,EAAE,aAAa,CAAC,cAAc,EAAE,CAAC;iBACtD,CAAC;aACH,CAAC,CAAC;YACH,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAiD,CAAC;YACvG,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,+EAA+E;gBAC/E,oFAAoF;gBACpF,+EAA+E;gBAC/E,4BAA4B;gBAC5B,MAAM,aAAa,CACjB,iBAAiB,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,UAAU,EAChD,OAAO,IAAI,EAAE,KAAK,KAAK,QAAQ;oBAC7B,CAAC,CAAC,IAAI,CAAC,KAAK;oBACZ,CAAC,CAAC,gBAAgB,QAAQ,kBAAkB,QAAQ,CAAC,MAAM,GAAG,CACjE,CAAC;YACJ,CAAC;YACD,MAAM,MAAM,GAAG,gBAAgB,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YAChD,IAAI,CAAC,MAAM,CAAC,OAAO;gBAAE,MAAM,aAAa,CAAC,aAAa,EAAE,+BAA+B,QAAQ,GAAG,CAAC,CAAC;YACpG,OAAO,MAAM,CAAC,IAAI,CAAC,MAAW,CAAC;QACjC,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -25,10 +25,22 @@
25
25
  * target's `writePermission` as them, now. The link is spent in that transaction, so every
26
26
  * failure leaves it unspent.
27
27
  *
28
- * Answers: **201** `{ id, filename, contentType, size, sha256 }`; **404** for a secret that
29
- * names nothing and for a path naming another target than the link's (nothing is spent);
30
- * **410** for a link already used, expired or revoked; **413** over the link's size;
31
- * **415** for a type the link refuses; **403** when the minter no longer holds the key.
28
+ * Answers: **201** `{ id, filename, contentType, size, sha256 }`; **200** with the same body for
29
+ * a replay (below); **404** for a secret that
30
+ * names nothing — a malformed one answered here, without asking the scope — and for a path
31
+ * naming another target than the link's, whatever the link's state (nothing is spent);
32
+ * **410** for a link already used, expired or revoked at its own path; **413** over the link's size;
33
+ * **415** for a type the link refuses; **403** when the minter no longer holds the key; **422**
34
+ * for a replay whose bytes are not the stored file; **400** for an empty body or a malformed
35
+ * `Idempotency-Key`.
36
+ *
37
+ * **A lost reply is retryable (#2315).** A request may name itself with `Idempotency-Key` (16 to
38
+ * 255 visible ASCII characters other than `"` and `\`, such as a UUID; a quoted structured-field
39
+ * string is read without its quotes). The spend records the key's hash. A later request for the
40
+ * SAME link with the same key and the same bytes, before the link's expiry plus
41
+ * `UPLOAD_LINK_REPLAY_GRACE_SECONDS`, gets the stored record with **200** and writes nothing. Every other request for a spent link (no
42
+ * key, another key, past the window, or a file removed or swept since) gets the same 410, so a
43
+ * holder of the secret without the key learns nothing about the redemption.
32
44
  *
33
45
  * **Response hygiene, set before anything can fail.** `Cache-Control: no-store`,
34
46
  * `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`. There is no `ETag`,
@@ -44,15 +56,16 @@ import type { Context, Env, Hono } from 'hono';
44
56
  import { type ScopeHost } from '@substrat-run/kernel';
45
57
  import type { CapabilityNode } from './capability-exchange.js';
46
58
  type Awaitable<T> = T | Promise<T>;
47
- /** Where `mountUploadLinkRedeem` listens unless told otherwise. */
59
+ /**
60
+ * Where `mountUploadLinkRedeem` listens — the one path, not an option: `uploadLinkPath` mints
61
+ * URLs under it, and `invocationLog` redacts the secret segment under it.
62
+ */
48
63
  export declare const UPLOAD_LINK_REDEEM_PATH = "/api/attachment-uploads/:entityType/:entityId/:secret";
49
64
  export interface UploadLinkRedeemOptions<E extends Env = Env> {
50
65
  /** The host serving the scope. Taken structurally — any host with the two link verbs. */
51
66
  host: (c: Context<E>) => Awaitable<Pick<ScopeHost, 'inspectUploadLink' | 'redeemUploadLink'>>;
52
67
  /** Which scope this request is for — the router-asserted (tenant, scope) the vertical already resolves. */
53
68
  node: (c: Context<E>) => Awaitable<CapabilityNode>;
54
- /** Defaults to `UPLOAD_LINK_REDEEM_PATH`; must carry `:entityType`, `:entityId` and `:secret`. */
55
- path?: string;
56
69
  }
57
70
  /** The few types whose first bytes say what they are. Anything else is `null`: not sniffable, not suspect. */
58
71
  export declare function sniffMediaType(bytes: Uint8Array): string | null;
@@ -65,8 +78,8 @@ export declare function sniffMediaType(bytes: Uint8Array): string | null;
65
78
  */
66
79
  export declare function resolveUploadMediaType(header: string | undefined, filename: string, bytes: Uint8Array): string | null;
67
80
  /**
68
- * Mount `PUT <path>` and `POST <path>`: redeem one upload link. See the module header for the
69
- * answers and the hygiene.
81
+ * Mount `PUT` and `POST` on `UPLOAD_LINK_REDEEM_PATH`: redeem one upload link. See the module
82
+ * header for the answers and the hygiene.
70
83
  */
71
84
  export declare function mountUploadLinkRedeem<E extends Env>(app: Hono<E>, options: UploadLinkRedeemOptions<E>): void;
72
85
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"upload-link.d.ts","sourceRoot":"","sources":["../src/upload-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAS/C,OAAO,EAML,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAG9B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE/D,KAAK,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEnC,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,0DAA6D,CAAC;AAElG,MAAM,WAAW,uBAAuB,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG;IAC1D,yFAAyF;IACzF,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,IAAI,CAAC,SAAS,EAAE,mBAAmB,GAAG,kBAAkB,CAAC,CAAC,CAAC;IAC9F,2GAA2G;IAC3G,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,cAAc,CAAC,CAAC;IACnD,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA4BD,8GAA8G;AAC9G,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAM/D;AAQD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,UAAU,GAChB,MAAM,GAAG,IAAI,CAQf;AAyHD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,uBAAuB,CAAC,CAAC,CAAC,GAAG,IAAI,CAwE5G"}
1
+ {"version":3,"file":"upload-link.d.ts","sourceRoot":"","sources":["../src/upload-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAY/C,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAG9B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE/D,KAAK,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,uBAAuB,0DAA6D,CAAC;AAElG,MAAM,WAAW,uBAAuB,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG;IAC1D,yFAAyF;IACzF,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,IAAI,CAAC,SAAS,EAAE,mBAAmB,GAAG,kBAAkB,CAAC,CAAC,CAAC;IAC9F,2GAA2G;IAC3G,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,cAAc,CAAC,CAAC;CACpD;AA4BD,8GAA8G;AAC9G,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAM/D;AAID;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,UAAU,GAChB,MAAM,GAAG,IAAI,CAQf;AA8ID;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,uBAAuB,CAAC,CAAC,CAAC,GAAG,IAAI,CAgF5G"}
@@ -1,7 +1,10 @@
1
- import { PROBLEM_CONTENT_TYPE, UPLOAD_LINK_PATH_PREFIX, substratError, uploadLinkFilename, } from '@substrat-run/contracts';
2
- import { UPLOAD_LINK_GONE, UPLOAD_LINK_TOO_LARGE, UPLOAD_LINK_TYPE_REFUSED, UPLOAD_LINK_WRONG_TARGET, uploadContentTypeAllowed, } from '@substrat-run/kernel';
1
+ import { PROBLEM_CONTENT_TYPE, UPLOAD_LINK_IDEMPOTENCY_HEADER, UPLOAD_LINK_PATH_PREFIX, isUploadLinkIdempotencyKey, isUploadLinkSecretShape, substratError, uploadLinkFilename, } from '@substrat-run/contracts';
2
+ import { UPLOAD_LINK_GONE, UPLOAD_LINK_REPLAY_MISMATCH, UPLOAD_LINK_TOO_LARGE, UPLOAD_LINK_TYPE_REFUSED, UPLOAD_LINK_WRONG_TARGET, normalizeUploadMediaType, uploadContentTypeAllowed, } from '@substrat-run/kernel';
3
3
  import { problemOf, messageOf } from './errors.js';
4
- /** Where `mountUploadLinkRedeem` listens unless told otherwise. */
4
+ /**
5
+ * Where `mountUploadLinkRedeem` listens — the one path, not an option: `uploadLinkPath` mints
6
+ * URLs under it, and `invocationLog` redacts the secret segment under it.
7
+ */
5
8
  export const UPLOAD_LINK_REDEEM_PATH = `${UPLOAD_LINK_PATH_PREFIX}/:entityType/:entityId/:secret`;
6
9
  // ---------------------------------------------------------------------------
7
10
  // The media type. A client's `Content-Type` is a claim; the bytes get a say where they can.
@@ -37,10 +40,6 @@ export function sniffMediaType(bytes) {
37
40
  return null;
38
41
  }
39
42
  const SNIFFABLE = new Set(['application/pdf', 'image/png', 'image/jpeg', 'image/gif']);
40
- const normalizeType = (raw) => {
41
- const t = raw.split(';')[0].trim().toLowerCase();
42
- return t === 'image/jpg' ? 'image/jpeg' : t;
43
- };
44
43
  /**
45
44
  * The type the platform will record. Order: the request's own `Content-Type` unless it is
46
45
  * generic (`curl --upload-file` sends none), else the file name's extension, else what the
@@ -49,7 +48,7 @@ const normalizeType = (raw) => {
49
48
  * another claim: a `.pdf` that is a PNG is not what the link's allowlist was written for.
50
49
  */
51
50
  export function resolveUploadMediaType(header, filename, bytes) {
52
- const fromHeader = header ? normalizeType(header) : '';
51
+ const fromHeader = header ? normalizeUploadMediaType(header) : '';
53
52
  const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.') + 1).toLowerCase() : '';
54
53
  const claimed = fromHeader && !GENERIC_TYPES.has(fromHeader) ? fromHeader : (EXTENSION_TYPES[ext] ?? null);
55
54
  const sniffed = sniffMediaType(bytes);
@@ -132,6 +131,7 @@ const STATUS_BY_REASON = {
132
131
  [UPLOAD_LINK_TOO_LARGE]: 413,
133
132
  [UPLOAD_LINK_TYPE_REFUSED]: 415,
134
133
  [UPLOAD_LINK_WRONG_TARGET]: 404,
134
+ [UPLOAD_LINK_REPLAY_MISMATCH]: 422,
135
135
  };
136
136
  const CORS_HEADERS = [
137
137
  'access-control-allow-origin',
@@ -170,11 +170,26 @@ function errorStatus(err) {
170
170
  return (code && STATUS_BY_CODE[code]) || 500;
171
171
  }
172
172
  /**
173
- * Mount `PUT <path>` and `POST <path>`: redeem one upload link. See the module header for the
174
- * answers and the hygiene.
173
+ * The `Idempotency-Key` a request carries, or `undefined` for none: the bare token, or one
174
+ * structured-field string (`"…"`, the IETF draft's spelling) with its quotes taken off. Anything
175
+ * else is a 400 — never a key matched loosely.
176
+ */
177
+ function idempotencyKeyOf(header) {
178
+ if (header === undefined)
179
+ return undefined;
180
+ const key = header.length >= 2 && header.startsWith('"') && header.endsWith('"') ? header.slice(1, -1) : header;
181
+ if (!isUploadLinkIdempotencyKey(key)) {
182
+ throw substratError('validation_failed', 'An Idempotency-Key is 16 to 255 visible ASCII characters, with no space, quote or backslash, such as a UUID.');
183
+ }
184
+ return key;
185
+ }
186
+ /** The 404 every secret that names nothing gets — the scope's answer, and this route's for a malformed one. */
187
+ const unknownLink = () => substratError('not_found', 'This upload link is not valid.');
188
+ /**
189
+ * Mount `PUT` and `POST` on `UPLOAD_LINK_REDEEM_PATH`: redeem one upload link. See the module
190
+ * header for the answers and the hygiene.
175
191
  */
176
192
  export function mountUploadLinkRedeem(app, options) {
177
- const path = options.path ?? UPLOAD_LINK_REDEEM_PATH;
178
193
  const handler = async (c) => {
179
194
  // Before anything can fail: nothing here may be cached or handed onwards, and an outer
180
195
  // blanket CORS layer must not make the answer readable by a page that learned the link.
@@ -187,20 +202,21 @@ export function mountUploadLinkRedeem(app, options) {
187
202
  const entityType = c.req.param('entityType');
188
203
  const entityId = c.req.param('entityId');
189
204
  const secret = c.req.param('secret');
190
- if (!entityType || !entityId || !secret)
191
- throw substratError('not_found', 'This upload link is not valid.');
205
+ // A secret that cannot be one the kernel minted costs the scope nothing: the same 404 an
206
+ // unknown one gets, before the host, the scope or its Durable Object is touched.
207
+ if (!entityType || !entityId || !isUploadLinkSecretShape(secret))
208
+ throw unknownLink();
209
+ // #2315: the redeemer's own id for this redemption, judged before the scope is asked, so a
210
+ // malformed one is the same 400 whatever the link's state.
211
+ const idempotencyKey = idempotencyKeyOf(c.req.header(UPLOAD_LINK_IDEMPOTENCY_HEADER));
192
212
  const [host, node] = await Promise.all([options.host(c), options.node(c)]);
193
213
  if (!host.inspectUploadLink || !host.redeemUploadLink) {
194
214
  throw substratError('unavailable', 'this scope host serves no upload links');
195
215
  }
196
- // The link first, so a refusal costs no body: unknown (404), used / expired / revoked (410),
197
- // and what the body will be judged against.
198
- const view = await host.inspectUploadLink(node.tenantId, node.scopeId, secret);
199
- if (view.entity.entityType !== entityType || view.entity.entityId !== entityId) {
200
- throw substratError('not_found', 'This upload link is not valid for that target.', {
201
- reason: UPLOAD_LINK_WRONG_TARGET,
202
- });
203
- }
216
+ // The link first, so a refusal costs no body: unknown or minted for another target (404,
217
+ // judged before its state), used / expired / revoked (410), and what the body will be
218
+ // judged against.
219
+ const view = await host.inspectUploadLink(node.tenantId, node.scopeId, secret, { entityType, entityId }, idempotencyKey);
204
220
  const filename = view.filename ??
205
221
  cleanFilename(c.req.query('filename')) ??
206
222
  cleanFilename(dispositionFilename(c.req.header('content-disposition'))) ??
@@ -230,20 +246,22 @@ export function mountUploadLinkRedeem(app, options) {
230
246
  filename,
231
247
  contentType,
232
248
  body,
249
+ ...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
233
250
  });
251
+ // 200 for a replay of the redemption that spent the link, 201 for a file stored now.
234
252
  return c.body(JSON.stringify({
235
253
  id: record.id,
236
254
  filename: record.filename,
237
255
  contentType: record.contentType,
238
256
  size: record.size,
239
257
  sha256: record.sha256,
240
- }), 201, { 'content-type': 'application/json' });
258
+ }), record.replayed ? 200 : 201, { 'content-type': 'application/json' });
241
259
  }
242
260
  catch (err) {
243
261
  return refuse(c, err);
244
262
  }
245
263
  };
246
- app.put(path, handler);
247
- app.post(path, handler);
264
+ app.put(UPLOAD_LINK_REDEEM_PATH, handler);
265
+ app.post(UPLOAD_LINK_REDEEM_PATH, handler);
248
266
  }
249
267
  //# sourceMappingURL=upload-link.js.map