@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.
Files changed (89) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/apex-landing.md +1 -1
  3. package/agents/examples/apex-surface-auth.md +3 -3
  4. package/agents/examples/compose-stacks.md +15 -8
  5. package/agents/examples/delivery-notifications.md +25 -2
  6. package/agents/examples/embedded-entity-form.md +1 -1
  7. package/agents/examples/embedded.md +191 -0
  8. package/agents/examples/notes-history-basic.md +1 -1
  9. package/agents/examples/tags-basic.md +1 -1
  10. package/agents/examples/user-profile.md +1 -1
  11. package/agents/examples/wizard-form.md +4 -4
  12. package/agents/features.md +2 -2
  13. package/agents/widgets.md +4 -3
  14. package/dist/api/index.d.ts +1 -1
  15. package/dist/api/index.js +1 -1
  16. package/dist/api/routes.js +2 -2
  17. package/dist/api/server.js +2 -1
  18. package/dist/config/index.d.ts +4 -0
  19. package/dist/db/index.d.ts +2 -2
  20. package/dist/db/index.js +1 -1
  21. package/dist/db/migrate-runner.js +12 -4
  22. package/dist/db/money.d.ts +2 -1
  23. package/dist/db/money.js +3 -8
  24. package/dist/db/query.js +14 -14
  25. package/dist/db/replay-migration-sql.d.ts +1 -0
  26. package/dist/db/replay-migration-sql.js +115 -11
  27. package/dist/db/tenant-db.d.ts +7 -0
  28. package/dist/db/tenant-db.js +21 -5
  29. package/dist/engine/boot-validator/entity-handler.js +3 -0
  30. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  31. package/dist/engine/boot-validator/form-derivations.js +82 -0
  32. package/dist/engine/boot-validator/screens.js +23 -4
  33. package/dist/engine/build-config-feature-schema.js +33 -10
  34. package/dist/engine/embedded-derived.d.ts +1 -1
  35. package/dist/engine/embedded-derived.js +7 -1
  36. package/dist/engine/form-derivations.d.ts +21 -0
  37. package/dist/engine/form-derivations.js +175 -0
  38. package/dist/engine/index.d.ts +5 -1
  39. package/dist/engine/index.js +3 -0
  40. package/dist/engine/minor-units.d.ts +2 -0
  41. package/dist/engine/minor-units.js +8 -0
  42. package/dist/engine/parse-ref-target.js +1 -1
  43. package/dist/engine/provider-plugin-context.d.ts +3 -0
  44. package/dist/engine/provider-plugin-context.js +28 -0
  45. package/dist/engine/schema-builder.js +7 -0
  46. package/dist/engine/template-variables.d.ts +2 -1
  47. package/dist/engine/template-variables.js +4 -0
  48. package/dist/engine/types/index.d.ts +3 -3
  49. package/dist/engine/with-form-derivations.d.ts +9 -0
  50. package/dist/engine/with-form-derivations.js +16 -0
  51. package/dist/env/index.js +1 -1
  52. package/dist/errors/zod-bridge.js +1 -1
  53. package/dist/files/file-ref-entity.d.ts +6 -6
  54. package/dist/files/file-ref-table.d.ts +6 -6
  55. package/dist/files/index.d.ts +1 -1
  56. package/dist/files/index.js +1 -1
  57. package/dist/files/provider-resolver.d.ts +17 -17
  58. package/dist/files/provider-resolver.js +1 -1
  59. package/dist/http/egress.d.ts +14 -0
  60. package/dist/http/egress.js +144 -0
  61. package/dist/http/index.d.ts +3 -2
  62. package/dist/http/index.js +2 -1
  63. package/dist/http/policy.d.ts +30 -0
  64. package/dist/http/policy.js +299 -0
  65. package/dist/i18n/required-surface-keys.js +2 -0
  66. package/dist/i18n/settings-hub-keys.js +1 -0
  67. package/dist/jobs/job-runner.js +4 -2
  68. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  69. package/dist/pipeline/config-accessor-factory.js +3 -0
  70. package/dist/pipeline/dispatch-shared.js +7 -5
  71. package/dist/pipeline/index.d.ts +1 -0
  72. package/dist/pipeline/index.js +1 -0
  73. package/dist/search/configure-tenant-index.d.ts +4 -0
  74. package/dist/search/configure-tenant-index.js +8 -0
  75. package/dist/search/index.d.ts +1 -0
  76. package/dist/search/index.js +1 -0
  77. package/dist/ui-types/index.d.ts +3 -1
  78. package/dist/ui-types/index.js +1 -0
  79. package/dist/upgrade-changelog.d.ts +25 -0
  80. package/dist/upgrade-changelog.js +48 -0
  81. package/dist/upgrade-cli.js +78 -13
  82. package/package.json +3 -5
  83. package/presets/biome-app.json +8 -0
  84. package/src/changes.json +103 -0
  85. package/src/scripts/codemod/README.md +25 -0
  86. package/src/scripts/codemod/codemod-support.ts +64 -6
  87. package/src/scripts/codemod/package-merge.ts +260 -0
  88. package/src/scripts/codemod/s3-imports.ts +4 -42
  89. 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 | {
@@ -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";
@@ -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
- * Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu fett
10
- * (haelt tx, actor, signal etc.) — Provider sollen sich auf die read-Felder
11
- * beschraenken die fuer Tenant-Config + Secret-Lookup gebraucht werden.
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
- * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt es keinen
14
- * request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider `ctx.tx` lesen
15
- * wuerde, wuerde der ganze Worker-Pfad zur Runtime brechen — und das wuerde NUR
16
- * mit S3 und nur in production auffallen. Die schmale Surface zwingt Provider
17
- * zur expliziten Erweiterung statt silent ctx-feld-ausnutzen.
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
- * **Felder:**
20
- * config — fuer tenant-config-reads (bucket/region/endpoint/...)
21
- * registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
22
- * secrets — fuer tenant-secret-reads (s3.secretAccessKey)
23
- * _userId — Audit-/Authority-Identity fuer secret-reads. Der Framework-
24
- * Resolver setzt das auf SYSTEM_USER_ID — der s3-Provider liest
25
- * `s3.secretAccessKey`, was Nicht-Admin-Request-User nicht duerfen;
26
- * die Request-User-Autorisierung bleibt am Route-accessGuard.
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 _userId?: string | undefined;
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, _userId: SYSTEM_USER_ID }, tenantId);
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
+ }
@@ -1,2 +1,3 @@
1
- export type { EgressPolicy, ResolvedHost } from "@cosmicdrift/kumiko-http";
2
- export { BlockedHostError, buildPinnedRequest, egress, HostResolutionError, isPublicHost, resolvePublicHost, resolvePublicHostname, } from "@cosmicdrift/kumiko-http";
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";
@@ -1 +1,2 @@
1
- export { BlockedHostError, buildPinnedRequest, egress, HostResolutionError, isPublicHost, resolvePublicHost, resolvePublicHostname, } from "@cosmicdrift/kumiko-http";
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;