@cosmicdrift/kumiko-framework 0.290.0 → 0.292.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 +4 -4
- package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/changes.json +63 -0
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +93 -0
- package/src/engine/__tests__/boot-validator.test.ts +226 -0
- package/src/engine/__tests__/build-app-schema.test.ts +18 -0
- package/src/engine/__tests__/engine.test.ts +87 -0
- package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
- package/src/engine/boot-validator/entity-handler.ts +44 -0
- package/src/engine/boot-validator/index.ts +10 -4
- package/src/engine/boot-validator/pii-retention.ts +8 -0
- package/src/engine/boot-validator/projection-list-screens.ts +52 -2
- package/src/engine/boot-validator/screens.ts +50 -0
- package/src/engine/create-app.ts +54 -0
- package/src/engine/extension-names.ts +10 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +3 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/engine/system-user.ts +3 -5
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
- package/src/files/provider-resolver.ts +9 -2
- package/src/i18n/required-surface-keys.ts +1 -0
- package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
- package/src/jobs/index.ts +7 -1
- package/src/jobs/job-runner.ts +94 -4
- package/src/logging/utils.ts +14 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +20 -0
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
- package/src/ui-types/list-row-meta.ts +4 -1
|
@@ -105,13 +105,63 @@ function validateProjectionListFilterSchemaAcceptance(
|
|
|
105
105
|
// (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
|
|
106
106
|
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
107
107
|
// unless the author added `filters` themselves.
|
|
108
|
+
// A dateRange facet (fw#3104) sends its two bounds as the top-level payload
|
|
109
|
+
// keys it names in `params`, not as a `filters` entry — so the query has to
|
|
110
|
+
// accept exactly those keys. Catches the facet pointed at a field the query
|
|
111
|
+
// can't narrow by, which would otherwise 422 on the first date the user picks.
|
|
112
|
+
// Keys buildListQueryPayload owns — a facet param naming one of them would
|
|
113
|
+
// silently replace the list's own paging/sorting on every pick.
|
|
114
|
+
const RESERVED_LIST_PAYLOAD_KEYS: ReadonlySet<string> = new Set([
|
|
115
|
+
"limit",
|
|
116
|
+
"search",
|
|
117
|
+
"sort",
|
|
118
|
+
"sortDirection",
|
|
119
|
+
"offset",
|
|
120
|
+
"totalCount",
|
|
121
|
+
"cursor",
|
|
122
|
+
"filter",
|
|
123
|
+
"filters",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
function validateProjectionListDateRangeFacets(
|
|
127
|
+
prefix: string,
|
|
128
|
+
screen: ProjectionListScreenDefinition,
|
|
129
|
+
schema: QueryHandlerDef["schema"] | undefined,
|
|
130
|
+
): void {
|
|
131
|
+
for (const facet of screen.facets ?? []) {
|
|
132
|
+
if (facet.type !== "dateRange") continue;
|
|
133
|
+
if (facet.params.from === facet.params.to) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${prefix}: dateRange facet on "${facet.field}" names the same param ` +
|
|
136
|
+
`"${facet.params.from}" for both bounds.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
for (const param of [facet.params.from, facet.params.to]) {
|
|
140
|
+
if (RESERVED_LIST_PAYLOAD_KEYS.has(param)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${prefix}: dateRange facet on "${facet.field}" names "${param}" as a bound, ` +
|
|
143
|
+
`which is a reserved list-payload key — pick the query's own time-bound param names.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (schemaAccepts(schema, param)) continue;
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${prefix}: dateRange facet on "${facet.field}" sends "${param}" but query ` +
|
|
149
|
+
`"${screen.query}" has no "${param}" parameter in its Zod schema — add ` +
|
|
150
|
+
`${param}: z.iso.datetime().optional() to the handler's schema, or point ` +
|
|
151
|
+
`params at the keys it already accepts.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
108
157
|
function validateProjectionListFacetsSchemaAcceptance(
|
|
109
158
|
prefix: string,
|
|
110
159
|
screen: ProjectionListScreenDefinition,
|
|
111
160
|
schema: QueryHandlerDef["schema"] | undefined,
|
|
112
161
|
): void {
|
|
113
|
-
|
|
114
|
-
|
|
162
|
+
validateProjectionListDateRangeFacets(prefix, screen, schema);
|
|
163
|
+
// skip: no facets that travel via `filters` — nothing to reject.
|
|
164
|
+
if (screen.facets === undefined || !screen.facets.some((f) => f.type !== "dateRange")) return;
|
|
115
165
|
// skip: the schema already accepts filters — nothing to reject.
|
|
116
166
|
if (schemaAccepts(schema, "filters")) return;
|
|
117
167
|
throw new Error(
|
|
@@ -578,10 +578,59 @@ function validateFormFieldsMap(
|
|
|
578
578
|
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
|
|
579
579
|
);
|
|
580
580
|
}
|
|
581
|
+
if (ftype === "money") {
|
|
582
|
+
validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
|
|
583
|
+
}
|
|
581
584
|
}
|
|
582
585
|
return fieldNames;
|
|
583
586
|
}
|
|
584
587
|
|
|
588
|
+
// Fail-closed currency-source gate (fw#2839), the runtime half of the
|
|
589
|
+
// narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
|
|
590
|
+
// stop it. An inline form screen has no entity, so a money field there can't
|
|
591
|
+
// borrow `entity.defaultCurrency`: without a declared source the renderer
|
|
592
|
+
// seeds a bare `0` and the handler's zod schema rejects the submit. Entity
|
|
593
|
+
// money fields are exempt — create-app already refuses an entity that holds
|
|
594
|
+
// money without a `defaultCurrency`.
|
|
595
|
+
function validateFormMoneyCurrency(
|
|
596
|
+
featureName: string,
|
|
597
|
+
screenId: string,
|
|
598
|
+
context: string,
|
|
599
|
+
fieldName: string,
|
|
600
|
+
fdef: unknown,
|
|
601
|
+
): void {
|
|
602
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
|
|
603
|
+
const validForms =
|
|
604
|
+
`Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
|
|
605
|
+
`\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
|
|
606
|
+
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
607
|
+
const currency = (fdef as { currency?: { kind?: unknown; code?: unknown } | null }).currency;
|
|
608
|
+
if (currency === undefined) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`${where} must declare where its currency comes from — this screen has no entity whose ` +
|
|
611
|
+
`\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
|
|
612
|
+
`handler's schema rejects. ${validForms}`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (typeof currency !== "object" || currency === null) {
|
|
616
|
+
throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
|
|
617
|
+
}
|
|
618
|
+
const kind = currency.kind;
|
|
619
|
+
if (kind === "literal") {
|
|
620
|
+
const code = currency.code;
|
|
621
|
+
if (typeof code !== "string" || code.trim() === "") {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
|
|
624
|
+
`Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
} else if (kind !== "tenant") {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
585
634
|
// `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
|
|
586
635
|
// caller only ever passes true together with an actually-empty fields map
|
|
587
636
|
// (an input-less secretMint declares BOTH `fields: {}` and
|
|
@@ -1265,6 +1314,7 @@ export function validateScreens(
|
|
|
1265
1314
|
`(writeForm) has zero fields — drop the section or add fields to it.`,
|
|
1266
1315
|
);
|
|
1267
1316
|
}
|
|
1317
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1268
1318
|
for (const f of section.fields) {
|
|
1269
1319
|
const fieldName = normalizeEditField(f).field;
|
|
1270
1320
|
if (section.fieldDefs[fieldName] === undefined) {
|
package/src/engine/create-app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ScreenDefinition } from "@cosmicdrift/kumiko-types/screen";
|
|
1
2
|
import { type ValidateBootOptions, validateBoot } from "./boot-validator";
|
|
2
3
|
import { dedupeFeatures } from "./dedupe-features";
|
|
3
4
|
import { createRegistry } from "./registry";
|
|
@@ -20,6 +21,37 @@ export type App = {
|
|
|
20
21
|
currencies: readonly string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
// Every field map an entity-less inline form screen renders: actionForm's and
|
|
25
|
+
// secretMint's own fields, plus a secretMint's separate `confirm` step.
|
|
26
|
+
function inlineFormFieldMaps(
|
|
27
|
+
screen: ScreenDefinition,
|
|
28
|
+
): readonly Readonly<Record<string, unknown>>[] {
|
|
29
|
+
if (screen.type === "actionForm") return [screen.fields];
|
|
30
|
+
if (screen.type !== "secretMint") return [];
|
|
31
|
+
return screen.confirm !== undefined ? [screen.fields, screen.confirm.fields] : [screen.fields];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `currency: { kind: "literal", code }` (fw#2839) is only meaningful for a
|
|
35
|
+
// code the app knows — a typo would otherwise render and submit amounts in a
|
|
36
|
+
// currency no formatter or rate table covers.
|
|
37
|
+
function validateLiteralCurrencyCode(
|
|
38
|
+
where: string,
|
|
39
|
+
field: unknown,
|
|
40
|
+
currencies: readonly string[],
|
|
41
|
+
): void {
|
|
42
|
+
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
43
|
+
const shape = field as { type?: unknown; currency?: { kind?: unknown; code?: unknown } };
|
|
44
|
+
if (shape.type === "money" && shape.currency?.kind === "literal") {
|
|
45
|
+
const code = shape.currency.code;
|
|
46
|
+
if (typeof code !== "string" || !currencies.includes(code)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${where} declares currency: { kind: "literal", code: ${JSON.stringify(code)} } which is ` +
|
|
49
|
+
`not in the currencies list. Available: ${currencies.join(", ")}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
export function createApp(config: AppConfig): App {
|
|
24
56
|
const features = dedupeFeatures(config.features);
|
|
25
57
|
const validRoles = new Set(config.roles);
|
|
@@ -108,6 +140,28 @@ export function createApp(config: AppConfig): App {
|
|
|
108
140
|
`Entity "${entityName}" in feature "${feature.name}" has money fields but no defaultCurrency. Set defaultCurrency on the entity definition.`,
|
|
109
141
|
);
|
|
110
142
|
}
|
|
143
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
144
|
+
validateLiteralCurrencyCode(
|
|
145
|
+
`Entity "${entityName}" in feature "${feature.name}", money field "${fieldName}"`,
|
|
146
|
+
field,
|
|
147
|
+
currencies,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A money field on an entity-less form screen names its own currency
|
|
152
|
+
// source (fw#2839) — a literal code has to be one the app actually knows,
|
|
153
|
+
// same rule the entity `defaultCurrency` check above applies.
|
|
154
|
+
for (const [screenId, screen] of Object.entries(feature.screens ?? {})) {
|
|
155
|
+
const inlineFields = inlineFormFieldMaps(screen);
|
|
156
|
+
for (const fields of inlineFields) {
|
|
157
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
158
|
+
validateLiteralCurrencyCode(
|
|
159
|
+
`Screen "${screenId}" in feature "${feature.name}", money field "${fieldName}"`,
|
|
160
|
+
field,
|
|
161
|
+
currencies,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
111
165
|
}
|
|
112
166
|
}
|
|
113
167
|
|
|
@@ -87,6 +87,16 @@ export const EXT_FILE_PROVIDER = "fileProvider" as const;
|
|
|
87
87
|
// des file-foundation-Features MUSS diese Konstante mitziehen.
|
|
88
88
|
export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
|
|
89
89
|
|
|
90
|
+
// Two roles: boot gate (validateBoot requires its presence once file/image
|
|
91
|
+
// fields are in use) AND the ENV source of the config key above, bridged via
|
|
92
|
+
// keyDef.env. Tenant rows keep overriding the bridged value.
|
|
93
|
+
export const FILE_STORAGE_PROVIDER_ENV = "FILE_STORAGE_PROVIDER" as const;
|
|
94
|
+
|
|
95
|
+
// Presence placeholder runDevApp writes when an explicitly wired provider
|
|
96
|
+
// (options.files) already satisfies the boot gate. It names no plugin, so
|
|
97
|
+
// provider resolution treats it like an unset key.
|
|
98
|
+
export const FILE_STORAGE_PROVIDER_BOOT_SENTINEL = "configured" as const;
|
|
99
|
+
|
|
90
100
|
/**
|
|
91
101
|
* `derivativeRenderer` — File-Derivative-Renderer-Plugin-Selection
|
|
92
102
|
* (file-derivatives).
|
|
@@ -30,6 +30,17 @@ import type {
|
|
|
30
30
|
TranslationsDef,
|
|
31
31
|
} from "./types";
|
|
32
32
|
|
|
33
|
+
// A payload field that still holds null/undefined after parsing — the case
|
|
34
|
+
// where an event-PII owner field yields no subject at append time (fw#2776).
|
|
35
|
+
// A `.default(...)` accepts undefined but parses to a value, so it is not
|
|
36
|
+
// absentable.
|
|
37
|
+
function isAbsentable(field: ZodType): boolean {
|
|
38
|
+
return [null, undefined].some((candidate) => {
|
|
39
|
+
const parsed = field.safeParse(candidate);
|
|
40
|
+
return parsed.success && (parsed.data === null || parsed.data === undefined);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
34
45
|
export function buildConfigEventsJobsMethods<TName extends string>(
|
|
35
46
|
state: FeatureBuilderState,
|
|
@@ -130,6 +141,14 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
130
141
|
);
|
|
131
142
|
}
|
|
132
143
|
}
|
|
144
|
+
const owner = shape?.[normalized.ownerField];
|
|
145
|
+
if (normalized.whenAbsent === undefined && owner !== undefined && isAbsentable(owner)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${normalized.ownerField}", which the payload schema allows to be null/undefined. ` +
|
|
148
|
+
`Declare what happens then: { personal: { of: "${normalized.ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
|
|
149
|
+
`whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
|
package/src/engine/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export {
|
|
|
13
13
|
export {
|
|
14
14
|
collectWriteHandlerQns,
|
|
15
15
|
SECURITY_BASELINE_FEATURE_NAMES,
|
|
16
|
+
type ValidateBootOptions,
|
|
16
17
|
validateAppCustomScreenWriteQns,
|
|
17
18
|
validateBoot,
|
|
18
19
|
} from "./boot-validator";
|
|
@@ -112,6 +113,8 @@ export {
|
|
|
112
113
|
EXT_USER_DATA,
|
|
113
114
|
EXT_USER_DATA_ORDER,
|
|
114
115
|
FILE_PROVIDER_CONFIG_KEY,
|
|
116
|
+
FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
117
|
+
FILE_STORAGE_PROVIDER_ENV,
|
|
115
118
|
TENANT_MEMBERSHIPS_QUERY,
|
|
116
119
|
} from "./extension-names";
|
|
117
120
|
export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage";
|
|
@@ -112,6 +112,7 @@ export type FieldsOrGroupsSection = {
|
|
|
112
112
|
// one runs after the fields-XOR-groups check, collectors run without it and must
|
|
113
113
|
// not drop a source (fw#2986).
|
|
114
114
|
export function sectionFieldSpecs(section: FieldsOrGroupsSection): readonly EditFieldSpec[] {
|
|
115
|
+
// kumiko-lint-ignore section-fields-raw this IS the union reader — the groups source is added on the same line
|
|
115
116
|
return [...section.fields, ...(section.groups?.flatMap((group) => group.fields) ?? [])];
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { SessionUser } from "./types";
|
|
2
|
-
import type
|
|
2
|
+
import { SYSTEM_USER_ID, type TenantId } from "./types/identifiers";
|
|
3
|
+
|
|
4
|
+
export { SYSTEM_USER_ID };
|
|
3
5
|
|
|
4
|
-
// Stringified so it round-trips through SessionUser.id (string UUID-shape).
|
|
5
|
-
// Not a real UUID — SYSTEM acts as an alias for "no human caller" and event-
|
|
6
|
-
// store createdBy is text, so the literal suffices.
|
|
7
|
-
export const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
8
6
|
export const SYSTEM_ROLE = "system" as const;
|
|
9
7
|
|
|
10
8
|
// extraRoles: hasAccess kennt keinen System-Bypass — Handler gaten auf
|
|
@@ -56,12 +56,20 @@ const crmFeature = defineFeature("crm", (r) => {
|
|
|
56
56
|
// hand-written, so a camelCase event name (tenantNote → tenant-note) can
|
|
57
57
|
// never drift from what the catalog is actually keyed by (fw#2801).
|
|
58
58
|
let PING_EVENT_TYPE: string;
|
|
59
|
+
let PING_TENANT_EVENT_TYPE: string;
|
|
60
|
+
const pingSchema = z.object({
|
|
61
|
+
targetId: z.string().nullable(),
|
|
62
|
+
address: z.string().nullable(),
|
|
63
|
+
});
|
|
59
64
|
const mailerFeature = defineFeature("mailer", (r) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
).name;
|
|
65
|
+
// targetId is nullable, so the stance has to say what happens without an
|
|
66
|
+
// owner — the two events below cover both declarations (fw#2776).
|
|
67
|
+
PING_EVENT_TYPE = r.defineEvent("ping", pingSchema, {
|
|
68
|
+
piiFields: { address: { personal: { of: "targetId", whenAbsent: "plaintext" } } },
|
|
69
|
+
}).name;
|
|
70
|
+
PING_TENANT_EVENT_TYPE = r.defineEvent("pingTenant", pingSchema, {
|
|
71
|
+
piiFields: { address: { personal: { of: "targetId", whenAbsent: "tenant" } } },
|
|
72
|
+
}).name;
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
// fw#2801: custom events declaring a tenant/self subject — no entity,
|
|
@@ -272,7 +280,7 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
272
280
|
>;
|
|
273
281
|
expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
|
|
274
282
|
|
|
275
|
-
// No subject
|
|
283
|
+
// No subject and whenAbsent: "plaintext" → declared, stays plaintext.
|
|
276
284
|
const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
|
|
277
285
|
string,
|
|
278
286
|
unknown
|
|
@@ -280,6 +288,22 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
280
288
|
expect(system["address"]).toBe("ops@x.com");
|
|
281
289
|
});
|
|
282
290
|
|
|
291
|
+
test('an absent owner with whenAbsent: "tenant" backfills under the tenant key (fw#2776)', async () => {
|
|
292
|
+
const p4 = generateId();
|
|
293
|
+
await appendPlain(p4, "ping", PING_TENANT_EVENT_TYPE, {
|
|
294
|
+
targetId: null,
|
|
295
|
+
address: "ops@x.com",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
armKms();
|
|
299
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
300
|
+
expect(result.failures).toEqual([]);
|
|
301
|
+
|
|
302
|
+
const row = (await loadAggregate(testDb.db, p4, TENANT))[0]?.payload as Record<string, unknown>;
|
|
303
|
+
expect(isPiiCiphertext(row["address"])).toBe(true);
|
|
304
|
+
expect(String(row["address"])).toContain(`tenant:${TENANT}`);
|
|
305
|
+
});
|
|
306
|
+
|
|
283
307
|
test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
|
|
284
308
|
const c1 = generateId();
|
|
285
309
|
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
16
16
|
import type { DbConnection } from "../db/connection";
|
|
17
17
|
import type { TenantDb } from "../db/tenant-db";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
EXT_FILE_PROVIDER,
|
|
20
|
+
FILE_PROVIDER_CONFIG_KEY,
|
|
21
|
+
FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
22
|
+
} from "../engine/extension-names";
|
|
19
23
|
import { SYSTEM_USER_ID } from "../engine/system-user";
|
|
20
24
|
import type { ConfigAccessor, ConfigAccessorFactory, Registry } from "../engine/types";
|
|
21
25
|
import type { SecretsContext } from "../secrets";
|
|
@@ -93,7 +97,10 @@ export async function createFileProviderForTenant(
|
|
|
93
97
|
|
|
94
98
|
const raw = await ctxConfig(FILE_PROVIDER_CONFIG_KEY);
|
|
95
99
|
const provider = typeof raw === "string" ? raw : raw == null ? "" : String(raw);
|
|
96
|
-
|
|
100
|
+
// The boot-gate placeholder names no plugin: treated as a provider name it
|
|
101
|
+
// would let FILE_STORAGE_PROVIDER=configured fake a selection through the
|
|
102
|
+
// ENV bridge.
|
|
103
|
+
if (provider.length === 0 || provider === FILE_STORAGE_PROVIDER_BOOT_SENTINEL) {
|
|
97
104
|
const usages = ctx.registry.getExtensionUsages(EXT_FILE_PROVIDER);
|
|
98
105
|
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
99
106
|
throw new Error(
|
|
@@ -328,6 +328,7 @@ export function requiredKeysFromScreen(
|
|
|
328
328
|
if (isWriteFormEditSection(section)) {
|
|
329
329
|
pushKey(out, section.title);
|
|
330
330
|
pushKey(out, section.submitLabel);
|
|
331
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
331
332
|
for (const f of section.fields) {
|
|
332
333
|
const fieldName = editFieldName(f);
|
|
333
334
|
out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildServer } from "../../api/server";
|
|
3
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
4
|
+
import type { AppContext, Registry } from "../../engine/types";
|
|
5
|
+
import {
|
|
6
|
+
createNoopProvider,
|
|
7
|
+
createPrometheusMeter,
|
|
8
|
+
registerStandardMetrics,
|
|
9
|
+
} from "../../observability";
|
|
10
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
11
|
+
import { waitFor } from "../../testing";
|
|
12
|
+
import { createJobRunner } from "../job-runner";
|
|
13
|
+
|
|
14
|
+
const JWT = "job-last-success-test-secret-minimum-32-chars!!";
|
|
15
|
+
const SUCCEEDS = "liveness:job:succeeds";
|
|
16
|
+
const FAILS = "liveness:job:fails-always";
|
|
17
|
+
|
|
18
|
+
let testRedis: TestRedis;
|
|
19
|
+
let redisUrl: string;
|
|
20
|
+
|
|
21
|
+
const livenessFeature = defineFeature("liveness", (r) => {
|
|
22
|
+
r.job("succeeds", { trigger: { manual: true } }, async () => {});
|
|
23
|
+
r.job("failsAlways", { trigger: { manual: true } }, async () => {
|
|
24
|
+
throw new Error("intentional failure");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
testRedis = await createTestRedis();
|
|
30
|
+
redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await testRedis.cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function slotFor(meter: ReturnType<typeof createPrometheusMeter>, job: string) {
|
|
38
|
+
return meter
|
|
39
|
+
.snapshot()
|
|
40
|
+
.get("kumiko_job_last_success_timestamp_seconds")
|
|
41
|
+
?.slots.find((s) => s.labels?.["job"] === job);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function withRunner(
|
|
45
|
+
meter: ReturnType<typeof createPrometheusMeter>,
|
|
46
|
+
fn: (runner: ReturnType<typeof createJobRunner>, failures: string[]) => Promise<void>,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const registry: Registry = createRegistry([livenessFeature]);
|
|
49
|
+
const context: AppContext = { meter };
|
|
50
|
+
const failures: string[] = [];
|
|
51
|
+
const queueNamePrefix = `kumiko-test-ls-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
52
|
+
const runner = createJobRunner({
|
|
53
|
+
registry,
|
|
54
|
+
context,
|
|
55
|
+
redisUrl,
|
|
56
|
+
consumerLane: "worker",
|
|
57
|
+
queueNamePrefix,
|
|
58
|
+
onJobFailed: (jobName) => {
|
|
59
|
+
failures.push(jobName);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
await runner.start();
|
|
63
|
+
try {
|
|
64
|
+
await fn(runner, failures);
|
|
65
|
+
} finally {
|
|
66
|
+
await runner.stop();
|
|
67
|
+
const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
|
|
68
|
+
if (keys.length > 0) await testRedis.redis.del(...keys);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("job-runner — kumiko_job_last_success_timestamp_seconds", () => {
|
|
73
|
+
test("a successful run stamps the gauge, a failing run leaves no series", async () => {
|
|
74
|
+
const meter = createPrometheusMeter();
|
|
75
|
+
registerStandardMetrics(meter);
|
|
76
|
+
|
|
77
|
+
await withRunner(meter, async (runner, failures) => {
|
|
78
|
+
const before = Date.now() / 1000;
|
|
79
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
80
|
+
await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
|
|
81
|
+
|
|
82
|
+
const stamped = slotFor(meter, SUCCEEDS) as { value: number };
|
|
83
|
+
expect(stamped.value).toBeGreaterThanOrEqual(before);
|
|
84
|
+
expect(stamped.value).toBeLessThanOrEqual(Date.now() / 1000 + 1);
|
|
85
|
+
|
|
86
|
+
// Separate job name, so the "failure must not stamp" assertion cannot
|
|
87
|
+
// pass just because a prior success left a value inside the same second.
|
|
88
|
+
await runner.dispatch(FAILS, {});
|
|
89
|
+
await waitFor(() => failures.includes(FAILS));
|
|
90
|
+
expect(slotFor(meter, FAILS)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a later success advances the stamp", async () => {
|
|
95
|
+
const meter = createPrometheusMeter();
|
|
96
|
+
registerStandardMetrics(meter);
|
|
97
|
+
|
|
98
|
+
await withRunner(meter, async (runner) => {
|
|
99
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
100
|
+
await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
|
|
101
|
+
const first = (slotFor(meter, SUCCEEDS) as { value: number }).value;
|
|
102
|
+
|
|
103
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
104
|
+
await waitFor(() => (slotFor(meter, SUCCEEDS) as { value: number }).value > first, {
|
|
105
|
+
delays: [250, 1000, 3000],
|
|
106
|
+
});
|
|
107
|
+
expect((slotFor(meter, SUCCEEDS) as { value: number }).value).toBeGreaterThan(first);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the stamp reaches the real /metrics scrape output", async () => {
|
|
112
|
+
const meter = createPrometheusMeter();
|
|
113
|
+
registerStandardMetrics(meter);
|
|
114
|
+
|
|
115
|
+
await withRunner(meter, async (runner) => {
|
|
116
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
117
|
+
await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
|
|
118
|
+
|
|
119
|
+
// Same meter instance the runner wrote into — that sharing is what
|
|
120
|
+
// buildServer + job-runner do in a real process (fw#1046).
|
|
121
|
+
const { app } = buildServer({
|
|
122
|
+
registry: createRegistry([livenessFeature]),
|
|
123
|
+
context: {},
|
|
124
|
+
jwtSecret: JWT,
|
|
125
|
+
observability: { ...createNoopProvider(), meter },
|
|
126
|
+
metrics: {},
|
|
127
|
+
});
|
|
128
|
+
const res = await app.request("/metrics");
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
const body = await res.text();
|
|
131
|
+
expect(body).toContain("# TYPE kumiko_job_last_success_timestamp_seconds gauge");
|
|
132
|
+
expect(body).toContain(`kumiko_job_last_success_timestamp_seconds{job="${SUCCEEDS}"} `);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/jobs/index.ts
CHANGED