@cosmicdrift/kumiko-framework 0.374.0 → 0.378.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/AGENTS.md +2 -2
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +3 -3
- package/agents/examples/compose-stacks.md +15 -8
- package/agents/examples/delivery-notifications.md +25 -2
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/embedded.md +191 -0
- package/agents/examples/notes-history-basic.md +1 -1
- package/agents/examples/tags-basic.md +1 -1
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/wizard-form.md +4 -4
- package/agents/features.md +2 -2
- package/agents/widgets.md +4 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +2 -1
- package/dist/config/index.d.ts +4 -0
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +12 -4
- package/dist/db/money.d.ts +2 -1
- package/dist/db/money.js +3 -8
- package/dist/db/query.js +14 -14
- package/dist/db/replay-migration-sql.d.ts +1 -0
- package/dist/db/replay-migration-sql.js +115 -11
- package/dist/db/tenant-db.d.ts +7 -0
- package/dist/db/tenant-db.js +21 -5
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
- package/dist/engine/boot-validator/form-derivations.js +82 -0
- package/dist/engine/boot-validator/screens.js +23 -4
- package/dist/engine/build-config-feature-schema.js +33 -10
- package/dist/engine/embedded-derived.d.ts +1 -1
- package/dist/engine/embedded-derived.js +7 -1
- package/dist/engine/form-derivations.d.ts +21 -0
- package/dist/engine/form-derivations.js +175 -0
- package/dist/engine/index.d.ts +5 -1
- package/dist/engine/index.js +3 -0
- package/dist/engine/minor-units.d.ts +2 -0
- package/dist/engine/minor-units.js +8 -0
- package/dist/engine/parse-ref-target.js +1 -1
- package/dist/engine/provider-plugin-context.d.ts +3 -0
- package/dist/engine/provider-plugin-context.js +28 -0
- package/dist/engine/schema-builder.js +7 -0
- package/dist/engine/template-variables.d.ts +2 -1
- package/dist/engine/template-variables.js +4 -0
- package/dist/engine/types/index.d.ts +3 -3
- package/dist/engine/with-form-derivations.d.ts +9 -0
- package/dist/engine/with-form-derivations.js +16 -0
- package/dist/env/index.js +1 -1
- package/dist/errors/zod-bridge.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/provider-resolver.d.ts +17 -17
- package/dist/files/provider-resolver.js +1 -1
- package/dist/http/egress.d.ts +14 -0
- package/dist/http/egress.js +144 -0
- package/dist/http/index.d.ts +3 -2
- package/dist/http/index.js +2 -1
- package/dist/http/policy.d.ts +30 -0
- package/dist/http/policy.js +299 -0
- package/dist/i18n/required-surface-keys.js +2 -0
- package/dist/i18n/settings-hub-keys.js +1 -0
- package/dist/jobs/job-runner.js +4 -2
- package/dist/pipeline/config-accessor-factory.d.ts +3 -0
- package/dist/pipeline/config-accessor-factory.js +3 -0
- package/dist/pipeline/dispatch-shared.js +7 -5
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/search/configure-tenant-index.d.ts +4 -0
- package/dist/search/configure-tenant-index.js +8 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/ui-types/index.d.ts +3 -1
- package/dist/ui-types/index.js +1 -0
- package/dist/upgrade-changelog.d.ts +25 -0
- package/dist/upgrade-changelog.js +48 -0
- package/dist/upgrade-cli.js +78 -13
- package/package.json +3 -5
- package/presets/biome-app.json +8 -0
- package/src/changes.json +103 -0
- package/src/scripts/codemod/README.md +25 -0
- package/src/scripts/codemod/codemod-support.ts +64 -6
- package/src/scripts/codemod/package-merge.ts +260 -0
- package/src/scripts/codemod/s3-imports.ts +4 -42
- package/src/scripts/codemod/testing-injection.ts +135 -0
|
@@ -14,7 +14,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
14
14
|
readonly sensitive?: boolean;
|
|
15
15
|
readonly writeOnly?: boolean;
|
|
16
16
|
readonly readAsInstruction?: boolean;
|
|
17
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
17
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
18
18
|
readonly default?: string;
|
|
19
19
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
20
20
|
readonly multiline?: boolean | {
|
|
@@ -37,7 +37,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
37
37
|
readonly sensitive?: boolean;
|
|
38
38
|
readonly writeOnly?: boolean;
|
|
39
39
|
readonly readAsInstruction?: boolean;
|
|
40
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
40
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
41
41
|
readonly default?: string;
|
|
42
42
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
43
43
|
readonly multiline?: boolean | {
|
|
@@ -60,7 +60,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
60
60
|
readonly sensitive?: boolean;
|
|
61
61
|
readonly writeOnly?: boolean;
|
|
62
62
|
readonly readAsInstruction?: boolean;
|
|
63
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
63
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
64
64
|
readonly default?: string;
|
|
65
65
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
66
66
|
readonly multiline?: boolean | {
|
|
@@ -95,7 +95,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
95
95
|
readonly sensitive?: boolean;
|
|
96
96
|
readonly writeOnly?: boolean;
|
|
97
97
|
readonly readAsInstruction?: boolean;
|
|
98
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
98
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
99
99
|
readonly default?: string;
|
|
100
100
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
101
101
|
readonly multiline?: boolean | {
|
|
@@ -118,7 +118,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
118
118
|
readonly sensitive?: boolean;
|
|
119
119
|
readonly writeOnly?: boolean;
|
|
120
120
|
readonly readAsInstruction?: boolean;
|
|
121
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
121
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
122
122
|
readonly default?: string;
|
|
123
123
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
124
124
|
readonly multiline?: boolean | {
|
|
@@ -141,7 +141,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
141
141
|
readonly sensitive?: boolean;
|
|
142
142
|
readonly writeOnly?: boolean;
|
|
143
143
|
readonly readAsInstruction?: boolean;
|
|
144
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
144
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
145
145
|
readonly default?: string;
|
|
146
146
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
147
147
|
readonly multiline?: boolean | {
|
|
@@ -13,7 +13,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
13
13
|
readonly sensitive?: boolean;
|
|
14
14
|
readonly writeOnly?: boolean;
|
|
15
15
|
readonly readAsInstruction?: boolean;
|
|
16
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
16
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
17
17
|
readonly default?: string;
|
|
18
18
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
19
19
|
readonly multiline?: boolean | {
|
|
@@ -36,7 +36,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
36
36
|
readonly sensitive?: boolean;
|
|
37
37
|
readonly writeOnly?: boolean;
|
|
38
38
|
readonly readAsInstruction?: boolean;
|
|
39
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
39
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
40
40
|
readonly default?: string;
|
|
41
41
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
42
42
|
readonly multiline?: boolean | {
|
|
@@ -59,7 +59,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
59
59
|
readonly sensitive?: boolean;
|
|
60
60
|
readonly writeOnly?: boolean;
|
|
61
61
|
readonly readAsInstruction?: boolean;
|
|
62
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
62
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
63
63
|
readonly default?: string;
|
|
64
64
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
65
65
|
readonly multiline?: boolean | {
|
|
@@ -94,7 +94,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
94
94
|
readonly sensitive?: boolean;
|
|
95
95
|
readonly writeOnly?: boolean;
|
|
96
96
|
readonly readAsInstruction?: boolean;
|
|
97
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
97
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
98
98
|
readonly default?: string;
|
|
99
99
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
100
100
|
readonly multiline?: boolean | {
|
|
@@ -117,7 +117,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
117
117
|
readonly sensitive?: boolean;
|
|
118
118
|
readonly writeOnly?: boolean;
|
|
119
119
|
readonly readAsInstruction?: boolean;
|
|
120
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
120
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
121
121
|
readonly default?: string;
|
|
122
122
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
123
123
|
readonly multiline?: boolean | {
|
|
@@ -140,7 +140,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
140
140
|
readonly sensitive?: boolean;
|
|
141
141
|
readonly writeOnly?: boolean;
|
|
142
142
|
readonly readAsInstruction?: boolean;
|
|
143
|
-
readonly format?: "email" | "url" | "phone" | "password";
|
|
143
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
144
144
|
readonly default?: string;
|
|
145
145
|
readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
|
|
146
146
|
readonly multiline?: boolean | {
|
package/dist/files/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
|
|
|
5
5
|
export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
|
|
6
6
|
export type { FileContext, FileHandle } from "./file-handle.ts";
|
|
7
7
|
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
|
|
8
|
-
export { fileRefEntity } from "./file-ref-entity.ts";
|
|
8
|
+
export { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.ts";
|
|
9
9
|
export { fileRefsTable } from "./file-ref-table.ts";
|
|
10
10
|
export type { FileAccessDecision, FileAccessGuard, FileRef, FileRoutesOptions, } from "./file-routes.ts";
|
|
11
11
|
export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.ts";
|
package/dist/files/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
|
|
|
4
4
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
5
5
|
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
6
6
|
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
|
|
7
|
-
export { fileRefEntity } from "./file-ref-entity.js";
|
|
7
|
+
export { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
8
8
|
export { fileRefsTable } from "./file-ref-table.js";
|
|
9
9
|
export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
|
|
10
10
|
export { createInMemoryFileProvider } from "./in-memory-provider.js";
|
|
@@ -6,30 +6,30 @@ import type { ConfigAccessor, ConfigAccessorFactory, Registry } from "../engine/
|
|
|
6
6
|
import type { SecretsContext } from "../secrets/index.ts";
|
|
7
7
|
import type { FileStorageProvider } from "./types.ts";
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Narrow surface type for provider plugins. HandlerContext carries tx, actor,
|
|
10
|
+
* signal and more; providers only get the read fields needed for tenant config
|
|
11
|
+
* and secret lookup.
|
|
12
12
|
*
|
|
13
|
-
* **
|
|
14
|
-
* request-
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* **Why not the full HandlerContext?** On the worker path (r.job) there is no
|
|
14
|
+
* request-bound `tx`/`actor`/`signal`. A provider reading `ctx.tx` would break
|
|
15
|
+
* the whole worker path at runtime, and that would only show up with S3 in
|
|
16
|
+
* production. The narrow surface forces providers to extend it explicitly
|
|
17
|
+
* instead of quietly relying on extra ctx fields.
|
|
18
18
|
*
|
|
19
|
-
* **
|
|
20
|
-
* config
|
|
21
|
-
* registry
|
|
22
|
-
* secrets
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
19
|
+
* **Fields:**
|
|
20
|
+
* config — tenant config reads (bucket/region/endpoint/...)
|
|
21
|
+
* registry — extension lookup in the factory (not plugin-internal)
|
|
22
|
+
* secrets — tenant secret reads (s3.secretAccessKey)
|
|
23
|
+
* auditUserId — audit and authority identity for secret reads. The framework
|
|
24
|
+
* resolver sets SYSTEM_USER_ID because the s3 provider reads
|
|
25
|
+
* `s3.secretAccessKey`, which non-admin request users may not;
|
|
26
|
+
* request-user authorization stays on the route accessGuard.
|
|
27
27
|
*/
|
|
28
28
|
export type FileProviderContext = {
|
|
29
29
|
readonly config?: ConfigAccessor;
|
|
30
30
|
readonly registry?: Registry;
|
|
31
31
|
readonly secrets?: SecretsContext;
|
|
32
|
-
readonly
|
|
32
|
+
readonly auditUserId?: string | undefined;
|
|
33
33
|
};
|
|
34
34
|
/**
|
|
35
35
|
* File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
|
|
@@ -110,7 +110,7 @@ export function makeFileProviderResolver(deps) {
|
|
|
110
110
|
db: deps.db,
|
|
111
111
|
secrets: deps.secrets,
|
|
112
112
|
});
|
|
113
|
-
return createFileProviderForTenant({ config, registry: deps.registry, secrets: deps.secrets,
|
|
113
|
+
return createFileProviderForTenant({ config, registry: deps.registry, secrets: deps.secrets, auditUserId: SYSTEM_USER_ID }, tenantId);
|
|
114
114
|
})();
|
|
115
115
|
built.catch(() => {
|
|
116
116
|
if (cache.get(tenantId) === built)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type EgressPolicy, type ResolvedHost } from "./policy.ts";
|
|
2
|
+
type PinnedRequestInit = RequestInit & {
|
|
3
|
+
tls?: {
|
|
4
|
+
servername: string;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
export declare function egress(policy: EgressPolicy): (raw: string, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
export declare function withOriginalUrl(res: Response, originalUrl: URL): Response;
|
|
9
|
+
export declare function buildPinnedRequest(url: URL, resolved: ResolvedHost, init: RequestInit | undefined): {
|
|
10
|
+
url: URL;
|
|
11
|
+
init: PinnedRequestInit;
|
|
12
|
+
};
|
|
13
|
+
export declare function withManualRedirect(init: RequestInit | undefined): RequestInit;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assertAllowedTarget, assertHttpScheme, assertValidInternalPolicy, resolvePublicHost, } from "./policy.js";
|
|
2
|
+
const MAX_INTERNAL_REDIRECTS = 5;
|
|
3
|
+
// Single exported way to speak outward. The policy is named once, at bind
|
|
4
|
+
// time, and cannot drift from the request that follows it.
|
|
5
|
+
export function egress(policy) {
|
|
6
|
+
if (policy.kind === "internal")
|
|
7
|
+
assertValidInternalPolicy(policy);
|
|
8
|
+
return (raw, init) => runEgress(policy, raw, init);
|
|
9
|
+
}
|
|
10
|
+
async function runEgress(policy, raw, init) {
|
|
11
|
+
const url = new URL(raw);
|
|
12
|
+
assertHttpScheme(url);
|
|
13
|
+
if (policy.kind === "internal") {
|
|
14
|
+
assertAllowedTarget(url, policy);
|
|
15
|
+
return fetchWithAllowlistedRedirects(url, policy, init);
|
|
16
|
+
}
|
|
17
|
+
// external + tenant-supplied: deny private/reserved/link-local ranges,
|
|
18
|
+
// pin the connect to the exact address that passed that check (closes the
|
|
19
|
+
// DNS-rebinding TOCTOU, see buildPinnedRequest), and never follow a
|
|
20
|
+
// redirect — a 3xx could point anywhere, including back into the denied
|
|
21
|
+
// ranges above. The caller sees the 3xx response itself and, to follow it,
|
|
22
|
+
// is expected to call `egress()` again with the Location header — which
|
|
23
|
+
// re-runs this same resolve-and-pin check against the new host.
|
|
24
|
+
const resolved = await resolvePublicHost(url);
|
|
25
|
+
const pinned = buildPinnedRequest(url, resolved, init);
|
|
26
|
+
const res = await fetch(pinned.url, withManualRedirect(pinned.init));
|
|
27
|
+
return withOriginalUrl(res, url);
|
|
28
|
+
}
|
|
29
|
+
// fetch() reports `res.url` as the address it actually connected to — the
|
|
30
|
+
// pinned IP literal, not the hostname the caller asked for. Left alone, a
|
|
31
|
+
// caller resolving a relative Location header the normal way
|
|
32
|
+
// (`new URL(location, res.url)`) would resolve it against the IP instead of
|
|
33
|
+
// the original host, and the re-invoked egress() call would then pin and
|
|
34
|
+
// SNI-validate against that IP, breaking virtual hosting and TLS for
|
|
35
|
+
// essentially every real server. Overwriting `.url` back to the original
|
|
36
|
+
// request URL keeps that caller pattern working exactly as it would without
|
|
37
|
+
// IP pinning.
|
|
38
|
+
export function withOriginalUrl(res, originalUrl) {
|
|
39
|
+
Object.defineProperty(res, "url", { value: originalUrl.toString(), configurable: true });
|
|
40
|
+
return res;
|
|
41
|
+
}
|
|
42
|
+
// Rewrites the request to connect directly to `resolved.address` — the
|
|
43
|
+
// exact address `resolvePublicHost` just validated — instead of handing
|
|
44
|
+
// `fetch()` the original hostname and letting it resolve again. That second
|
|
45
|
+
// resolution is the DNS-rebinding window: fetch-by-IP removes it entirely,
|
|
46
|
+
// since there is no hostname left for fetch to look up. The original
|
|
47
|
+
// hostname is preserved in the Host header (virtual hosting) and in
|
|
48
|
+
// `tls.servername` (TLS SNI, and certificate validation still checks the
|
|
49
|
+
// real hostname — not the IP we dial).
|
|
50
|
+
export function buildPinnedRequest(url, resolved, init) {
|
|
51
|
+
const pinnedHost = resolved.family === 6 ? `[${resolved.address}]` : resolved.address;
|
|
52
|
+
const pinnedUrl = new URL(url.toString());
|
|
53
|
+
pinnedUrl.hostname = pinnedHost;
|
|
54
|
+
if (pinnedUrl.hostname !== pinnedHost) {
|
|
55
|
+
// The WHATWG URL hostname setter silently no-ops on invalid input
|
|
56
|
+
// instead of throwing. If that ever happened here, the request would
|
|
57
|
+
// silently fall back to the original (unpinned) hostname — reopening
|
|
58
|
+
// the rebinding window this function exists to close. Fail loudly.
|
|
59
|
+
throw new Error(`egress: failed to pin connection to resolved address ${resolved.address}`);
|
|
60
|
+
}
|
|
61
|
+
const headers = new Headers(init?.headers);
|
|
62
|
+
headers.set("host", url.host);
|
|
63
|
+
const pinnedInit = { ...init, headers };
|
|
64
|
+
if (url.protocol === "https:") {
|
|
65
|
+
pinnedInit.tls = { servername: url.hostname };
|
|
66
|
+
}
|
|
67
|
+
return { url: pinnedUrl, init: pinnedInit };
|
|
68
|
+
}
|
|
69
|
+
// Applied last, after spreading the caller's `init` — a caller cannot
|
|
70
|
+
// override the policy's redirect handling by passing its own `redirect`
|
|
71
|
+
// option. Used by both the external/tenant-supplied fetch above and the
|
|
72
|
+
// internal redirect loop below, so it is not part of the package's public
|
|
73
|
+
// barrel (not re-exported from index.ts) but is a plain named export for
|
|
74
|
+
// that shared use and for pinning the invariant directly in
|
|
75
|
+
// ./__tests__/egress.test.ts.
|
|
76
|
+
export function withManualRedirect(init) {
|
|
77
|
+
return { ...init, redirect: "manual" };
|
|
78
|
+
}
|
|
79
|
+
const ORIGIN_CHANGE_SAFE_HEADERS = new Set([
|
|
80
|
+
"content-type",
|
|
81
|
+
"accept",
|
|
82
|
+
"accept-encoding",
|
|
83
|
+
"accept-language",
|
|
84
|
+
"user-agent",
|
|
85
|
+
]);
|
|
86
|
+
// Allowlist, not denylist: any credential-bearing header we have not thought
|
|
87
|
+
// of (x-api-key, custom tokens) must not follow a redirect to another origin.
|
|
88
|
+
function stripToSafeHeaders(init) {
|
|
89
|
+
const kept = new Headers();
|
|
90
|
+
for (const [name, value] of new Headers(init?.headers)) {
|
|
91
|
+
if (ORIGIN_CHANGE_SAFE_HEADERS.has(name))
|
|
92
|
+
kept.set(name, value);
|
|
93
|
+
}
|
|
94
|
+
return { ...init, headers: kept };
|
|
95
|
+
}
|
|
96
|
+
// `internal` is the only kind that permits redirects (it is the only kind
|
|
97
|
+
// with an allowlist to re-validate them against). Each hop's Location is
|
|
98
|
+
// resolved, restricted to the current hop's host and checked against the
|
|
99
|
+
// policy before it is followed, capped at MAX_INTERNAL_REDIRECTS.
|
|
100
|
+
// `init` (method, body) is replayed verbatim on every hop — unlike a browser,
|
|
101
|
+
// this does not downgrade POST to GET on a 301/302/303. Headers are replayed
|
|
102
|
+
// only while the origin is unchanged; once a hop leaves the original origin
|
|
103
|
+
// (scheme or port differs), credential headers are dropped for the rest of the
|
|
104
|
+
// chain. The body is still replayed to the new origin (as fetch does on
|
|
105
|
+
// 307/308); use allowOrigins to forbid such hops entirely.
|
|
106
|
+
async function fetchWithAllowlistedRedirects(url, policy, init) {
|
|
107
|
+
let current = url;
|
|
108
|
+
let hopInit = init;
|
|
109
|
+
let strippedInit;
|
|
110
|
+
for (let hop = 0; hop <= MAX_INTERNAL_REDIRECTS; hop++) {
|
|
111
|
+
const res = await fetch(current, withManualRedirect(hopInit));
|
|
112
|
+
if (res.status < 300 || res.status >= 400)
|
|
113
|
+
return res;
|
|
114
|
+
const location = res.headers.get("location");
|
|
115
|
+
if (!location)
|
|
116
|
+
return res; // 3xx without Location — nothing to follow
|
|
117
|
+
let next;
|
|
118
|
+
try {
|
|
119
|
+
next = new URL(location, current);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`egress(internal): redirect Location header is not a resolvable URL: ${location}`);
|
|
123
|
+
}
|
|
124
|
+
assertHttpScheme(next);
|
|
125
|
+
// Hosts may differ in the allowlist; credentials must still not hop
|
|
126
|
+
// between them.
|
|
127
|
+
if (next.hostname.toLowerCase() !== current.hostname.toLowerCase()) {
|
|
128
|
+
throw new Error(`egress(internal): redirect crosses host (${current.hostname} -> ${next.hostname}), not followed`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
assertAllowedTarget(next, policy);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Prefix is the contract consumers match on (chat-webhook-sender); origins only, no path/query.
|
|
135
|
+
throw new Error(`egress(internal): redirect target not in allowlist (${current.origin} -> ${next.origin}), not followed`);
|
|
136
|
+
}
|
|
137
|
+
if (next.origin !== url.origin) {
|
|
138
|
+
strippedInit ??= stripToSafeHeaders(init);
|
|
139
|
+
hopInit = strippedInit;
|
|
140
|
+
}
|
|
141
|
+
current = next;
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`egress(internal): exceeded ${MAX_INTERNAL_REDIRECTS} redirects`);
|
|
144
|
+
}
|
package/dist/http/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export
|
|
2
|
-
export {
|
|
1
|
+
export { buildPinnedRequest, egress } from "./egress.ts";
|
|
2
|
+
export type { EgressPolicy, ResolvedHost } from "./policy.ts";
|
|
3
|
+
export { BlockedHostError, HostResolutionError, isPublicHost, resolvePublicHost, resolvePublicHostname, } from "./policy.ts";
|
package/dist/http/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { buildPinnedRequest, egress } from "./egress.js";
|
|
2
|
+
export { BlockedHostError, HostResolutionError, isPublicHost, resolvePublicHost, resolvePublicHostname, } from "./policy.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
export type EgressPolicy = {
|
|
3
|
+
readonly kind: "external";
|
|
4
|
+
} | {
|
|
5
|
+
readonly kind: "internal";
|
|
6
|
+
readonly allowHosts?: readonly string[];
|
|
7
|
+
readonly allowOrigins?: readonly string[];
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: "tenant-supplied";
|
|
10
|
+
};
|
|
11
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
12
|
+
export declare function assertHttpScheme(url: URL): void;
|
|
13
|
+
export interface ResolvedHost {
|
|
14
|
+
readonly address: string;
|
|
15
|
+
readonly family: 4 | 6;
|
|
16
|
+
}
|
|
17
|
+
export declare class BlockedHostError extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
export declare class HostResolutionError extends Error {
|
|
21
|
+
constructor(message: string);
|
|
22
|
+
}
|
|
23
|
+
export declare function resolvePublicHostname(rawHost: string, lookupFn?: typeof lookup): Promise<ResolvedHost>;
|
|
24
|
+
export declare function resolvePublicHost(url: URL, lookupFn?: typeof lookup): Promise<ResolvedHost>;
|
|
25
|
+
export declare function isPublicHost(raw: string, lookupFn?: typeof lookup): Promise<boolean>;
|
|
26
|
+
export type InternalEgressPolicy = Extract<EgressPolicy, {
|
|
27
|
+
kind: "internal";
|
|
28
|
+
}>;
|
|
29
|
+
export declare function assertValidInternalPolicy(policy: InternalEgressPolicy): void;
|
|
30
|
+
export declare function assertAllowedTarget(url: URL, policy: InternalEgressPolicy): void;
|