@appstrate/afps-shared 0.4.0 → 0.6.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 +2 -1
- package/src/archive-prefix.ts +81 -0
- package/src/delivery-http.ts +32 -0
- package/src/guarded-fetch.ts +1 -1
- package/src/mime.ts +46 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"./guarded-fetch": "./src/guarded-fetch.ts",
|
|
50
50
|
"./signed-token": "./src/signed-token.ts",
|
|
51
51
|
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
52
|
+
"./archive-prefix": "./src/archive-prefix.ts",
|
|
52
53
|
"./backoff": "./src/backoff.ts",
|
|
53
54
|
"./mime": "./src/mime.ts"
|
|
54
55
|
},
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Copyright 2026 Appstrate
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wrapper-folder stripping for AFPS archives — the ONE place that answers
|
|
6
|
+
* "did whoever zipped this wrap everything in a top-level directory, and if so
|
|
7
|
+
* what is it?".
|
|
8
|
+
*
|
|
9
|
+
* ZIPs created by macOS Finder or `zip -r folder/` wrap all entries under a
|
|
10
|
+
* single top-level directory, so a lookup like `files["manifest.json"]` misses.
|
|
11
|
+
* Only strip when EVERY entry shares one first-level prefix and none sits at
|
|
12
|
+
* the root; anything else is ambiguous and the collection comes back untouched,
|
|
13
|
+
* by identity.
|
|
14
|
+
*
|
|
15
|
+
* Lives in the zero-dependency shared package because two packages that cannot
|
|
16
|
+
* import each other both have to strip the same prefix the same way:
|
|
17
|
+
*
|
|
18
|
+
* - **Core** (`@appstrate/core/zip`) — the platform's package-ZIP parser, over
|
|
19
|
+
* fflate's `Record<string, Uint8Array>` shape, plus the sanitized `Map`
|
|
20
|
+
* shape the bundle path hands it.
|
|
21
|
+
* - **AFPS runtime** (`@appstrate/afps-runtime` → `bundle/archive-utils.ts`) —
|
|
22
|
+
* the `.afps` ingestion path, over the sanitized `Map` shape.
|
|
23
|
+
*
|
|
24
|
+
* The runtime used to keep a hand-copy of core's `Map` branch under a comment
|
|
25
|
+
* asking a human to "keep the two algorithms in sync", with no parity test to
|
|
26
|
+
* police it. That is exactly the arrangement `@appstrate/afps-shared/mime`
|
|
27
|
+
* replaced for the media-type set — where the policing parity test existed and
|
|
28
|
+
* the copies drifted three times anyway, once corrupting every OOXML download.
|
|
29
|
+
* `@appstrate/afps-runtime` deliberately carries no runtime dependency on core
|
|
30
|
+
* (it ships as a portable bundle runner and a standalone `afps` CLI), and
|
|
31
|
+
* afps-shared is a dependency of both, so one definition reaches both without
|
|
32
|
+
* either taking on the other.
|
|
33
|
+
*
|
|
34
|
+
* Change the rule HERE, not at a call site.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect and strip a single common wrapper folder from archive entries.
|
|
39
|
+
*
|
|
40
|
+
* Accepts either a `Record<string, Uint8Array>` (fflate's default ZIP shape) or
|
|
41
|
+
* a `Map<string, Uint8Array>` (the sanitized bundle shape) and returns the same
|
|
42
|
+
* type as the input — the ORIGINAL object, by identity, when there is nothing
|
|
43
|
+
* to strip.
|
|
44
|
+
*/
|
|
45
|
+
export function stripWrapperPrefix(files: Record<string, Uint8Array>): Record<string, Uint8Array>;
|
|
46
|
+
export function stripWrapperPrefix(files: Map<string, Uint8Array>): Map<string, Uint8Array>;
|
|
47
|
+
export function stripWrapperPrefix(
|
|
48
|
+
files: Record<string, Uint8Array> | Map<string, Uint8Array>,
|
|
49
|
+
): Record<string, Uint8Array> | Map<string, Uint8Array> {
|
|
50
|
+
const keys = files instanceof Map ? [...files.keys()] : Object.keys(files);
|
|
51
|
+
const prefix = commonWrapperPrefix(keys);
|
|
52
|
+
if (prefix === null) return files;
|
|
53
|
+
|
|
54
|
+
if (files instanceof Map) {
|
|
55
|
+
const stripped = new Map<string, Uint8Array>();
|
|
56
|
+
for (const [key, value] of files) stripped.set(key.slice(prefix.length), value);
|
|
57
|
+
return stripped;
|
|
58
|
+
}
|
|
59
|
+
const stripped: Record<string, Uint8Array> = {};
|
|
60
|
+
for (const [key, value] of Object.entries(files)) {
|
|
61
|
+
stripped[key.slice(prefix.length)] = value;
|
|
62
|
+
}
|
|
63
|
+
return stripped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The single wrapper prefix (WITH its trailing slash) every key shares, or
|
|
68
|
+
* `null` when there is none to strip — no entries at all, an entry at the root
|
|
69
|
+
* level, or more than one top-level folder (ambiguous).
|
|
70
|
+
*/
|
|
71
|
+
function commonWrapperPrefix(keys: readonly string[]): string | null {
|
|
72
|
+
if (keys.length === 0) return null;
|
|
73
|
+
const prefixes = new Set<string>();
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const slashIdx = key.indexOf("/");
|
|
76
|
+
if (slashIdx === -1) return null; // root-level file → no stripping
|
|
77
|
+
prefixes.add(key.slice(0, slashIdx));
|
|
78
|
+
}
|
|
79
|
+
if (prefixes.size !== 1) return null; // multiple top-level folders → ambiguous
|
|
80
|
+
return `${[...prefixes][0]}/`;
|
|
81
|
+
}
|
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/guarded-fetch.ts
CHANGED
|
@@ -80,7 +80,7 @@ export interface GuardedFetchOptions {
|
|
|
80
80
|
fetchImpl?: typeof fetch;
|
|
81
81
|
/**
|
|
82
82
|
* Opt-in predicate for hosts the OPERATOR has explicitly trusted (e.g. an
|
|
83
|
-
* internal IdP on a private address via `
|
|
83
|
+
* internal IdP on a private address via `EGRESS_ALLOW_INTERNAL_HOSTS`).
|
|
84
84
|
* When it returns true the host blocklist is skipped for that hop, but the
|
|
85
85
|
* manual-redirect discipline (cross-origin body/credential stripping) still
|
|
86
86
|
* applies — so a trusted host that open-redirects cannot forward the secret.
|
package/src/mime.ts
CHANGED
|
@@ -60,6 +60,24 @@ export function normalizeMime(mime: string | null | undefined): string {
|
|
|
60
60
|
* Media types whose payload is text, matched EXACTLY. A substring test would
|
|
61
61
|
* classify `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
|
62
62
|
* as XML — see the module doc.
|
|
63
|
+
*
|
|
64
|
+
* Membership criterion, applied to every entry below:
|
|
65
|
+
*
|
|
66
|
+
* 1. the format's specification defines the payload as a character sequence,
|
|
67
|
+
* so a UTF-8 decode is lossless and a base64 round-trip is pure noise to
|
|
68
|
+
* the model;
|
|
69
|
+
* 2. it plausibly arrives as an HTTP response body (request-only grammars
|
|
70
|
+
* such as `application/sparql-query` or `application/jsonpath` are out);
|
|
71
|
+
* 3. it is not a container that may embed raw bytes. `application/rtf`
|
|
72
|
+
* (`\bin` blocks), `application/postscript` (binary-token PostScript) and
|
|
73
|
+
* `application/http` (a message whose body may be anything) all fail this
|
|
74
|
+
* and stay on the byte path even though their common form is ASCII.
|
|
75
|
+
*
|
|
76
|
+
* Completeness matters more than it used to. `isTextLikeMimeType` in
|
|
77
|
+
* `@appstrate/afps-runtime` used to treat any `; charset=…` parameter as a
|
|
78
|
+
* declaration of textness ahead of this set; it now consults that parameter
|
|
79
|
+
* only for an ambiguous base type, so a text format MISSING from this set is
|
|
80
|
+
* base64'd to the model even when the upstream declared a charset.
|
|
63
81
|
*/
|
|
64
82
|
export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
|
|
65
83
|
// JSON family
|
|
@@ -68,22 +86,49 @@ export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
|
|
|
68
86
|
"application/x-ndjson",
|
|
69
87
|
"application/jsonl",
|
|
70
88
|
"application/json-seq",
|
|
71
|
-
|
|
89
|
+
"application/json5",
|
|
90
|
+
"application/hjson",
|
|
91
|
+
// XML / SGML family
|
|
72
92
|
"application/xml",
|
|
73
93
|
"application/xml-dtd",
|
|
74
94
|
"application/xml-external-parsed-entity", // RFC 7303
|
|
95
|
+
"application/sgml",
|
|
75
96
|
"image/svg+xml", // XML-based, file-type never matches it
|
|
76
97
|
// YAML family
|
|
77
98
|
"application/yaml",
|
|
78
99
|
"application/x-yaml",
|
|
100
|
+
// TOML
|
|
101
|
+
"application/toml",
|
|
102
|
+
// Markdown — `text/markdown` is the registered spelling and already matches
|
|
103
|
+
// on the `text/` prefix; these two are the widespread unregistered ones.
|
|
104
|
+
"application/markdown",
|
|
105
|
+
"application/x-markdown",
|
|
106
|
+
// Query / schema languages served as source text
|
|
107
|
+
"application/sql", // RFC 6922
|
|
108
|
+
"application/graphql",
|
|
109
|
+
// RDF text serialisations. The `+json` / `+xml` RDF spellings already match
|
|
110
|
+
// on their structured suffix, and Turtle is registered as `text/turtle`.
|
|
111
|
+
"application/n-triples",
|
|
112
|
+
"application/n-quads",
|
|
113
|
+
"application/trig",
|
|
79
114
|
// Scripting / tabular / form encodings with no magic signature
|
|
80
115
|
"application/javascript",
|
|
81
116
|
"application/x-javascript",
|
|
82
117
|
"application/ecmascript",
|
|
118
|
+
"application/node",
|
|
119
|
+
"application/typescript",
|
|
120
|
+
"application/x-typescript",
|
|
121
|
+
"application/dart",
|
|
83
122
|
"application/csv",
|
|
84
123
|
"application/x-sh",
|
|
124
|
+
"application/x-shellscript",
|
|
85
125
|
"application/x-httpd-php",
|
|
86
126
|
"application/x-www-form-urlencoded",
|
|
127
|
+
// ASCII-armored / PEM blocks — base64 wrapped in text framing. Re-encoding
|
|
128
|
+
// them as base64 hides the framing the model needs to read.
|
|
129
|
+
"application/pem-certificate-chain", // RFC 8555
|
|
130
|
+
"application/pgp-keys", // RFC 3156 — the armored form
|
|
131
|
+
"application/pgp-signature", // RFC 3156 — the armored form
|
|
87
132
|
]);
|
|
88
133
|
|
|
89
134
|
/**
|