@appstrate/afps-shared 0.5.0 → 0.7.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/package.json +1 -1
- package/src/delivery-http.ts +32 -0
- package/src/file-field.ts +64 -16
- package/src/guarded-fetch.ts +88 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/delivery-http.ts
CHANGED
|
@@ -96,3 +96,35 @@ export function projectHttpDeliveryConfig(
|
|
|
96
96
|
}
|
|
97
97
|
return cfg;
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Header names whose value is an RFC 9110 `credentials` production — an auth
|
|
102
|
+
* scheme token, one SP, then the credentials. Only in these positions is a
|
|
103
|
+
* bare token prefix a defect; anywhere else (`Cookie: session=`) it is an
|
|
104
|
+
* ordinary literal.
|
|
105
|
+
*/
|
|
106
|
+
const AUTH_SCHEME_HEADERS = new Set(["authorization", "proxy-authorization"]);
|
|
107
|
+
|
|
108
|
+
/** RFC 9110 `token` grammar — matches a prefix that is nothing but a scheme. */
|
|
109
|
+
const BARE_AUTH_SCHEME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True when `prefix` is nothing but an auth-scheme token AND `headerName` is a
|
|
113
|
+
* position whose value is an RFC 9110 `credentials` production.
|
|
114
|
+
*
|
|
115
|
+
* `prefix` is a LITERAL prepended to the rendered credential (AFPS §7.6), so a
|
|
116
|
+
* bare `"Bearer"` renders `Authorization: BearerTOKEN` — a malformed credential
|
|
117
|
+
* every upstream answers with a 401 that names nothing. The injector
|
|
118
|
+
* (`@appstrate/afps-runtime`'s `planHttpDeliveryInjection`) concatenates
|
|
119
|
+
* verbatim and repairs nothing, so every path that accepts an author-written
|
|
120
|
+
* prefix must refuse the bare form up front instead. This is the one grammar
|
|
121
|
+
* those gates share: the integration manifest validator
|
|
122
|
+
* (`@appstrate/core/integration`, install time) and the portable runtime's
|
|
123
|
+
* local creds file (`resolvers/integration-api-call.ts`, load time).
|
|
124
|
+
*
|
|
125
|
+
* Callers pass the EFFECTIVE header name — the one that will actually be sent,
|
|
126
|
+
* after their own defaulting has been applied.
|
|
127
|
+
*/
|
|
128
|
+
export function isBareAuthSchemePrefix(headerName: string, prefix: string): boolean {
|
|
129
|
+
return AUTH_SCHEME_HEADERS.has(headerName.toLowerCase()) && BARE_AUTH_SCHEME.test(prefix);
|
|
130
|
+
}
|
package/src/file-field.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Canonical AFPS file-field predicate
|
|
6
|
-
*
|
|
7
|
-
* `@appstrate/afps-runtime`'s platform-prompt composer.
|
|
5
|
+
* Canonical AFPS file-field predicate, used by `@appstrate/afps-runtime`'s
|
|
6
|
+
* platform-prompt composer.
|
|
8
7
|
*
|
|
9
8
|
* AFPS file fields are JSON Schema string nodes carrying `format: "uri"` plus a
|
|
10
9
|
* `contentMediaType` (single file), or an array whose `items` are such nodes
|
|
@@ -17,10 +16,46 @@
|
|
|
17
16
|
* Accepts a permissive `unknown` input narrowed internally so both the
|
|
18
17
|
* JSONSchema7-typed core call site and the `unknown`-typed runtime call site
|
|
19
18
|
* compile against one definition.
|
|
19
|
+
*
|
|
20
|
+
* ── `@appstrate/core/form` HAS A PARALLEL COPY OF THIS RULE, ON PURPOSE ──
|
|
21
|
+
* `isMultipleFileField` is new in THIS version (0.7.0), which is not on npm
|
|
22
|
+
* yet; the newest published release, 0.6.0, exports `isFileField` from this
|
|
23
|
+
* subpath and nothing else. `@appstrate/core` ships as source, so a consumer's
|
|
24
|
+
* `tsc` compiles core's files against the `@appstrate/afps-shared` their own
|
|
25
|
+
* install resolves — importing `isMultipleFileField` from here typechecks in
|
|
26
|
+
* this workspace and cannot resolve for them. Core therefore carries its own
|
|
27
|
+
* copy, derived from the same single-file-node rule; `packages/core/test/
|
|
28
|
+
* form.test.ts` asserts the two agree table-wide, so a change made HERE and not
|
|
29
|
+
* there (or vice versa) fails that test.
|
|
30
|
+
*
|
|
31
|
+
* Core has ALREADY raised its floor to `^0.7.0`, so the remaining step is the
|
|
32
|
+
* publish (`git tag afps-shared@0.7.0`); after it lands, core's copy is
|
|
33
|
+
* replaced by an import of the two predicates. Not before.
|
|
34
|
+
*
|
|
35
|
+
* The helpers below (`asNode`, `isSingleFileNode`, `resolveItems`,
|
|
36
|
+
* `resolveType`) are deliberately NOT exported and are not part of that plan.
|
|
37
|
+
* They are implementation detail of the two predicates, they have no importer
|
|
38
|
+
* anywhere, and every name this package exports is a semver commitment to
|
|
39
|
+
* out-of-tree consumers that only a breaking release can take back.
|
|
20
40
|
*/
|
|
21
41
|
|
|
22
|
-
/**
|
|
23
|
-
function
|
|
42
|
+
/** Narrow an `unknown` schema node to an indexable object, or `undefined`. */
|
|
43
|
+
function asNode(schema: unknown): Record<string, unknown> | undefined {
|
|
44
|
+
return schema && typeof schema === "object" ? (schema as Record<string, unknown>) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single file field: `format: "uri"` + a DECLARED `contentMediaType`.
|
|
49
|
+
*
|
|
50
|
+
* "Declared" is `!= null && !== false`, deliberately NOT truthiness: the
|
|
51
|
+
* keyword's presence is what marks the field as a file, and whether its value
|
|
52
|
+
* is a well-formed media type is the manifest validator's job, not this
|
|
53
|
+
* predicate's. `contentMediaType: ""` is therefore a file field — the same
|
|
54
|
+
* reading `apps/api/src/services/inline-run.ts` documents and relies on.
|
|
55
|
+
*/
|
|
56
|
+
function isSingleFileNode(schema: unknown): boolean {
|
|
57
|
+
const node = asNode(schema);
|
|
58
|
+
if (!node) return false;
|
|
24
59
|
return node.format === "uri" && node.contentMediaType != null && node.contentMediaType !== false;
|
|
25
60
|
}
|
|
26
61
|
|
|
@@ -28,8 +63,9 @@ function isSingleFileNode(node: Record<string, unknown>): boolean {
|
|
|
28
63
|
* Resolve a node's `items` schema, handling the JSON Schema boolean / tuple
|
|
29
64
|
* forms (`items: false` → none; `items: [first, …]` → first object entry).
|
|
30
65
|
*/
|
|
31
|
-
function resolveItems(
|
|
32
|
-
const
|
|
66
|
+
function resolveItems(schema: unknown): Record<string, unknown> | undefined {
|
|
67
|
+
const node = asNode(schema);
|
|
68
|
+
const items = node?.items;
|
|
33
69
|
if (!items || typeof items === "boolean") return undefined;
|
|
34
70
|
if (Array.isArray(items)) {
|
|
35
71
|
const first = items[0];
|
|
@@ -39,7 +75,10 @@ function resolveItems(node: Record<string, unknown>): Record<string, unknown> |
|
|
|
39
75
|
return undefined;
|
|
40
76
|
}
|
|
41
77
|
|
|
42
|
-
|
|
78
|
+
/** Resolve a node's `type` (JSON Schema allows a union array — first wins). */
|
|
79
|
+
function resolveType(schema: unknown): string | undefined {
|
|
80
|
+
const node = asNode(schema);
|
|
81
|
+
if (!node) return undefined;
|
|
43
82
|
if (typeof node.type === "string") return node.type;
|
|
44
83
|
if (Array.isArray(node.type) && node.type.length > 0 && typeof node.type[0] === "string") {
|
|
45
84
|
return node.type[0];
|
|
@@ -52,12 +91,21 @@ function resolveType(node: Record<string, unknown>): string | undefined {
|
|
|
52
91
|
* OR an array whose items are such a node.
|
|
53
92
|
*/
|
|
54
93
|
export function isFileField(schema: unknown): boolean {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
return isSingleFileNode(schema) || isMultipleFileField(schema);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detect a MULTIPLE-files field: an array whose `items` are a single file node.
|
|
99
|
+
*
|
|
100
|
+
* Shares {@link isSingleFileNode} with {@link isFileField} by construction, so
|
|
101
|
+
* the two can never disagree about the same array node — they did, when
|
|
102
|
+
* `@appstrate/core/form`'s `isMultipleFileField` tested
|
|
103
|
+
* `!!items.contentMediaType` (truthiness) against an `isFileField` that tested
|
|
104
|
+
* "declared": for `contentMediaType: ""` the field was a file field that was
|
|
105
|
+
* not multiple, and the RJSF adapter rendered a single-file widget bound to an
|
|
106
|
+
* array property. Core's copy is now derived the same way; see the header for
|
|
107
|
+
* why it is still a copy.
|
|
108
|
+
*/
|
|
109
|
+
export function isMultipleFileField(schema: unknown): boolean {
|
|
110
|
+
return resolveType(schema) === "array" && isSingleFileNode(resolveItems(schema));
|
|
63
111
|
}
|
package/src/guarded-fetch.ts
CHANGED
|
@@ -54,8 +54,51 @@
|
|
|
54
54
|
|
|
55
55
|
import { resolveAndCheckHost, type HostResolver } from "./ssrf-dns.ts";
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Redirect hops any guarded chain will chase before giving up — the ONE budget
|
|
59
|
+
* in the codebase, and the default of both followers.
|
|
60
|
+
*
|
|
61
|
+
* It used to be two unrelated numbers: `maxRedirects ?? 5` here and a hard
|
|
62
|
+
* `MAX_REDIRECTS = 10` in `@appstrate/afps-runtime`'s credential-proxy
|
|
63
|
+
* follower, neither aware of the other. 10 is the surviving value because it is
|
|
64
|
+
* the one with a reason: the credential proxy walks multi-step OAuth/CAS dances
|
|
65
|
+
* whose session cookie lands on an intermediate 302 (#473), and five hops does
|
|
66
|
+
* not always reach the end of one. Nothing is weakened by the raise — every hop
|
|
67
|
+
* is independently DNS-checked, allowlist-checked and credential-stripped, so
|
|
68
|
+
* the cap is a loop/DoS bound, not a trust boundary.
|
|
69
|
+
*
|
|
70
|
+
* Raising a SHARED default for one caller's benefit would be a poor trade, so
|
|
71
|
+
* here is the whole roster it applies to. The callers where a longer chain
|
|
72
|
+
* would be questionable — a signed payload, an inference endpoint, a
|
|
73
|
+
* registration POST — already pin their own budget and are untouched by the
|
|
74
|
+
* value here:
|
|
75
|
+
*
|
|
76
|
+
* pinned `maxRedirects: 0`, unaffected
|
|
77
|
+
* - `apps/api/src/modules/webhooks/service.ts` — a signed delivery payload
|
|
78
|
+
* must never be re-sent to a `Location` target.
|
|
79
|
+
* - `apps/api/src/services/llm-proxy/core.ts` — an inference endpoint has
|
|
80
|
+
* no legitimate reason to redirect.
|
|
81
|
+
* - `packages/connect/src/dcr.ts` — dynamic client registration is a
|
|
82
|
+
* single POST to a discovered endpoint.
|
|
83
|
+
*
|
|
84
|
+
* on this default, and all of them multi-step credential exchanges — the
|
|
85
|
+
* exact population #473 is about
|
|
86
|
+
* - `apps/api/src/services/credential-proxy/core.ts` — the out-of-container
|
|
87
|
+
* twin of the `@appstrate/afps-runtime` follower this value came from;
|
|
88
|
+
* same vendor auth dances, and it re-runs the caller's `validateHop`
|
|
89
|
+
* allowlist assertion on every hop.
|
|
90
|
+
* - `packages/connect/src/oauth-egress.ts` — OAuth discovery, token
|
|
91
|
+
* exchange and userinfo.
|
|
92
|
+
* - `apps/api/src/services/integration-connections.ts` — OAuth
|
|
93
|
+
* protected-resource metadata discovery.
|
|
94
|
+
* - `apps/api/src/services/org-models.ts` — model-catalog probes.
|
|
95
|
+
* - `runtime-pi/sidecar/integrations-boot.ts` — remote MCP transport
|
|
96
|
+
* egress.
|
|
97
|
+
*/
|
|
98
|
+
export const DEFAULT_MAX_REDIRECTS = 10;
|
|
99
|
+
|
|
57
100
|
export interface GuardedFetchOptions {
|
|
58
|
-
/** Max redirect hops to follow before giving up. Default
|
|
101
|
+
/** Max redirect hops to follow before giving up. Default {@link DEFAULT_MAX_REDIRECTS}. */
|
|
59
102
|
maxRedirects?: number;
|
|
60
103
|
/**
|
|
61
104
|
* Deadline in ms covering the redirect chain up to the final response's
|
|
@@ -80,7 +123,7 @@ export interface GuardedFetchOptions {
|
|
|
80
123
|
fetchImpl?: typeof fetch;
|
|
81
124
|
/**
|
|
82
125
|
* Opt-in predicate for hosts the OPERATOR has explicitly trusted (e.g. an
|
|
83
|
-
* internal IdP on a private address via `
|
|
126
|
+
* internal IdP on a private address via `EGRESS_ALLOW_INTERNAL_HOSTS`).
|
|
84
127
|
* When it returns true the host blocklist is skipped for that hop, but the
|
|
85
128
|
* manual-redirect discipline (cross-origin body/credential stripping) still
|
|
86
129
|
* applies — so a trusted host that open-redirects cannot forward the secret.
|
|
@@ -188,7 +231,7 @@ export async function guardedFetch(
|
|
|
188
231
|
init?: RequestInit,
|
|
189
232
|
opts?: GuardedFetchOptions,
|
|
190
233
|
): Promise<Response> {
|
|
191
|
-
const maxRedirects = opts?.maxRedirects ??
|
|
234
|
+
const maxRedirects = opts?.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
192
235
|
|
|
193
236
|
let current = stripUserInfoAndFragment(new URL(typeof input === "string" ? input : input.href));
|
|
194
237
|
assertHttp(current);
|
|
@@ -338,13 +381,50 @@ export async function guardedFetch(
|
|
|
338
381
|
}
|
|
339
382
|
}
|
|
340
383
|
|
|
341
|
-
// Standard redirect method/body rewriting
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
384
|
+
// Standard redirect method/body rewriting, per WHATWG fetch
|
|
385
|
+
// (HTTP-redirect fetch step 11) + RFC 9110 §15.4:
|
|
386
|
+
// - 301/302 downgrade POST → GET; every OTHER method is preserved.
|
|
387
|
+
// - 303 downgrades everything except GET/HEAD → GET.
|
|
388
|
+
// - 307/308 preserve method + body (already dropped above when
|
|
389
|
+
// crossing a host boundary).
|
|
390
|
+
// The 301/302 clause used to read `method !== "HEAD"`, which turned a
|
|
391
|
+
// 302'd PUT/PATCH/DELETE into a bodyless GET — a request the caller never
|
|
392
|
+
// made, and a silent one. `@appstrate/afps-runtime`'s credential-proxy
|
|
393
|
+
// follower always had the conformant rule; this side did not, and the
|
|
394
|
+
// two disagreed about the same response.
|
|
395
|
+
const toGet =
|
|
396
|
+
((res.status === 301 || res.status === 302) && method === "POST") ||
|
|
397
|
+
(res.status === 303 && method !== "GET" && method !== "HEAD");
|
|
398
|
+
if (toGet) {
|
|
399
|
+
method = "GET";
|
|
346
400
|
dropBody();
|
|
347
401
|
}
|
|
402
|
+
// A `ReadableStream` body is single-use: hop 0 consumed it, so any hop
|
|
403
|
+
// that PRESERVES the body is about to re-send a locked stream. The
|
|
404
|
+
// runtime answers that with an opaque `TypeError: body already used`
|
|
405
|
+
// from inside `fetch`, naming neither the redirect nor the stream.
|
|
406
|
+
//
|
|
407
|
+
// Callers CAN reach this: `apps/api/src/services/credential-proxy/core.ts`
|
|
408
|
+
// forwards its caller's request body straight through, sets
|
|
409
|
+
// `duplex: "half"` for the stream case, and takes the default redirect
|
|
410
|
+
// budget. It has always been reachable via 307/308 (which preserve
|
|
411
|
+
// method + body unconditionally); making 301/302 conformant for
|
|
412
|
+
// PUT/PATCH/DELETE widened WHICH statuses land on it, so it is named
|
|
413
|
+
// here rather than left to surface as a transport-level type error.
|
|
414
|
+
//
|
|
415
|
+
// Fail loudly instead of dropping the body: a bodyless PUT the caller
|
|
416
|
+
// never made, sent silently, is the worse outcome — that is the exact
|
|
417
|
+
// shape the 301/302 conformance fix removed. Nothing replayable is
|
|
418
|
+
// affected (string / `Uint8Array` / `FormData` bodies re-send fine), and
|
|
419
|
+
// a hop that DROPS the body (`toGet` above, or the cross-host containment
|
|
420
|
+
// below) never gets here.
|
|
421
|
+
if (body instanceof ReadableStream) {
|
|
422
|
+
throw new TypeError(
|
|
423
|
+
`guardedFetch cannot follow a ${res.status} redirect to ${next.origin}: the request ` +
|
|
424
|
+
`body is a ReadableStream and was already consumed by the previous hop. Buffer the ` +
|
|
425
|
+
`body before the call, or pass maxRedirects: 0 and handle the redirect yourself.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
348
428
|
current = next;
|
|
349
429
|
pinnedAddress = nextPin;
|
|
350
430
|
// Drain the redirect response body so the connection can be reused.
|