@cosmicdrift/kumiko-framework 0.374.0 → 0.377.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 (65) 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/embedded-entity-form.md +1 -1
  6. package/agents/examples/notes-history-basic.md +1 -1
  7. package/agents/examples/tags-basic.md +1 -1
  8. package/agents/examples/user-profile.md +1 -1
  9. package/agents/examples/wizard-form.md +4 -4
  10. package/agents/widgets.md +3 -3
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/index.js +1 -1
  13. package/dist/api/routes.js +2 -2
  14. package/dist/api/server.js +2 -1
  15. package/dist/config/index.d.ts +4 -0
  16. package/dist/db/index.d.ts +2 -2
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/migrate-runner.js +12 -4
  19. package/dist/db/money.js +1 -1
  20. package/dist/db/query.js +14 -14
  21. package/dist/db/replay-migration-sql.d.ts +1 -0
  22. package/dist/db/replay-migration-sql.js +115 -11
  23. package/dist/db/tenant-db.d.ts +7 -0
  24. package/dist/db/tenant-db.js +17 -5
  25. package/dist/engine/boot-validator/screens.js +7 -1
  26. package/dist/engine/index.d.ts +2 -1
  27. package/dist/engine/index.js +1 -0
  28. package/dist/engine/parse-ref-target.js +1 -1
  29. package/dist/engine/provider-plugin-context.d.ts +3 -0
  30. package/dist/engine/provider-plugin-context.js +28 -0
  31. package/dist/engine/schema-builder.js +7 -0
  32. package/dist/engine/types/index.d.ts +1 -1
  33. package/dist/env/index.js +1 -1
  34. package/dist/errors/zod-bridge.js +1 -1
  35. package/dist/files/file-ref-entity.d.ts +6 -6
  36. package/dist/files/file-ref-table.d.ts +6 -6
  37. package/dist/files/index.d.ts +1 -1
  38. package/dist/files/index.js +1 -1
  39. package/dist/files/provider-resolver.d.ts +17 -17
  40. package/dist/files/provider-resolver.js +1 -1
  41. package/dist/http/egress.d.ts +14 -0
  42. package/dist/http/egress.js +144 -0
  43. package/dist/http/index.d.ts +3 -2
  44. package/dist/http/index.js +2 -1
  45. package/dist/http/policy.d.ts +30 -0
  46. package/dist/http/policy.js +299 -0
  47. package/dist/i18n/required-surface-keys.js +2 -0
  48. package/dist/jobs/job-runner.js +4 -2
  49. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  50. package/dist/pipeline/config-accessor-factory.js +3 -0
  51. package/dist/pipeline/dispatch-shared.js +7 -5
  52. package/dist/pipeline/index.d.ts +1 -0
  53. package/dist/pipeline/index.js +1 -0
  54. package/dist/ui-types/index.d.ts +1 -1
  55. package/dist/upgrade-changelog.d.ts +25 -0
  56. package/dist/upgrade-changelog.js +48 -0
  57. package/dist/upgrade-cli.js +78 -13
  58. package/package.json +3 -5
  59. package/presets/biome-app.json +8 -0
  60. package/src/changes.json +71 -0
  61. package/src/scripts/codemod/README.md +25 -0
  62. package/src/scripts/codemod/codemod-support.ts +64 -6
  63. package/src/scripts/codemod/package-merge.ts +260 -0
  64. package/src/scripts/codemod/s3-imports.ts +4 -42
  65. package/src/scripts/codemod/testing-injection.ts +135 -0
@@ -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;
@@ -0,0 +1,299 @@
1
+ // Egress trust boundary. `EgressPolicy` and the checks below back the single
2
+ // exported way to speak outward, `egress()` in ./egress.ts. Only `isPublicHost`
3
+ // reaches the package's public barrel (index.ts), as a boolean pre-check for
4
+ // create-time validation — call sites otherwise bind a policy once via
5
+ // `egress(policy)` and never see the range table or the allowlist check
6
+ // directly.
7
+ //
8
+ // `external` and `tenant-supplied` resolve to the identical set of checks
9
+ // below (deny private/reserved/link-local + no redirects). They stay
10
+ // separate policy kinds because they carry different trust semantics — a
11
+ // tenant-controlled URL is adversary-input in a way a hardcoded external
12
+ // endpoint is not.
13
+ //
14
+ // DNS-rebinding protection: `resolvePublicHost` resolves the hostname
15
+ // exactly once, validates every returned address, and hands back the one
16
+ // address ./egress.ts connects to directly (fetch-by-IP, with the original
17
+ // hostname preserved for the Host header and TLS SNI/cert validation).
18
+ // There is no second resolution for an attacker-controlled DNS answer to
19
+ // swap in between check and connect — see `resolvePublicHost` below for the
20
+ // mechanism and ../__tests__/egress.test.ts / policy.test.ts for the tests
21
+ // pinning it.
22
+ import { lookup } from "node:dns/promises";
23
+ import { isIP } from "node:net";
24
+ export function isBlockedIp(ip) {
25
+ const kind = isIP(ip);
26
+ if (kind === 4)
27
+ return isBlockedV4(ip);
28
+ if (kind === 6)
29
+ return isBlockedV6(ip);
30
+ return true; // not parseable as an IP -> fail closed
31
+ }
32
+ function inRange(n, [min, max]) {
33
+ return n >= min && n <= max;
34
+ }
35
+ // Each entry: first-octet range, an optional second-octet range for ranges
36
+ // narrower than a full /8, and an optional third-octet range for the one
37
+ // entry (192.0.0.0/24) narrower than a full /16. Table form keeps the range
38
+ // list scannable as data rather than as a chain of near-identical `if`s.
39
+ const BLOCKED_V4_RANGES = [
40
+ { a: [0, 0] }, // 0.0.0.0/8 "this host"
41
+ { a: [10, 10] }, // 10/8 private
42
+ { a: [127, 127] }, // loopback
43
+ { a: [169, 169], b: [254, 254] }, // link-local + cloud metadata (169.254.169.254)
44
+ { a: [172, 172], b: [16, 31] }, // 172.16/12 private
45
+ { a: [192, 192], b: [0, 0], c: [0, 0] }, // 192.0.0.0/24 IETF protocol assignments
46
+ { a: [192, 192], b: [168, 168] }, // 192.168/16 private
47
+ { a: [198, 198], b: [18, 19] }, // 198.18.0.0/15 benchmarking (RFC 2544)
48
+ { a: [100, 100], b: [64, 127] }, // 100.64/10 CGNAT
49
+ { a: [224, 255] }, // multicast/reserved — also covers 240.0.0.0/4 and the broadcast address
50
+ ];
51
+ function isBlockedV4(ip) {
52
+ const [a = 0, b = 0, c = 0] = ip.split(".").map(Number);
53
+ return BLOCKED_V4_RANGES.some((range) => inRange(a, range.a) &&
54
+ (range.b === undefined || inRange(b, range.b)) &&
55
+ (range.c === undefined || inRange(c, range.c)));
56
+ }
57
+ // WHATWG URL host-parsing always serializes IPv6 into the canonical
58
+ // compressed lowercase form (RFC 5952) regardless of the input shape —
59
+ // uncompressed groups, embedded IPv4-dotted notation, mixed case all collapse
60
+ // to one string. isBlockedV6 below only has to handle that one shape, so any
61
+ // caller-supplied IPv6 literal is normalized through `new URL()` first.
62
+ function canonicalIPv6(ip) {
63
+ try {
64
+ return new URL(`http://[${ip}]/`).hostname.replace(/^\[/, "").replace(/\]$/, "");
65
+ }
66
+ catch {
67
+ return ip; // unparsable — falls through to the (fail-closed) checks below
68
+ }
69
+ }
70
+ const HEX_GROUP_RE = /^[0-9a-f]{1,4}$/;
71
+ // Expands a canonicalized (RFC 5952) IPv6 literal into its 8 explicit
72
+ // 16-bit groups. Compression ("::") swallows a variable number of the
73
+ // trailing zero groups an embedded IPv4 address produces — e.g.
74
+ // `64:ff9b::0.0.0.1` canonicalizes to `64:ff9b::1` (one group, not two) —
75
+ // so matching by regex on the compressed string misses those forms.
76
+ // Expanding first lets every embedded-v4 check below match by fixed group
77
+ // *position* regardless of how much of the address compressed away.
78
+ // Returns null for anything that isn't well-formed hex-group IPv6 (e.g. a
79
+ // zone id), which isBlockedV6 treats as fail-closed.
80
+ function expandGroups(ip) {
81
+ const parts = ip.split("::");
82
+ if (parts.length > 2)
83
+ return null;
84
+ const toGroups = (segment) => (segment ? segment.split(":") : []);
85
+ const head = toGroups(parts[0]);
86
+ if (parts.length === 1) {
87
+ if (head.length !== 8 || !head.every((g) => HEX_GROUP_RE.test(g)))
88
+ return null;
89
+ return head.map((g) => Number.parseInt(g, 16));
90
+ }
91
+ const tail = toGroups(parts[1]);
92
+ const missing = 8 - head.length - tail.length;
93
+ if (missing < 0 || ![...head, ...tail].every((g) => HEX_GROUP_RE.test(g)))
94
+ return null;
95
+ return [...head, ...Array(missing).fill("0"), ...tail].map((g) => Number.parseInt(g, 16));
96
+ }
97
+ // Decodes the two 16-bit groups at `at`/`at+1` back into dotted-decimal, for
98
+ // the v4 range rules.
99
+ function decodeEmbeddedV4(groups, at) {
100
+ const hi = groups[at] ?? 0;
101
+ const lo = groups[at + 1] ?? 0;
102
+ return `${hi >>> 8}.${hi & 0xff}.${lo >>> 8}.${lo & 0xff}`;
103
+ }
104
+ // Every "IPv4-in-IPv6" encoding a URL literal can carry, as the fixed
105
+ // leading groups (in expanded, 8-group form) and the group index the
106
+ // embedded IPv4 address starts at. Matching by expanded position instead of
107
+ // regex on the compressed string is what makes a single-group-compressed
108
+ // embed (see expandGroups above) match the same as an uncompressed one.
109
+ const EMBEDDED_V4_FORMS = [
110
+ { prefix: [0, 0, 0, 0, 0, 0], at: 6 }, // ::a.b.c.d — deprecated IPv4-compatible (RFC 4291)
111
+ { prefix: [0, 0, 0, 0, 0, 0xffff], at: 6 }, // ::ffff:a.b.c.d — IPv4-mapped (RFC 4291)
112
+ { prefix: [0, 0, 0, 0, 0xffff, 0], at: 6 }, // ::ffff:0:a.b.c.d — IPv4-translated (RFC 8215)
113
+ { prefix: [0x64, 0xff9b, 0, 0, 0, 0], at: 6 }, // 64:ff9b::a.b.c.d — NAT64 well-known prefix (RFC 6052)
114
+ { prefix: [0x64, 0xff9b, 1], at: 6 }, // 64:ff9b:1::a.b.c.d — NAT64 local-use prefix (RFC 8215)
115
+ { prefix: [0x2002], at: 1 }, // 2002:a.b.c.d:: — 6to4 (RFC 3056)
116
+ ];
117
+ function matchesPrefix(groups, prefix) {
118
+ return prefix.every((value, index) => groups[index] === value);
119
+ }
120
+ const BLOCKED_V6_EXACT = new Set(["::1", "::"]); // loopback / unspecified
121
+ // All three ranges expressed on the first 16-bit group ("head"), so they
122
+ // share one table instead of three near-identical `if`s:
123
+ // fc00::/7 unique-local -> head 0xfc00-0xfdff
124
+ // fe80::/10 link-local -> head 0xfe80-0xfebf
125
+ // fec0::/10 deprecated site-local (RFC 3879, 2004) — no DNS server hands
126
+ // this out today, but this is an exported security boundary,
127
+ // so block it rather than lean on the deprecation.
128
+ const BLOCKED_V6_HEAD_RANGES = [
129
+ [0xfc00, 0xfdff],
130
+ [0xfe80, 0xfebf],
131
+ [0xfec0, 0xfeff],
132
+ ];
133
+ function isBlockedV6(ip) {
134
+ const lower = canonicalIPv6(ip.toLowerCase());
135
+ if (BLOCKED_V6_EXACT.has(lower))
136
+ return true;
137
+ const groups = expandGroups(lower);
138
+ if (!groups)
139
+ return true; // not well-formed IPv6 -> fail closed
140
+ const embedded = EMBEDDED_V4_FORMS.find((form) => matchesPrefix(groups, form.prefix));
141
+ if (embedded)
142
+ return isBlockedV4(decodeEmbeddedV4(groups, embedded.at));
143
+ const head = groups[0] ?? 0;
144
+ return BLOCKED_V6_HEAD_RANGES.some((range) => inRange(head, range));
145
+ }
146
+ function stripBrackets(host) {
147
+ return host.replace(/^\[/, "").replace(/\]$/, "");
148
+ }
149
+ export function assertHttpScheme(url) {
150
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
151
+ throw new Error(`egress: unsupported scheme ${url.protocol}`);
152
+ }
153
+ }
154
+ // Denies private/reserved/link-local ranges for `external` and
155
+ // `tenant-supplied`, and pins the exact address ./egress.ts must connect to.
156
+ // Resolves the hostname's A/AAAA records (or reads the literal IP directly),
157
+ // rejects if ANY resolved address falls in a denied range — a host with one
158
+ // public and one private record must not pass — and returns ONE validated
159
+ // address from that same resolution. Reusing that address for the connect,
160
+ // instead of letting `fetch()` resolve the hostname again, is what closes
161
+ // the DNS-rebinding window: there is no second lookup left for an
162
+ // attacker-controlled DNS server to answer differently.
163
+ //
164
+ // Thrown for a host that is itself a blocked IP-literal, or that resolves to
165
+ // one — a policy verdict, not a network condition. Retrying will not help;
166
+ // callers should surface this as a rejected configuration.
167
+ export class BlockedHostError extends Error {
168
+ constructor(message) {
169
+ super(message);
170
+ this.name = "BlockedHostError";
171
+ }
172
+ }
173
+ // Thrown when the resolver itself failed or returned nothing — a network
174
+ // condition, not a policy verdict. Callers that retry (e.g. an inbound-mail
175
+ // poll) should treat this as transient, unlike BlockedHostError.
176
+ export class HostResolutionError extends Error {
177
+ constructor(message) {
178
+ super(message);
179
+ this.name = "HostResolutionError";
180
+ }
181
+ }
182
+ // Resolves a bare hostname or IP-literal (no scheme, no URL parsing) exactly
183
+ // once and returns the single validated address the caller must connect to
184
+ // — the same DNS-rebinding-closing mechanism as resolvePublicHost below,
185
+ // factored out for callers that only ever have a host (SMTP/IMAP tenant
186
+ // config), never a full URL.
187
+ //
188
+ // `lookupFn` defaults to the real resolver and exists only so tests can pin
189
+ // deterministic, network-free answers — production call sites never pass it.
190
+ export async function resolvePublicHostname(rawHost, lookupFn = lookup) {
191
+ const host = stripBrackets(rawHost);
192
+ const ipVersion = isIP(host);
193
+ if (ipVersion !== 0) {
194
+ if (isBlockedIp(host))
195
+ throw new BlockedHostError(`egress: host is not a public address: ${host}`);
196
+ return { address: host, family: ipVersion === 6 ? 6 : 4 };
197
+ }
198
+ let addresses;
199
+ try {
200
+ addresses = await lookupFn(host, { all: true });
201
+ }
202
+ catch {
203
+ throw new HostResolutionError(`egress: DNS resolution failed for host: ${host}`);
204
+ }
205
+ if (addresses.length === 0) {
206
+ throw new HostResolutionError(`egress: DNS resolution returned no records for host: ${host}`);
207
+ }
208
+ const blocked = addresses.find((a) => isBlockedIp(a.address));
209
+ if (blocked) {
210
+ throw new BlockedHostError(`egress: host resolves to a non-public address: ${host} -> ${blocked.address}`);
211
+ }
212
+ // IPv4 first: hosts and runners without an IPv6 route would otherwise get an unreachable pinned address.
213
+ const chosen = addresses.find((address) => address.family === 4) ?? addresses[0];
214
+ if (!chosen) {
215
+ throw new HostResolutionError(`egress: DNS resolution returned no records for host: ${host}`);
216
+ }
217
+ return { address: chosen.address, family: chosen.family === 6 ? 6 : 4 };
218
+ }
219
+ export async function resolvePublicHost(url, lookupFn = lookup) {
220
+ if (url.username || url.password) {
221
+ throw new Error("egress: URLs with embedded credentials are not supported");
222
+ }
223
+ return resolvePublicHostname(url.hostname, lookupFn);
224
+ }
225
+ // Create-time validation for tenant-supplied URLs (e.g. webhook registration)
226
+ // where rejecting at registration is better UX than failing later at fetch
227
+ // time. Runs the same resolution as `egress()` without connecting anywhere,
228
+ // and never throws — bad input (unparsable URL, non-http(s) scheme, embedded
229
+ // credentials, DNS failure, blocked range) all just resolve to `false`.
230
+ //
231
+ // A `true` only holds at the moment of the check: DNS can change afterwards,
232
+ // so this is a UX pre-check, not a substitute for the fetch-time check.
233
+ // Outbound requests to tenant-supplied URLs MUST still go through
234
+ // `egress({ kind: "tenant-supplied" })`, never a plain `fetch()`.
235
+ export async function isPublicHost(raw, lookupFn = lookup) {
236
+ try {
237
+ const url = new URL(raw);
238
+ assertHttpScheme(url);
239
+ await resolvePublicHost(url, lookupFn);
240
+ return true;
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ }
246
+ function isHostEntryWellFormed(entry) {
247
+ if (entry.includes("/") || entry.includes("@") || entry.includes("://"))
248
+ return false;
249
+ if (!entry.includes(":"))
250
+ return true;
251
+ return isIP(stripBrackets(entry)) === 6;
252
+ }
253
+ function isOriginEntryWellFormed(entry) {
254
+ let parsed;
255
+ try {
256
+ parsed = new URL(entry);
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ return ((parsed.protocol === "http:" || parsed.protocol === "https:") &&
262
+ !parsed.username &&
263
+ !parsed.password &&
264
+ parsed.pathname === "/" &&
265
+ !parsed.search &&
266
+ !parsed.hash);
267
+ }
268
+ // Runs once at bind time so a misconfigured allowlist fails at startup, not
269
+ // on the first request.
270
+ export function assertValidInternalPolicy(policy) {
271
+ const allowHosts = policy.allowHosts ?? [];
272
+ const allowOrigins = policy.allowOrigins ?? [];
273
+ if (allowHosts.length === 0 && allowOrigins.length === 0) {
274
+ throw new Error("egress(internal): allowHosts or allowOrigins must list at least one entry");
275
+ }
276
+ for (const entry of allowHosts) {
277
+ if (!isHostEntryWellFormed(entry)) {
278
+ throw new Error(`egress(internal): allowHosts entry must be a bare hostname, got "${entry}" (use allowOrigins to pin scheme and port)`);
279
+ }
280
+ }
281
+ // The entry is named by index only: it may carry credentials that must not reach startup logs.
282
+ allowOrigins.forEach((entry, index) => {
283
+ if (!isOriginEntryWellFormed(entry)) {
284
+ throw new Error(`egress(internal): allowOrigins[${index}] must be an http(s) origin like "https://host:port" without path, query or credentials`);
285
+ }
286
+ });
287
+ }
288
+ // Explicit allowlist check for `internal` — no range check, since `internal`
289
+ // targets are deliberately private/cluster-local. The hostname (not the
290
+ // resolved IP) must match `allowHosts` (any port/scheme) or the full origin
291
+ // (scheme + host + port) must match `allowOrigins`.
292
+ export function assertAllowedTarget(url, policy) {
293
+ const host = stripBrackets(url.hostname).toLowerCase();
294
+ const hostAllowed = (policy.allowHosts ?? []).some((entry) => stripBrackets(entry).toLowerCase() === host);
295
+ const originAllowed = (policy.allowOrigins ?? []).some((entry) => new URL(entry).origin === url.origin);
296
+ if (!hostAllowed && !originAllowed) {
297
+ throw new Error(`egress: target not in allowlist: ${url.origin}`);
298
+ }
299
+ }
@@ -304,6 +304,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
304
304
  pushKey(out, action.label);
305
305
  for (const link of form.successLinks ?? [])
306
306
  pushKey(out, link.label);
307
+ for (const fact of form.successFacts ?? [])
308
+ pushKey(out, fact.label);
307
309
  for (const fieldName of Object.keys(form.fields)) {
308
310
  const override = form.fieldLabels?.[fieldName];
309
311
  if (override !== undefined)
@@ -15,6 +15,7 @@ import { isKumikoError } from "../errors/kumiko-error.js";
15
15
  import { createTenantFileContext } from "../files/file-handle.js";
16
16
  import { createFallbackLogger } from "../logging/index.js";
17
17
  import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
18
+ import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
18
19
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
19
20
  import { RedisKeys } from "../pipeline/redis-keys.js";
20
21
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
@@ -967,8 +968,9 @@ export function createJobRunner(options) {
967
968
  const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
968
969
  ? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
969
970
  : undefined;
970
- const config = context._configAccessorFactory && tenantScopedDb
971
- ? context._configAccessorFactory({
971
+ const configFactory = configAccessorFactoryOf(context);
972
+ const config = configFactory && tenantScopedDb
973
+ ? configFactory({
972
974
  user: { id: jobSystemUser.id, tenantId },
973
975
  db: tenantScopedDb,
974
976
  secrets: context.secrets,
@@ -0,0 +1,3 @@
1
+ import type { ConfigAccessorFactory } from "@cosmicdrift/kumiko-types/config";
2
+ import type { AppContext } from "@cosmicdrift/kumiko-types/handlers";
3
+ export declare function configAccessorFactoryOf(context: Pick<AppContext, "configAccessorFactory" | "_configAccessorFactory">): ConfigAccessorFactory | undefined;
@@ -0,0 +1,3 @@
1
+ export function configAccessorFactoryOf(context) {
2
+ return context.configAccessorFactory ?? context._configAccessorFactory;
3
+ }
@@ -22,6 +22,7 @@ import { isPlainObject } from "../utils/is-plain-object.js";
22
22
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
23
23
  import { appendDomainEventCore } from "./append-event-core.js";
24
24
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver.js";
25
+ import { configAccessorFactoryOf } from "./config-accessor-factory.js";
25
26
  import { runBatch, unwrapSingle } from "./dispatch-batch.js";
26
27
  import { executeQuery } from "./dispatch-query.js";
27
28
  import { executeWrite } from "./dispatch-write.js";
@@ -393,8 +394,9 @@ readOnlyForTenant = false) {
393
394
  });
394
395
  }
395
396
  // Mirror notify: only built when the config feature wired its factory.
396
- const config = context._configAccessorFactory && db
397
- ? context._configAccessorFactory({
397
+ const configFactory = configAccessorFactoryOf(context);
398
+ const config = configFactory && db
399
+ ? configFactory({
398
400
  user: { id: user.id, tenantId: user.tenantId },
399
401
  db,
400
402
  secrets: context.secrets,
@@ -404,7 +406,6 @@ readOnlyForTenant = false) {
404
406
  // identity is the target tenant's system user so the caller's own user-scope
405
407
  // values never resolve into another tenant's config; the db is tenant-mode
406
408
  // scoped to the target.
407
- const configFactory = context._configAccessorFactory;
408
409
  const configFor = configFactory && dbSource
409
410
  ? (targetTenantId) => {
410
411
  if (targetTenantId === user.tenantId && config)
@@ -1268,8 +1269,9 @@ function buildAuthClaimsContext(ctx, user) {
1268
1269
  projectionRegistry: ctx.registry,
1269
1270
  },
1270
1271
  });
1271
- const configAccessor = context._configAccessorFactory
1272
- ? context._configAccessorFactory({
1272
+ const authClaimsConfigFactory = configAccessorFactoryOf(context);
1273
+ const configAccessor = authClaimsConfigFactory
1274
+ ? authClaimsConfigFactory({
1273
1275
  user: { id: user.id, tenantId: user.tenantId },
1274
1276
  db,
1275
1277
  secrets: context.secrets,
@@ -41,3 +41,4 @@ export type { ProjectionStatus } from "./projection-state.ts";
41
41
  export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.ts";
42
42
  export { runProjectionsForEvent } from "./projections-runner.ts";
43
43
  export { ACCESS_INVALIDATION_CONSUMER_NAME, createAccessInvalidationEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, SEARCH_CONSUMER_NAME, SSE_BROADCAST_CONSUMER_NAME, } from "./system-hooks.ts";
44
+ export { TENANT_TIMEZONE_CONFIG_KEY } from "./tenant-timezone-config-key.ts";
@@ -23,3 +23,4 @@ export { getAllProjectionProgress, getProjectionState, listProjectionsWithState,
23
23
  export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
24
24
  export { runProjectionsForEvent } from "./projections-runner.js";
25
25
  export { ACCESS_INVALIDATION_CONSUMER_NAME, createAccessInvalidationEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, SEARCH_CONSUMER_NAME, SSE_BROADCAST_CONSUMER_NAME, } from "./system-hooks.js";
26
+ export { TENANT_TIMEZONE_CONFIG_KEY } from "./tenant-timezone-config-key.js";
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.ts";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.ts";
12
- export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.ts";
12
+ export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.ts";
13
13
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.ts";
14
14
  export type { TargetRef } from "../engine/types/target-ref.ts";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.ts";
@@ -0,0 +1,25 @@
1
+ export type UpgradeChangelogManual = {
2
+ readonly id: string;
3
+ readonly version: string;
4
+ readonly title: string;
5
+ readonly status: "open" | "migrated" | "not-applicable";
6
+ };
7
+ export type UpgradeChangelogSection = {
8
+ readonly from: string;
9
+ readonly to: string;
10
+ /** YYYY-MM-DD. */
11
+ readonly date: string;
12
+ readonly breaking: readonly {
13
+ readonly version: string;
14
+ readonly title: string;
15
+ }[];
16
+ readonly manual: readonly UpgradeChangelogManual[];
17
+ readonly codemods: readonly {
18
+ readonly version: string;
19
+ readonly codemod: string;
20
+ readonly title: string;
21
+ }[];
22
+ };
23
+ export declare function renderUpgradeSection(section: UpgradeChangelogSection): string;
24
+ /** Puts the section on top; a section for the same target version is replaced, so a re-run stays idempotent. */
25
+ export declare function mergeUpgradeSection(existing: string | undefined, section: UpgradeChangelogSection): string;
@@ -0,0 +1,48 @@
1
+ const CHANGELOG_HEADER = `# Changelog
2
+
3
+ Kumiko upgrades of this app, written by \`kumiko-upgrade --apply\`. Newest first.
4
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+ `;
6
+ const SECTION_START = /^## \[/;
7
+ const PACKAGES_LABEL = "@cosmicdrift/kumiko-*";
8
+ function printableLine(text) {
9
+ return text.replace(/\p{Cc}/gu, " ");
10
+ }
11
+ function bulletList(title, lines) {
12
+ return lines.length === 0 ? [] : ["", `### ${title}`, "", ...lines.map((line) => `- ${line}`)];
13
+ }
14
+ export function renderUpgradeSection(section) {
15
+ return [
16
+ `## [${section.to}] - ${section.date}`,
17
+ ...bulletList("Changed", [`${PACKAGES_LABEL}: ${section.from} → ${section.to}`]),
18
+ ...bulletList("Breaking", section.breaking.map((entry) => `${entry.version} · ${printableLine(entry.title)}`)),
19
+ ...bulletList("Manual migrations", section.manual.map((entry) => `[${entry.status}] ${entry.id} · ${entry.version} · ${printableLine(entry.title)}`)),
20
+ ...bulletList("Codemods applied", section.codemods.map((entry) => `${entry.version} · ${entry.codemod} · ${printableLine(entry.title)}`)),
21
+ "",
22
+ ].join("\n");
23
+ }
24
+ function splitSections(content) {
25
+ const lines = content.split("\n");
26
+ const firstSection = lines.findIndex((line) => SECTION_START.test(line));
27
+ if (firstSection === -1)
28
+ return { header: content.replace(/\n*$/, "\n"), sections: [] };
29
+ const header = lines.slice(0, firstSection).join("\n").replace(/\n*$/, "\n");
30
+ const sections = [];
31
+ let current = [];
32
+ for (const line of lines.slice(firstSection)) {
33
+ if (SECTION_START.test(line) && current.length > 0) {
34
+ sections.push(current.join("\n").replace(/\n*$/, "\n"));
35
+ current = [];
36
+ }
37
+ current.push(line);
38
+ }
39
+ sections.push(current.join("\n").replace(/\n*$/, "\n"));
40
+ return { header, sections };
41
+ }
42
+ /** Puts the section on top; a section for the same target version is replaced, so a re-run stays idempotent. */
43
+ export function mergeUpgradeSection(existing, section) {
44
+ const { header, sections } = splitSections(existing ?? CHANGELOG_HEADER);
45
+ const prefix = `## [${section.to}]`;
46
+ const others = sections.filter((candidate) => !candidate.startsWith(prefix));
47
+ return [header, renderUpgradeSection(section), ...others].join("\n");
48
+ }