@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +32 -11
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +45 -38
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +10 -2
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +172 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +264 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
package/src/changes.json
CHANGED
|
@@ -1,4 +1,268 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.336.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Boot gates with an explicit timeout reject start() naming the gate; gates without one warn after 60s",
|
|
6
|
+
"detail": "A `bootGate` job with an explicit `timeout` now rejects `start()` with a message naming the gate once that timeout elapses. Without `timeout` the gate keeps waiting and logs a warning naming it after 60s instead of blocking boot silently."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.336.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Boot rejects unknown visibleWhen fields, bootGate with runOnBoot, and unknown money currency kinds",
|
|
12
|
+
"detail": "Boot now rejects three misconfigurations that previously failed silently: a dashboard screen-panel `visibleWhen.field` that is missing from the query's declared `outputSchema`, a job that combines `bootGate` with `runOnBoot` (it ran twice under one job id), and a money field whose `currency` source has an unknown `kind`. `auth-mfa:query:user-mfa:status` now declares its `outputSchema`."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.336.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Dry-run env output keeps multi-line field descriptions inside the comment",
|
|
18
|
+
"detail": "`KUMIKO_DRY_RUN_ENV` pulumi and k8s output now folds a multi-line `.describe()` text onto one comment line, so the continuation can no longer escape the `#` and run as a shell command or break the YAML stub. The `unprocessable-error-details-reason` codemod now skips a shorthand `reason` whose variable is only used in `details`."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.336.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "waitForEvent ne-match ignores payloads without the referenced path",
|
|
24
|
+
"detail": "`waitForEvent` match atoms no longer match events that lack the referenced payload path, so `ne` no longer wakes a workflow on an unrelated event shape. Explicit `undefined` optionals on `r.nav()` entries are dropped from the client schema."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.336.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "r.nav() explicit undefined optionals no longer reach the client schema"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"version": "0.336.0",
|
|
33
|
+
"type": "fix",
|
|
34
|
+
"title": "public_intake_required 403 no longer discloses table and column names to anonymous callers",
|
|
35
|
+
"detail": "The `public_intake_required` 403 no longer puts the physical table name and personal-data column names into `message` or `details` (it keeps `reason`, `rootHandler` and `job`); they travel in the error `cause` for the server log. The event dispatcher counts idle-gated consumer turns in the new `kumiko_event_consumer_pass_skipped_total{reason=\"idle\"}` counter, since those turns open no `events.consumer.pass` span. Personal-data column sets for the anonymous write gate are cached per entity."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"version": "0.336.0",
|
|
39
|
+
"type": "improvement",
|
|
40
|
+
"title": "Idle-gated event consumer turns are counted in kumiko_event_consumer_pass_skipped_total"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"version": "0.336.0",
|
|
44
|
+
"type": "breaking",
|
|
45
|
+
"title": "/api/sse sends change signals without field values; anonymous connections only for declared entities",
|
|
46
|
+
"detail": "The SSE broadcast consumer no longer puts the event payload (changes, previous) on the tenant channel, because that channel fans out to every tenant member and to anonymous connections. Entity frames carry `{ id, aggregateType, eventType, version, createdAt }`. Anonymous connections get entity signals only for entities named by an anonymously callable query via the new `liveEntities` option; the query name alone grants nothing, because a signal carries the id of every row, including rows the query filters out. Frames without an entity are delivered only when `data.userId` matches the connected user. Boot fails when `liveEntities` names an unregistered entity.",
|
|
47
|
+
"migration": "Code that reads `data.payload` from SSE frames must load the data with a query after the signal instead. Public pages that update anonymously add `liveEntities: [\"<entity>\"]` to the anonymous query they refetch (for example a `page:current` query), otherwise the live update stays off for anonymous visitors. `createSseRoute(broker)` now needs a second argument, `{ anonymousLiveEntities: collectAnonymousLiveEntities(registry) }`."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"version": "0.336.0",
|
|
51
|
+
"type": "fix",
|
|
52
|
+
"title": "drainEventConsumers rejects an invalid maxPasses and drainJobs fails a stuck drain with a diagnosis",
|
|
53
|
+
"detail": "`drainEventConsumers` rejects a non-positive or non-integer `maxPasses` instead of returning without draining, and `drainJobs` accepts `timeoutMs` (default 10s) and fails a stuck drain with the pending job count and the consumers still behind. The stance report names which hint category (direct, user-owned, user-reference) a near-miss field resembles, and the `UnprocessableError` reason codemod no longer reports spread literals without a `reason` key and lists non-analyzable `details` arguments as unverified."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"version": "0.336.0",
|
|
57
|
+
"type": "improvement",
|
|
58
|
+
"title": "Codemod reports name the near-miss hint category and separate unverified sites from manual skips"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"version": "0.335.0",
|
|
62
|
+
"type": "fix",
|
|
63
|
+
"title": "Agent tool manifests no longer offer the caller-chosen id on create tools",
|
|
64
|
+
"detail": "Agent tool manifests no longer offer the caller-chosen `id` on create tools, since agent dispatch never runs as a system identity and the value was silently dropped. Combobox options now carry a value-based `data-testid`."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"version": "0.335.0",
|
|
68
|
+
"type": "improvement",
|
|
69
|
+
"title": "Stricter boot checks for list outputSchema envelopes, detail section fields, reference facet list handlers, metric navigate, dashboard screen panels, refEntity columns and checkbox columns",
|
|
70
|
+
"detail": "Stricter boot checks for screens and handlers; apps that carried a latent typo or misconfiguration now fail at boot instead of rendering an empty cell or doing nothing at runtime.\n- A plain (non-paged) query handler whose `outputSchema` declares the row shape instead of `{ rows, nextCursor }` now fails when a projectionList, relatedList, expandableRow or dashboard list panel uses it.\n- `projectionDetail` layout section `fields` are checked against the query's `outputSchema`.\n- A `reference` list facet requires the target's list query handler (`<feature>:query:<entity>:list`, or its central lookup override) to be registered.\n- `MetricSpec.navigate.screen`/`entity` are validated even without `tab`; `navigate.entity` needs `entityId`.\n- A dashboard `screen` panel must not embed an `actionForm`/`secretMint` with `redirect` or `cancelTarget`.\n- `refEntity` on entityList columns and dashboard list-panel columns must resolve to a registered entity.\n- `multiSelect` `display: \"checkboxes\"` rejects `columns` outside 1-4.\n- Personal-data detection for `openToAll` write handlers now also looks inside `z.map`, `z.set`, `z.promise` and `.catchall()` schemas.\n- Fixes false boot failures for cross-feature rowActions / `listScreenId` written as a fully-qualified screen QN, and for where-rules that use a bare outer column next to a qualified subquery.",
|
|
71
|
+
"migration": "Boot now fails for configurations that previously rendered empty or did nothing. Fix the named screen or field: wrap a list handler's outputSchema in { rows, nextCursor }, correct detail section field names, register the entity list handler (defineEntityListHandler) behind a reference facet, add entityId to a metric navigate.entity, drop redirect/cancelTarget on forms embedded in a dashboard, and use a registered entity for refEntity."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"version": "0.335.0",
|
|
75
|
+
"type": "fix",
|
|
76
|
+
"title": "Encrypted-at-rest text config keys derive writeOnly settings fields; boot validator allows writeOnly on configEdit only for those keys"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"version": "0.335.0",
|
|
80
|
+
"type": "fix",
|
|
81
|
+
"title": "migrate-db-raw codemod lists own-tenant calls with a spread after tenantId for manual review instead of rewriting them",
|
|
82
|
+
"detail": "The `migrate-db-raw` codemod no longer auto-rewrites own-tenant calls whose filter object has a spread after `tenantId`, since the spread can override it at runtime; such sites are listed for manual review."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"version": "0.335.0",
|
|
86
|
+
"type": "fix",
|
|
87
|
+
"title": "switch-tenant validates tenantId, fault logger tolerates non-string type, PII guard matches full ciphertext shape",
|
|
88
|
+
"detail": "`POST /api/auth/switch-tenant` answers 400 `invalid_tenant` for a missing or non-string `tenantId` instead of feeding it into the membership lookup. A non-string `type` in a request body no longer turns a 4xx into a 500 inside the fault logger. The PII ciphertext response guard now matches the full ciphertext shape, so user text containing the bare `kumiko-pii:v` marker no longer 500s reads. `kumiko upgrade --apply` records `pendingManual` in the marker when no codemod runs."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"version": "0.335.0",
|
|
92
|
+
"type": "fix",
|
|
93
|
+
"title": "File-storage boot gate reads the injected env, derivative erasure matches lowercase hashes only",
|
|
94
|
+
"detail": "The `FILE_STORAGE_PROVIDER` boot gate now reads the `env` passed to `validateBoot` (falling back to `process.env`), so an `envSource` handed to `runProdApp` with an empty `process.env` no longer fails the gate. `runDevApp` stops mutating `process.env` for an explicitly wired file provider. The derivative-key recognizer used by forget and tenant-destroy now matches the hash segment case-sensitively."
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"version": "0.335.0",
|
|
98
|
+
"type": "breaking",
|
|
99
|
+
"title": "Boot rejects multiple principalStatus providers, updateMany rejects unknown columns again",
|
|
100
|
+
"detail": "Several fail-loud fixes. `buildServer` now also rejects more than one `principalStatus` provider when `auth.membershipQuery` is registered (previously every login 500ed at runtime). `updateMany` throws again on a key that is not a column, and the replay of historical update events skips fields removed from the entity instead. `lt`/`gt`/`lte`/`gte`/`like` on a jsonb column match nothing in the where builder, like the event-store read path.",
|
|
101
|
+
"migration": "`updateMany` now throws when `set` or `where` contains a key that is not a column of the table; remove such keys from your calls. Replay of historical update events still skips removed fields."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"version": "0.335.0",
|
|
105
|
+
"type": "improvement",
|
|
106
|
+
"title": "Boot validator rejects overlapping dateRange facet params, unknown subtitleHref/countField fields and whitespace in redirect.idFrom; clearer refEntity, nav and where-probe diagnostics",
|
|
107
|
+
"detail": "Boot validator follow-ups.\n- A `dateRange` facet param name used by more than one dateRange facet on a projectionList screen now fails boot (the facets overwrote each other's bound).\n- `header.subtitleHref` and a section's `countField` on a projectionDetail screen are checked against the query's `outputSchema`.\n- `redirect.idFrom` with leading or trailing whitespace now fails boot instead of silently navigating without an id.\n- A `refEntity` pointing at a feature that is not mounted now says so and names `r.requires(...)` instead of listing \"(none)\" entities.\n- The nav inversion warning no longer prints `requires roles []` for a leaf with an invalid `openToAll`, and the unreachable-screen warning points workspace apps at `r.workspace({ nav })` instead of a non-existent allowlist.\n- The where-rule boot probe renders `ctx.tableName` with the real table name, matching runtime.",
|
|
108
|
+
"migration": "Boot now fails for configurations that silently misbehaved. Give each dateRange facet on a screen its own from/to params, point header.subtitleHref and section countField at fields of the query's outputSchema, and remove stray whitespace from redirect.idFrom."
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"version": "0.335.0",
|
|
112
|
+
"type": "fix",
|
|
113
|
+
"title": "Malformed waitForEvent matchExpr rows are skipped instead of blocking the wakeup projection",
|
|
114
|
+
"detail": "Review round 2 fixes for waitForEvent matching, read steps, env parsing and agent reasons.\n- A pending workflow row with a malformed or unsupported `matchExpr` is now skipped and logged instead of aborting the wakeup projection for every other run waiting on the same event. `evaluateEventMatch` reports a structurally malformed AST as a readable `Malformed EventMatch` error.\n- `read.findOne` and `read.findMany` reject a non-object `unsafeAllTenants` (for example `true`) with an `InternalError` instead of a raw `TypeError`.\n- `parseEnv` reports a refinement that throws as a `KumikoBootError`, and the ciphertext-only slot caveat for refinements is documented.\n- Removed the unused `AgentReasons.permissionDenied` (`agent.permission_denied`) reason and its error-docs pages; nothing emitted it."
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"version": "0.335.0",
|
|
118
|
+
"type": "breaking",
|
|
119
|
+
"title": "AgentReasons.permissionDenied removed",
|
|
120
|
+
"migration": "Drop references to `AgentReasons.permissionDenied` / `\"agent.permission_denied\"`. The framework never emitted this reason."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"version": "0.335.0",
|
|
124
|
+
"type": "fix",
|
|
125
|
+
"title": "ExtraRouteRejection retryAfterSeconds must be a safe integer; token-request routes stop logging rate_limited",
|
|
126
|
+
"detail": "`ExtraRouteRejection` now rejects a `retryAfterSeconds` beyond the safe-integer range, so the `Retry-After` header is always valid delta-seconds. Password-reset style token-request routes no longer log `rate_limited` handler results as errors."
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"version": "0.335.0",
|
|
130
|
+
"type": "fix",
|
|
131
|
+
"title": "Feature-AST patcher handles quoted keys, stream-handler escapeHatch and shorthand piiFields",
|
|
132
|
+
"detail": "The feature-AST patcher now replaces and unsets string-literal keys such as `\"description\": \"old\"` instead of appending a duplicate key or skipping the unset. `addStreamHandler` accepts `escapeHatch`. The defineEvent extractor accepts a shorthand `{ piiFields }` stance and reports a non-literal options argument with its own message."
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"version": "0.335.0",
|
|
136
|
+
"type": "fix",
|
|
137
|
+
"title": "File byte routes survive undecryptable fileName; loadAggregate and appendProvenanceEvent reject empty type parts",
|
|
138
|
+
"detail": "`GET /files/:id` and the download-url route now fall back to the filename \"download\" when the stored fileName cannot be decrypted (malformed ciphertext, erased subject, annotation drift) instead of returning 500 or leaking ciphertext into headers. `loadAggregate` rejects an empty `aggregateType` instead of loading the unfiltered stream, and `appendProvenanceEvent` rejects event types with an empty owner or name."
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"version": "0.335.0",
|
|
142
|
+
"type": "breaking",
|
|
143
|
+
"title": "FileRef type drops the unreachable isDeleted field",
|
|
144
|
+
"detail": "The exported `FileRef` type no longer carries `isDeleted`. Soft-deleted rows were already filtered out before the guard sees a file, so the field was never reachable. Custom file access guards that read `fileRef.isDeleted` must drop that check.",
|
|
145
|
+
"migration": "Remove reads of `fileRef.isDeleted` in custom file access guards; soft-deleted rows never reach the guard."
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"version": "0.335.0",
|
|
149
|
+
"type": "breaking",
|
|
150
|
+
"title": "File content check covers csv alias, local list scoped to prefix, provider contract checked, locale tags canonicalized",
|
|
151
|
+
"detail": "File storage and locale hardening. `validateFileContent` now rejects binary bytes for an extension with a declared-MIME alias (a real `.xls` renamed to `.csv`), so custom upload routes using `validateFile` + `validateFileContent` are covered. `tenantExportPrefix` and `tenantStoragePrefixes` throw for a tenant id equal to the reserved `exports` segment. The local provider's `list()` only walks the prefix directory instead of the whole storage root. A file-provider plugin whose built provider lacks a required method now fails loudly at resolve time. `canonicalizeLocaleTag` normalizes region and script subtags (`de-AT`, `zh-Hant-TW`), so `X-Locale: DE-at` finds a `de-AT` mail registration.",
|
|
152
|
+
"migration": "A file-provider plugin whose built provider lacks a required method now throws at resolve time; make sure custom file providers implement the full provider interface."
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"version": "0.335.0",
|
|
156
|
+
"type": "breaking",
|
|
157
|
+
"title": "Global-table tenant writes reject loudly, restore maps unique violations, managed unique-index changes recreate",
|
|
158
|
+
"detail": "Tenant-mode `updateMany`/`deleteMany` on a `tenancy: \"global\"` table now reject with an `AccessDeniedError` pointing at `db.global(table)` instead of silently matching zero rows. `restore` on a soft-deleted row whose unique value was re-used now returns a 409 `unique_violation` instead of a raw 500. The migration generator now recreates a managed projection when an existing index becomes unique, changes columns or widens its WHERE.",
|
|
159
|
+
"migration": "Tenant-mode `updateMany`/`deleteMany` on a `tenancy: \"global\"` table now throws instead of matching zero rows; switch those calls to `db.global(table)`."
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"version": "0.335.0",
|
|
163
|
+
"type": "fix",
|
|
164
|
+
"title": "Guard edge cases, extension entityName fallback and dispatcher drain waiting for in-flight passes",
|
|
165
|
+
"detail": "Guards: the runtime-isolation guard now follows value re-exports (`export * from`, `export { x } from`) and reads the `kumiko.runtime` marker in the repo-root `package.json`. The upgrade-state guard prefers the repo-local `node_modules/.bin/kumiko-upgrade` over a PATH entry. The error-reasons guard no longer flags prose reasons passed to `withUnsafeRawGrant`. Remaining German guard messages are now English. Blank `entityName` overrides on extension sections fall back to the host entity. `EventDispatcher.drain()` now also waits for a pass still in its idle pre-check."
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"version": "0.335.0",
|
|
169
|
+
"type": "breaking",
|
|
170
|
+
"title": "i18n key guard covers multiSelect option labels and writeForm section descriptions",
|
|
171
|
+
"detail": "The i18n key guard now requires translations for filterable `multiSelect` option labels and for the `description` of a `projectionDetail` writeForm section. Missing translations used to fall back silently; they now fail the guard.",
|
|
172
|
+
"migration": "The i18n required-surface-keys check now requires translations for filterable `multiSelect` option labels and `projectionDetail` writeForm section descriptions; add the missing keys to your locale files."
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"version": "0.335.0",
|
|
176
|
+
"type": "fix",
|
|
177
|
+
"title": "perTenant fan-out without context.db reports the missing db instead of a missing tenant feature",
|
|
178
|
+
"detail": "A perTenant job on a job runner built without `context.db` now fails with a message that names the missing `context.db` when the tenant feature is mounted, instead of telling the operator to mount it. A readiness failure during `stop()` is now logged at debug level."
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"version": "0.335.0",
|
|
182
|
+
"type": "fix",
|
|
183
|
+
"title": "Key Manager boot resolves slots in parallel, validates the previous-KEK version up front and keeps the fetch error as cause",
|
|
184
|
+
"detail": "The Key Manager boot resolution now resolves all KEK slots in parallel, rejects a previous KEK without `PLATFORM_KEK_PREVIOUS_VERSION` before any Key Manager call, keeps the original network error as `cause`, and names the slot in the partial-wiring error. The boot log line names the source actually taken."
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"version": "0.335.0",
|
|
188
|
+
"type": "fix",
|
|
189
|
+
"title": "Boot column check skips loose/catchall output schemas; schema-walk error names the handler",
|
|
190
|
+
"detail": "The query output-schema column check now skips `z.looseObject()` and `.catchall()` row schemas, since they accept keys beyond the declared shape. The schema-size guard for write-handler input schemas now names the feature and handler in its boot error."
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"version": "0.335.0",
|
|
194
|
+
"type": "fix",
|
|
195
|
+
"title": "Multi-stream projection rebuild honors the shutdown AbortSignal",
|
|
196
|
+
"detail": "`rebuildMultiStreamProjection` now accepts an `AbortSignal` and checks it per replayed event. The boot backfill's shutdown signal reaches it, so a SIGTERM during a long multi-stream replay rolls the transaction back and releases the consumer lock instead of hanging until the pod is killed."
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"version": "0.335.0",
|
|
200
|
+
"type": "fix",
|
|
201
|
+
"title": "multiSelect filters handle empty and non-string values and NULL columns; forget sweeps tolerate missing columns and serial ids",
|
|
202
|
+
"detail": "multiSelect list filters no longer return every row for an empty array value, no longer fail with a 500 on boolean or numeric client values, and `ne` now keeps rows whose column is NULL. The forget sweeps (blind-index nulling, search purge) skip lookupable or searchable fields whose columns are missing from a migrated table instead of aborting after the key was erased, and the self-PII tenant probe ignores serial-id entities instead of throwing on a uuid subject id."
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"version": "0.335.0",
|
|
206
|
+
"type": "fix",
|
|
207
|
+
"title": "App-schema JSON-safety rejects NaN/Infinity and cyclic defaults",
|
|
208
|
+
"detail": "The app-schema JSON-safety check now rejects `NaN`/`Infinity` defaults and guards against self-referencing values. The agent doc lint reports exposed handlers whose input schema cannot be converted to JSON Schema (they were silently left out of the agent manifest). Icon-only list and edit action buttons now also set `title`, so sighted mouse users get the hover tooltip."
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"version": "0.335.0",
|
|
212
|
+
"type": "fix",
|
|
213
|
+
"title": "Executor create/restore validate before connection binding; clearer rebuild and backfill errors",
|
|
214
|
+
"detail": "The event-store executor's `create()` and `restore()` now run their validation (global-tenancy guard, `soft_delete_not_enabled`) before resolving the connection, so callers get the proper 4xx error instead of an internal \"no connection bound\" error. List `totalCount` always counts over the full WHERE, and a PII backfill failure for an unresolvable catalog event subject no longer suggests the entity-only retry options. The shadow-swap RLS and blind-index error messages now state the concrete action (full KMS wiring for the blind-index key)."
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"version": "0.335.0",
|
|
218
|
+
"type": "fix",
|
|
219
|
+
"title": "afterCommit hook writes run in their own transaction, plus cache, nested-write, skip-poison and search-consumer fixes",
|
|
220
|
+
"detail": "Writes from an afterCommit hook now run as their own transaction with their own afterCommit flush, so the nested write's hooks fire and a failing projection rolls it back. The tenant timezone cache no longer stores a value read before a concurrent config write. Nested-write ownership checks complete a partial parent row from the stored row. `skipPoisonEvent` locks the consumer row before computing pending gaps. The search consumer fails an event for retry when its context has no database instead of removing the index entry."
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"version": "0.335.0",
|
|
224
|
+
"type": "fix",
|
|
225
|
+
"title": "Qualified redirects resolve in their named feature, update forms skip untouched required fields, secretMint confirm can restart",
|
|
226
|
+
"detail": "A qualified redirect target (`feature:screen:id`) is now resolved inside the named feature, so a same-id screen in the redirecting feature no longer swallows the created record id. An entityEdit update form only presence-checks required fields the user changed, so a legacy row with an empty, newly required field can still be saved. The secretMint confirm step always offers a cancel that restarts at the mint form when no redirect or cancelTarget is set."
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"version": "0.335.0",
|
|
230
|
+
"type": "breaking",
|
|
231
|
+
"title": "totalsMatch rejects a payload that carries only one side of the pair",
|
|
232
|
+
"detail": "A `totalsMatch` pair is now validated as a unit. A payload that carries only the embedded list or only its sibling money field is rejected with a validation error, because update payloads only carry `changes` and the sum could otherwise drift away from the total.",
|
|
233
|
+
"migration": "Send the embedded list and its sibling money field together in every create and update payload. A partial update that changes only `total` or only the list now fails validation."
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"version": "0.335.0",
|
|
237
|
+
"type": "fix",
|
|
238
|
+
"title": "Transfer-graph boot check rejects a multiple reference only when its target is transferable",
|
|
239
|
+
"detail": "The transfer-graph boot check now rejects a `multiple` reference on a transferable entity only when the target entity is itself transferable. A multiple reference to a plain lookup entity boots again."
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"version": "0.335.0",
|
|
243
|
+
"type": "fix",
|
|
244
|
+
"title": "Empty KUMIKO_TRUSTED_PROXY_HOPS is treated as unset, and dist no longer ships compiled codemods",
|
|
245
|
+
"detail": "`parseTrustedProxyHopsEnv` treats an empty `KUMIKO_TRUSTED_PROXY_HOPS` as unset, so a blank `.env` template line no longer crashes the dev boot while prod ignored it. The published package also no longer ships the unusable compiled `dist/scripts/` codemod copies."
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"version": "0.335.0",
|
|
249
|
+
"type": "fix",
|
|
250
|
+
"title": "Upgrade marker needs an installed version, Meilisearch drop keeps sibling prefixes, drainJobs sees pre-run job failures",
|
|
251
|
+
"detail": "`kumiko upgrade --apply` now refuses to write a marker when no installed Kumiko version can be found under the target, instead of recording the `--from` filter value. It also finds `node_modules/@cosmicdrift/*` packages that are workspace symlinks. `MeilisearchAdapter.dropAllIndexes()` only drops tenant indexes of its own prefix and leaves indexes of a longer sibling prefix alone. `drainJobs()` now rejects when a perTenant fan-out or an unknown job fails before the job body runs."
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"version": "0.335.0",
|
|
255
|
+
"type": "fix",
|
|
256
|
+
"title": "SSE stream closes when access is revoked",
|
|
257
|
+
"detail": "The live event stream (GET /sse) now closes as soon as the session behind it is revoked or the user's tenant roles change or the membership is removed. User-addressed frames such as in-app notifications are delivered only to the addressed user instead of the whole tenant."
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"version": "0.335.0",
|
|
261
|
+
"type": "improvement",
|
|
262
|
+
"title": "Boot validator checks redirect targets of writeHandler record actions",
|
|
263
|
+
"detail": "An unknown `redirect` screen on a projectionDetail or entityEdit action or section action fails boot, like entityEdit `redirect`.",
|
|
264
|
+
"migration": "If a writeHandler record action already carries a `redirect` that does not resolve to a registered screen, fix or remove it."
|
|
265
|
+
},
|
|
2
266
|
{
|
|
3
267
|
"version": "0.334.0",
|
|
4
268
|
"type": "improvement",
|
|
@@ -168,8 +168,20 @@ export function migrateCrossTenantSource(
|
|
|
168
168
|
const rewrites: Rewrite[] = [];
|
|
169
169
|
const manual: ManualSite[] = [];
|
|
170
170
|
|
|
171
|
+
// Captured up front: a forgotten node can no longer report its position.
|
|
172
|
+
const lineByAssignment = new Map(targets.map((a) => [a, a.getStartLineNumber()]));
|
|
173
|
+
|
|
171
174
|
for (const assignment of targets) {
|
|
172
|
-
|
|
175
|
+
// A forgotten node has no position or text left; a bare `continue` would
|
|
176
|
+
// drop a security-relevant crossTenant switch from the report entirely.
|
|
177
|
+
if (assignment.wasForgotten()) {
|
|
178
|
+
manual.push({
|
|
179
|
+
file: fileName,
|
|
180
|
+
line: lineByAssignment.get(assignment) ?? 0,
|
|
181
|
+
why: "node was invalidated by an earlier rewrite in this file — run the codemod on this file again",
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
173
185
|
const line = assignment.getStartLineNumber();
|
|
174
186
|
const init = assignment.getInitializer();
|
|
175
187
|
|
|
@@ -327,7 +339,7 @@ async function main(): Promise<void> {
|
|
|
327
339
|
}
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
process.exit(0);
|
|
342
|
+
process.exit(allManual.length > 0 ? 1 : 0);
|
|
331
343
|
}
|
|
332
344
|
|
|
333
345
|
if (import.meta.main) {
|
|
@@ -89,14 +89,22 @@ function isValidOwnTenantIdValue(valueNode: Node): boolean {
|
|
|
89
89
|
|
|
90
90
|
function objectHasValidOwnTenantId(obj: Node): boolean {
|
|
91
91
|
if (!Node.isObjectLiteralExpression(obj)) return false;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
const props = obj.getProperties();
|
|
93
|
+
const tenantIdIndex = props.findIndex(
|
|
94
|
+
(prop) =>
|
|
95
|
+
(Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) &&
|
|
96
|
+
prop.getName() === "tenantId",
|
|
97
|
+
);
|
|
98
|
+
if (tenantIdIndex === -1) return false;
|
|
99
|
+
// A later spread can override the literal tenantId at runtime.
|
|
100
|
+
if (props.slice(tenantIdIndex + 1).some((prop) => Node.isSpreadAssignment(prop))) return false;
|
|
101
|
+
const prop = props[tenantIdIndex];
|
|
102
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
103
|
+
const init = prop.getInitializer();
|
|
104
|
+
return init !== undefined && isValidOwnTenantIdValue(init);
|
|
105
|
+
}
|
|
106
|
+
if (Node.isShorthandPropertyAssignment(prop)) {
|
|
107
|
+
return isConstTenantIdBinding(enclosingFunctionScope(prop), "tenantId");
|
|
100
108
|
}
|
|
101
109
|
return false;
|
|
102
110
|
}
|
|
@@ -188,6 +188,12 @@ function collectFiles(paths: string[]): string[] {
|
|
|
188
188
|
return files;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Remaining manual sites still fail boot validation, so upgrade-cli must not
|
|
192
|
+
// treat this run as a completed migration.
|
|
193
|
+
export function exitCodeForManualSites(manualCount: number): 0 | 1 {
|
|
194
|
+
return manualCount > 0 ? 1 : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
191
197
|
async function main(): Promise<void> {
|
|
192
198
|
let options: CliOptions;
|
|
193
199
|
try {
|
|
@@ -241,7 +247,7 @@ async function main(): Promise<void> {
|
|
|
241
247
|
}
|
|
242
248
|
}
|
|
243
249
|
|
|
244
|
-
process.exit(
|
|
250
|
+
process.exit(exitCodeForManualSites(allManual.length));
|
|
245
251
|
}
|
|
246
252
|
|
|
247
253
|
if (import.meta.main) {
|
|
@@ -440,6 +440,8 @@ function findTargetFiles(rootDir: string): string[] {
|
|
|
440
440
|
|
|
441
441
|
export type StanceClass = "direct" | "user-owned" | "user-reference" | "near-miss" | "unclassified";
|
|
442
442
|
|
|
443
|
+
export type HintCategory = "direct" | "user-owned" | "user-reference";
|
|
444
|
+
|
|
443
445
|
export type StanceSite = {
|
|
444
446
|
readonly line: number;
|
|
445
447
|
readonly field: string;
|
|
@@ -447,14 +449,23 @@ export type StanceSite = {
|
|
|
447
449
|
readonly callee: string;
|
|
448
450
|
readonly stance: StanceClass;
|
|
449
451
|
readonly hint: string | undefined;
|
|
452
|
+
readonly nearMissOf: HintCategory | undefined;
|
|
450
453
|
};
|
|
451
454
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
...
|
|
455
|
+
type CategorizedHint = readonly [hint: string, category: HintCategory];
|
|
456
|
+
|
|
457
|
+
const ALL_PII_NAME_HINTS: readonly CategorizedHint[] = [
|
|
458
|
+
...[...PII_DIRECT_NAME_HINTS].map((hint): CategorizedHint => [hint, "direct"]),
|
|
459
|
+
...[...PII_USER_OWNED_NAME_HINTS].map((hint): CategorizedHint => [hint, "user-owned"]),
|
|
460
|
+
...[...PII_USER_REFERENCE_NAME_HINTS].map((hint): CategorizedHint => [hint, "user-reference"]),
|
|
456
461
|
];
|
|
457
462
|
|
|
463
|
+
type FieldClassification = {
|
|
464
|
+
readonly stance: StanceClass;
|
|
465
|
+
readonly hint: string | undefined;
|
|
466
|
+
readonly nearMissOf: HintCategory | undefined;
|
|
467
|
+
};
|
|
468
|
+
|
|
458
469
|
const REPORT_STANCE_CALLEES = new Set(["createTextField", "createLongTextField"]);
|
|
459
470
|
|
|
460
471
|
// Mirrors guard-text-field-stance.ts's hasPersonalStance exactly.
|
|
@@ -473,7 +484,14 @@ function reportStanceHasPersonalStance(obj: ObjectLiteralExpression): boolean {
|
|
|
473
484
|
function reportStanceEnclosingFieldName(call: CallExpression): string | undefined {
|
|
474
485
|
let node = call.getParent();
|
|
475
486
|
while (node) {
|
|
476
|
-
if (Node.isPropertyAssignment(node))
|
|
487
|
+
if (Node.isPropertyAssignment(node)) {
|
|
488
|
+
// getName() keeps the quotes of a string-literal key; computed keys have no static name.
|
|
489
|
+
const nameNode = node.getNameNode();
|
|
490
|
+
if (Node.isStringLiteral(nameNode) || Node.isNoSubstitutionTemplateLiteral(nameNode)) {
|
|
491
|
+
return nameNode.getLiteralText();
|
|
492
|
+
}
|
|
493
|
+
return Node.isIdentifier(nameNode) ? nameNode.getText() : undefined;
|
|
494
|
+
}
|
|
477
495
|
node = node.getParent();
|
|
478
496
|
}
|
|
479
497
|
return undefined;
|
|
@@ -506,72 +524,112 @@ function reportStanceResolveEntity(call: CallExpression): string | null {
|
|
|
506
524
|
return varDecl ? varDecl.getName() : null;
|
|
507
525
|
}
|
|
508
526
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
527
|
+
function isSegmentStart(fieldOriginal: string, index: number): boolean {
|
|
528
|
+
return (
|
|
529
|
+
index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_"
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// A hint only counts when it starts and ends on a segment boundary (index 0 /
|
|
534
|
+
// end of field, an uppercase letter in the original, or `_`) — otherwise a
|
|
535
|
+
// coincidental substring like "text" in "contextId" or "type" in "contentType"
|
|
536
|
+
// would false-positive.
|
|
512
537
|
function hintOccursAtBoundary(fieldLower: string, fieldOriginal: string, hint: string): boolean {
|
|
513
538
|
let searchFrom = 0;
|
|
514
539
|
for (;;) {
|
|
515
540
|
const index = fieldLower.indexOf(hint, searchFrom);
|
|
516
541
|
if (index === -1) return false;
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
542
|
+
const end = index + hint.length;
|
|
543
|
+
if (
|
|
544
|
+
isSegmentStart(fieldOriginal, index) &&
|
|
545
|
+
(end === fieldOriginal.length || isSegmentStart(fieldOriginal, end))
|
|
546
|
+
) {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
520
549
|
searchFrom = index + 1;
|
|
521
550
|
}
|
|
522
551
|
}
|
|
523
552
|
|
|
524
|
-
function findLongestBoundaryHint(
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
553
|
+
function findLongestBoundaryHint(
|
|
554
|
+
fieldLower: string,
|
|
555
|
+
fieldOriginal: string,
|
|
556
|
+
): CategorizedHint | undefined {
|
|
557
|
+
let best: CategorizedHint | undefined;
|
|
558
|
+
for (const entry of ALL_PII_NAME_HINTS) {
|
|
559
|
+
const [hint] = entry;
|
|
560
|
+
if (best && hint.length <= best[0].length) continue;
|
|
561
|
+
if (hintOccursAtBoundary(fieldLower, fieldOriginal, hint)) best = entry;
|
|
529
562
|
}
|
|
530
563
|
return best;
|
|
531
564
|
}
|
|
532
565
|
|
|
533
566
|
function segmentAlignedSuffixes(fieldOriginal: string): string[] {
|
|
534
567
|
const fieldLower = fieldOriginal.toLowerCase();
|
|
535
|
-
const
|
|
568
|
+
const starts: number[] = [];
|
|
536
569
|
for (let index = 0; index < fieldOriginal.length; index++) {
|
|
537
|
-
|
|
538
|
-
index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
|
|
539
|
-
if (atBoundary) suffixes.push(fieldLower.slice(index));
|
|
570
|
+
if (isSegmentStart(fieldOriginal, index)) starts.push(index);
|
|
540
571
|
}
|
|
541
|
-
|
|
572
|
+
// Hints are lowercase, so a hint's own segment boundary is unknowable. A
|
|
573
|
+
// single-segment tail ("Number", "Owner") is too generic to align with one;
|
|
574
|
+
// two or more segments ("UserId") are specific enough.
|
|
575
|
+
return starts.slice(0, -1).map((index) => fieldLower.slice(index));
|
|
542
576
|
}
|
|
543
577
|
|
|
544
578
|
// The hint sets only carry exact full names, so a suffix variant of one
|
|
545
579
|
// (e.g. "...UserId" of "assigneeUserId") otherwise slips through undetected.
|
|
546
|
-
|
|
547
|
-
|
|
580
|
+
// The suffix must be the tail of the hint: plain `includes` matched "number"
|
|
581
|
+
// inside "phonenumber" for "orderNumber".
|
|
582
|
+
function findShortestHintContainingSuffix(fieldOriginal: string): CategorizedHint | undefined {
|
|
583
|
+
let best: CategorizedHint | undefined;
|
|
548
584
|
for (const suffix of segmentAlignedSuffixes(fieldOriginal)) {
|
|
549
585
|
if (suffix.length < 5) continue;
|
|
550
|
-
for (const
|
|
551
|
-
|
|
552
|
-
if (
|
|
586
|
+
for (const entry of ALL_PII_NAME_HINTS) {
|
|
587
|
+
const [hint] = entry;
|
|
588
|
+
if (hint === suffix || !hint.endsWith(suffix)) continue;
|
|
589
|
+
if (!best || hint.length < best[0].length) best = entry;
|
|
553
590
|
}
|
|
554
591
|
}
|
|
555
592
|
return best;
|
|
556
593
|
}
|
|
557
594
|
|
|
558
|
-
function classifyFieldStance(field: string):
|
|
559
|
-
const fieldLower = field.toLowerCase();
|
|
560
|
-
if (PII_DIRECT_NAME_HINTS.has(fieldLower))
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
595
|
+
function classifyFieldStance(field: string): FieldClassification {
|
|
596
|
+
const fieldLower = field.replaceAll("_", "").toLowerCase();
|
|
597
|
+
if (PII_DIRECT_NAME_HINTS.has(fieldLower)) {
|
|
598
|
+
return { stance: "direct", hint: fieldLower, nearMissOf: undefined };
|
|
599
|
+
}
|
|
600
|
+
if (PII_USER_OWNED_NAME_HINTS.has(fieldLower)) {
|
|
601
|
+
return { stance: "user-owned", hint: fieldLower, nearMissOf: undefined };
|
|
602
|
+
}
|
|
603
|
+
if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower)) {
|
|
604
|
+
return { stance: "user-reference", hint: fieldLower, nearMissOf: undefined };
|
|
605
|
+
}
|
|
606
|
+
const nearMiss =
|
|
607
|
+
findLongestBoundaryHint(field.toLowerCase(), field) ?? findShortestHintContainingSuffix(field);
|
|
608
|
+
if (nearMiss) {
|
|
609
|
+
return { stance: "near-miss", hint: nearMiss[0], nearMissOf: nearMiss[1] };
|
|
610
|
+
}
|
|
611
|
+
return { stance: "unclassified", hint: undefined, nearMissOf: undefined };
|
|
569
612
|
}
|
|
570
613
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
614
|
+
function createReportStanceProject(): Project {
|
|
615
|
+
return new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
|
|
616
|
+
}
|
|
574
617
|
|
|
618
|
+
export function reportStanceForSource(
|
|
619
|
+
source: string,
|
|
620
|
+
filePath: string,
|
|
621
|
+
project: Project = createReportStanceProject(),
|
|
622
|
+
): StanceSite[] {
|
|
623
|
+
const sourceFile = project.createSourceFile(filePath, source, { overwrite: true });
|
|
624
|
+
try {
|
|
625
|
+
return collectStanceSites(sourceFile);
|
|
626
|
+
} finally {
|
|
627
|
+
// A shared project would otherwise keep every scanned file's AST alive.
|
|
628
|
+
project.removeSourceFile(sourceFile);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function collectStanceSites(sourceFile: SourceFile): StanceSite[] {
|
|
575
633
|
const sites: StanceSite[] = [];
|
|
576
634
|
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
577
635
|
const exprNode = call.getExpression();
|
|
@@ -591,9 +649,9 @@ export function reportStanceForSource(source: string, filePath: string): StanceS
|
|
|
591
649
|
// A call with no enclosing field (e.g. a bare createTextField() at top
|
|
592
650
|
// level) has no real name to classify — `createTextField(...)` would
|
|
593
651
|
// otherwise false-positive as a near-miss on its own "Text".
|
|
594
|
-
const { stance, hint } = enclosingField
|
|
652
|
+
const { stance, hint, nearMissOf }: FieldClassification = enclosingField
|
|
595
653
|
? classifyFieldStance(enclosingField)
|
|
596
|
-
: { stance: "unclassified"
|
|
654
|
+
: { stance: "unclassified", hint: undefined, nearMissOf: undefined };
|
|
597
655
|
|
|
598
656
|
sites.push({
|
|
599
657
|
line: call.getStartLineNumber(),
|
|
@@ -602,6 +660,7 @@ export function reportStanceForSource(source: string, filePath: string): StanceS
|
|
|
602
660
|
callee,
|
|
603
661
|
stance,
|
|
604
662
|
hint,
|
|
663
|
+
nearMissOf,
|
|
605
664
|
});
|
|
606
665
|
}
|
|
607
666
|
return sites;
|
|
@@ -620,6 +679,13 @@ function findReportStanceFiles(rootDir: string): string[] {
|
|
|
620
679
|
return files.sort();
|
|
621
680
|
}
|
|
622
681
|
|
|
682
|
+
function formatHintSuffix(site: StanceSite): string {
|
|
683
|
+
if (!site.hint) return "";
|
|
684
|
+
return site.nearMissOf
|
|
685
|
+
? ` (hint: ${site.hint}, near ${site.nearMissOf})`
|
|
686
|
+
: ` (hint: ${site.hint})`;
|
|
687
|
+
}
|
|
688
|
+
|
|
623
689
|
function reportStance(rootDir: string): void {
|
|
624
690
|
console.log(
|
|
625
691
|
`Scanning every .ts/.tsx under ${rootDir} except node_modules/dist/build/*.d.ts — guard-text-field-stance scans packages/*/src/** only, so counts can differ outside that scope.`,
|
|
@@ -634,8 +700,9 @@ function reportStance(rootDir: string): void {
|
|
|
634
700
|
};
|
|
635
701
|
let total = 0;
|
|
636
702
|
|
|
703
|
+
const project = createReportStanceProject();
|
|
637
704
|
for (const file of findReportStanceFiles(rootDir)) {
|
|
638
|
-
const sites = reportStanceForSource(readFileSync(file, "utf8"), file);
|
|
705
|
+
const sites = reportStanceForSource(readFileSync(file, "utf8"), file, project);
|
|
639
706
|
if (sites.length === 0) continue;
|
|
640
707
|
|
|
641
708
|
console.log(`\n${relative(rootDir, file)}`);
|
|
@@ -643,7 +710,7 @@ function reportStance(rootDir: string): void {
|
|
|
643
710
|
totals[site.stance]++;
|
|
644
711
|
total++;
|
|
645
712
|
const entity = site.entity ?? "<unresolved>";
|
|
646
|
-
const hintSuffix =
|
|
713
|
+
const hintSuffix = formatHintSuffix(site);
|
|
647
714
|
console.log(
|
|
648
715
|
` ${site.line} ${site.field} entity=${entity} ${site.callee} ${site.stance}${hintSuffix}`,
|
|
649
716
|
);
|