@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.
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +111 -13
- package/dist/index.js.map +1 -1
- package/dist/invocation-log.d.ts +39 -1
- package/dist/invocation-log.d.ts.map +1 -1
- package/dist/invocation-log.js +69 -10
- package/dist/invocation-log.js.map +1 -1
- package/dist/outbound-calls.d.ts +94 -0
- package/dist/outbound-calls.d.ts.map +1 -0
- package/dist/outbound-calls.js +282 -0
- package/dist/outbound-calls.js.map +1 -0
- package/dist/peer-client.d.ts.map +1 -1
- package/dist/peer-client.js +6 -2
- package/dist/peer-client.js.map +1 -1
- package/dist/upload-link.d.ts +22 -9
- package/dist/upload-link.d.ts.map +1 -1
- package/dist/upload-link.js +42 -24
- package/dist/upload-link.js.map +1 -1
- package/package.json +7 -7
|
@@ -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":"
|
|
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"}
|
package/dist/peer-client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { globalFetch } from '@substrat-run/kernel';
|
|
2
|
-
import {
|
|
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,
|
package/dist/peer-client.js.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/upload-link.d.ts
CHANGED
|
@@ -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 }`; **
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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"}
|
package/dist/upload-link.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 ?
|
|
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
|
-
*
|
|
174
|
-
*
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
197
|
-
// and what the body will be
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
247
|
-
app.post(
|
|
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
|