diff --git a/.changeset/cli-v2-cutover-prompt-correction.md b/.changeset/cli-v2-cutover-prompt-correction.md new file mode 100644 index 000000000..eb917fe2d --- /dev/null +++ b/.changeset/cli-v2-cutover-prompt-correction.md @@ -0,0 +1,13 @@ +--- +'stash': patch +--- + +`stash init`'s setup prompt no longer tells agents to declare an EQL v2 cutover +column with a `types.*` domain. + +The `types.*` factories are EQL v3 only — a v2 column is an `eql_v2_encrypted` +composite — so an agent following the old step-4 guidance would author a schema +that cannot describe the column it just cut over to. The prompt now says +explicitly not to use `types.*` for a v2 column, and points at the deprecated +`@cipherstash/stack/schema` builders with decryption through +`@cipherstash/stack`, which is the actual read path for legacy v2 rows. diff --git a/.changeset/domain-carrier-not-public.md b/.changeset/domain-carrier-not-public.md new file mode 100644 index 000000000..202284dbb --- /dev/null +++ b/.changeset/domain-carrier-not-public.md @@ -0,0 +1,18 @@ +--- +'@cipherstash/stack': patch +--- + +The phantom domain carrier on encrypted v3 columns is no longer part of the +public surface. + +Recovering a column's domain after declaration emit requires the type parameter +to appear bare somewhere in the instance type, which is what makes typed +`encryptQuery` callable against the published package. That carrier was a +plainly named `__domain` property, and this build has no `stripInternal`, so +`@internal` was documentation rather than enforcement: it appeared in +autocomplete on every column and could be read from consumer code, typed +`D | undefined` while being `undefined` at runtime in every case. + +It is now keyed by a `unique symbol` that is not exported, so it is unnameable +outside the package while remaining exactly as inferrable. Nothing is emitted at +runtime and no call site changes. diff --git a/.changeset/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md new file mode 100644 index 000000000..f5a125303 --- /dev/null +++ b/.changeset/encryption-schema-arrays.md @@ -0,0 +1,52 @@ +--- +'@cipherstash/stack': minor +'@cipherstash/prisma-next': patch +--- + +`Encryption({ schemas })` now accepts any non-empty array of EQL v3 tables, not +only an array literal. + +Previously the v3 signature required a non-empty *tuple*, so every indirect form +was rejected at compile time even though it worked at runtime: + +```ts +// all of these used to fail with TS2769 +export const schemas: AnyV3Table[] = [users, orders] +await Encryption({ schemas }) + +const readonlySchemas: ReadonlyArray = [users, orders] +await Encryption({ schemas: readonlySchemas }) + +const built: AnyV3Table[] = [] +built.push(users) +await Encryption({ schemas: built }) +``` + +They all compile now. `Encryption({ schemas: [] })` is still a compile error, and +an array literal still gets full per-column typing — passing the wrong plaintext +for a column's domain, or a table the client was not built with, is still +rejected. + +`EncryptionClientFor` is widened to match, so it names the typed client for a +loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic over +its schemas — an adapter that builds a table per request, say — can now write +`EncryptionClientFor` and keep the typed surface. + +If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to +satisfy the old signature, that narrowing is no longer needed. + +A wrapper that is itself generic over its schemas keeps working too: + +```ts +async function makeClient( + schemas: S, +) { + return await Encryption({ schemas }) +} +``` + +Note the non-empty tuple constraint. A wrapper generic over a loose +`readonly AnyV3Table[]` is still rejected — that type admits `readonly []`, so +the wrapper cannot promise what `Encryption` requires. Constrain it as above, or +take `EncryptionClientFor` as a parameter instead of +building the client inside the generic function. diff --git a/.changeset/prisma-next-v3-client-config.md b/.changeset/prisma-next-v3-client-config.md index 8d17b9a54..000bb7d41 100644 --- a/.changeset/prisma-next-v3-client-config.md +++ b/.changeset/prisma-next-v3-client-config.md @@ -16,9 +16,9 @@ const cipherstash = await cipherstashFromStack({ ``` The option never did what it looked like it did. This package is EQL v3 only, -and `eqlVersion: 2` selects `Encryption`'s nominal (untyped) overload at -runtime — not the `TypedEncryptionClient` that `cipherstashFromStack` returns as -`encryptionClient`. The field disagreed with the client you got back. +and `cipherstashFromStack` always builds from an all-v3 schema set, over which +`eqlVersion: 2` is refused at setup — v2 payloads cannot satisfy the columns' +`eql_v3_*` domains. The field promised a client it could never return. **Migration:** drop the `eqlVersion` field. Every other `ClientConfig` option (`workspaceCrn`, `clientId`, `clientKey`, `accessKey`, `authStrategy`, logging, diff --git a/.changeset/reject-v2-wire-over-v3-schemas.md b/.changeset/reject-v2-wire-over-v3-schemas.md new file mode 100644 index 000000000..a4b90ccfe --- /dev/null +++ b/.changeset/reject-v2-wire-over-v3-schemas.md @@ -0,0 +1,30 @@ +--- +'@cipherstash/stack': minor +--- + +`Encryption({ schemas, config: { eqlVersion: 2 } })` now throws when every table +in `schemas` is an EQL v3 table, instead of building a client that writes EQL v2 +payloads into `eql_v3_*` columns. + +`EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over whatever the +caller passed — the override's comment said that was why it existed. Collapsing +`EncryptionV3` into a deprecated alias of `Encryption` removed the override, and +nothing else rejected the combination: `resolveEqlVersion` already threw for a +mixed v2/v3 schema set and for legacy v2 `searchableJson()`, but returned an +explicit version unchanged. So a caller upgrading from +`EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — previously +auto-corrected, and working — silently got a v2-wire client instead, with no +diagnostic at any layer. The type surface agreed with the runtime (both say +"nominal client"), so nothing disagreed loudly enough to notice, and the failure +surfaced later as an `eql_v3_*` domain CHECK rejecting the write, or as v2 wire +landing in a v3 column wherever the check is looser. + +The escape hatch itself is unchanged where it is actually used: an explicit +`eqlVersion: 2` over an **EQL v2** schema set still emits v2 wire, which is how +v2 payloads are minted for the read-compatibility suite. Mixed sets still throw +the existing mixing error. Reading v2 payloads is unaffected — `decrypt` and +`decryptModel` continue to read both generations regardless of the client's wire +version. + +If you hit the new error, drop `config.eqlVersion` to emit v3, or build the +client from the EQL v2 schema you actually intend to write. diff --git a/.changeset/skills-encryption-client-naming.md b/.changeset/skills-encryption-client-naming.md new file mode 100644 index 000000000..4960a2212 --- /dev/null +++ b/.changeset/skills-encryption-client-naming.md @@ -0,0 +1,8 @@ +--- +'stash': patch +--- + +`skills/stash-encryption` now documents how to name the client's type +(`EncryptionClientFor`, not `Awaited>`, which +always resolves to the untyped nominal client) and states that `schemas` accepts +any non-empty array of v3 tables rather than only an array literal. diff --git a/.changeset/skills-ship-current-copy.md b/.changeset/skills-ship-current-copy.md new file mode 100644 index 000000000..3aa96a880 --- /dev/null +++ b/.changeset/skills-ship-current-copy.md @@ -0,0 +1,18 @@ +--- +'stash': patch +'@cipherstash/wizard': patch +--- + +Fixed: a change to `skills/` could ship a stale copy of that skill. + +Both CLIs copy the repo-root `skills/` into their bundle at build time +(`dist/skills`), which `stash init` then installs into a customer's +`.claude/skills/` or `.codex/skills/`. That directory sits outside the package, +so it was not part of the build's declared inputs — a skills-only edit did not +invalidate the cached build. Once the build began declaring its output +directory, a cache hit stopped being merely stale and started actively restoring +the previous `dist/skills` over the tree, so an edited skill could be published +with the pre-edit text while the source file on disk was correct and CI green. + +The two builds now declare the skills directory as an input, and a test pins +that coupling so it cannot come undone. diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md index de4b34cdf..48b32a60e 100644 --- a/.changeset/stack-audit-on-decrypt.md +++ b/.changeset/stack-audit-on-decrypt.md @@ -34,12 +34,10 @@ return type changes, and the two-argument form reconstructs `Date` columns from `cast_as` instead of leaving them as ISO strings. Code that read those columns as strings needs updating. -The v3 overload takes a non-empty tuple of tables and a `V3ClientConfig` — -`ClientConfig` without the deprecated `eqlVersion` escape hatch. So -`Encryption({ schemas: [] })` no longer type-checks (it used to compile and then -throw), and `config: { eqlVersion: 2 }` selects the nominal overload, which is -the client you actually get back. Callers passing a plain `AnyV3Table[]` rather -than an array literal must narrow it to `readonly [AnyV3Table, ...AnyV3Table[]]`. +The v3 overload takes a `V3ClientConfig` — `ClientConfig` without the deprecated +`eqlVersion` escape hatch. So `Encryption({ schemas: [] })` no longer type-checks +(it used to compile and then throw), and `config: { eqlVersion: 2 }` selects the +nominal overload — though over an all-v3 schema set that call now throws at setup. `Awaited>` names the nominal client whatever you pass, because `ReturnType` reads the last overload; use the exported `EncryptionClientFor` to name the client for a schema tuple. diff --git a/.changeset/typecheck-gates-scope-source.md b/.changeset/typecheck-gates-scope-source.md new file mode 100644 index 000000000..33879a9b5 --- /dev/null +++ b/.changeset/typecheck-gates-scope-source.md @@ -0,0 +1,20 @@ +--- +'@cipherstash/stack': patch +--- + +The declaration gate now covers all three emitted type artifacts, not just one. + +`packages/stack/dist-types` typechecks what `tsc` emits rather than what the +source says — the gap that let typed `encryptQuery` ship uncallable for every +column through the whole rc series. It resolved `../dist/*.js` by relative path +under bundler resolution, which never consults the `exports` map, so it only ever +exercised the ESM `.d.ts` set. `tsup` emits the CJS `.d.cts` set in a separate +pass and `wasm-inline.d.ts` in a third, each with its own inlined copy of the +column class whose domain parameter the bug was about. + +A second suite now resolves `@cipherstash/stack/*` **by package name** under +`moduleResolution: node16`, with the file extension selecting the condition +(`.cts` → `require` → `.d.cts`, `.mts` → `import` → `.d.ts`), plus a probe for +the `wasm-inline` entry — the documented target for Workers, Deno, Bun and +Supabase Edge, and previously the one artifact nothing typechecked. Removing the +phantom domain carrier makes all three fail, so none of them passes vacuously. diff --git a/.changeset/typed-client-init-parity.md b/.changeset/typed-client-init-parity.md new file mode 100644 index 000000000..ecc41b3b5 --- /dev/null +++ b/.changeset/typed-client-init-parity.md @@ -0,0 +1,37 @@ +--- +'@cipherstash/stack': patch +--- + +The typed EQL v3 client no longer throws `TypeError: init is not a function`. + +`Encryption` selects its overload from the `config` argument but selects the +client it actually returns by inspecting the `schemas`, so the two can disagree. +Hoisting a config into a `ClientConfig`-typed variable is enough to split them — +`ClientConfig.eqlVersion` is `2 | 3`, which the v3 overload's `eqlVersion?: 3` +rejects — so the call types as the nominal `EncryptionClient` while the runtime +still returns the typed client: + +```ts +const config: ClientConfig = { keyset } +const client = await Encryption({ schemas: [users], config }) +// type: EncryptionClient · runtime: TypedEncryptionClient +``` + +`init` was the only member of `EncryptionClient` missing from the typed client, +so any call through the declared type crashed. It is now present — and pins the +wire format rather than delegating verbatim. + +That distinction matters. `EncryptionClient.init` takes its own `eqlVersion` and +forwards `undefined` to the FFI, whose default is EQL **v2**, and it never +consults the check `Encryption` runs at construction. A bare passthrough would +therefore have let a typed v3 client be re-initialised into v2 wire while keeping +its v3 surface — writing `eql_v2_encrypted` payloads into `eql_v3_*` columns, the +same contradiction refused at construction, one method call later. The typed +`init` pins `eqlVersion: 3`, refuses an explicit `2` with a failure `Result`, and +resolves to the **typed** client, so the common +`client = (await client.init(cfg)).data` does not silently swap the typed surface +for the nominal one. + +The type still under-reports in this case: you lose the typed surface with no +diagnostic. Pass the config inline, or type the variable as `V3ClientConfig`, to +keep it. diff --git a/.changeset/typed-encrypt-query-dist.md b/.changeset/typed-encrypt-query-dist.md new file mode 100644 index 000000000..1fce605e7 --- /dev/null +++ b/.changeset/typed-encrypt-query-dist.md @@ -0,0 +1,34 @@ +--- +'@cipherstash/stack': patch +--- + +Fixed: `encryptQuery` on the typed EQL v3 client did not typecheck against the +published package — for any column. + +```ts +const users = encryptedTable('users', { email: types.TextSearch('email') }) +const client = await Encryption({ schemas: [users] }) + +client.encrypt('a@b.com', { table: users, column: users.email }) // fine +client.encryptQuery('a@b.com', { table: users, column: users.email }) +// TS2345: Argument of type '"a@b.com"' is not assignable to parameter of type 'never' +``` + +`PlaintextForColumn` and `QueryTypesForColumn` recover a column's domain with +`C extends EncryptedV3Column`, which needs `D` to appear bare somewhere +in the instance type. Its only bare occurrence was a **private** field, and +`tsc` strips the types of private members on declaration emit — so in the +shipped `.d.ts` the inference fell back to the `V3DomainDefinition` constraint. +`QueryTypesForColumn` collapsed to `never`, which made `QueryableColumnsOf` +`never`, which typed every query plaintext `never`. `encrypt` was unaffected +because it resolves through `ColumnsOf`. + +`EncryptedV3Column` now carries a type-only `declare readonly __domain?: D`. +Nothing is emitted at runtime and no call site changes; the declaration survives +emit and restores the inference site. + +This affected every published release with the v3 typed client, including +`1.0.0-rc.4` — the searchable-query recipes in the `stash-encryption` skill did +not compile in a customer's repo. It was invisible in CI because the type tests +import source rather than `dist/`; a new `test:types:dist` suite now typechecks +the emitted declarations and is wired into CI. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 849feafbf..369eecd61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -160,9 +160,50 @@ jobs: - name: Typecheck (examples/basic — guards the v3 stack/stack-drizzle importers) run: pnpm exec turbo run typecheck --filter @cipherstash/basic-example + # The rest of the surfaces that were compiling nowhere. Each of these has + # a `tsconfig.json` that covers its `src` and tests, and each was already + # clean — so they are gated now, before they drift. They resolve their + # workspace dependencies through `dist/*.d.ts`, hence the turbo filters + # (`^build` builds the dependencies first). + # + # NOT gated yet, and deliberately: `@cipherstash/stack` (147 errors under + # its own tsconfig), `@cipherstash/stack-drizzle` (63), `stash` (21) and + # `@cipherstash/stack-supabase` (11). Their `test:types` scripts only + # cover `__tests__/**/*.test-d.ts`, so `src`, the runtime test suites and + # `integration/**` compile nowhere. Most of the drizzle and supabase count + # is one root cause — `V3_MATRIX`'s `indexes` union in + # `@cipherstash/test-kit` — see #778. + - name: Typecheck (migrate) + run: pnpm exec turbo run typecheck --filter @cipherstash/migrate + + - name: Typecheck (nextjs) + run: pnpm exec turbo run typecheck --filter @cipherstash/nextjs + + - name: Typecheck (examples/prisma — guards the prisma-next importers) + run: pnpm exec turbo run typecheck --filter @cipherstash/prisma-next-example + + - name: Typecheck (e2e) + run: pnpm exec turbo run typecheck --filter @cipherstash/e2e + + # Everything else typechecks against SOURCE. This one reads the emitted + # `.d.ts`, which is what customers consume — and where typed `encryptQuery` + # sat broken for every column through the whole rc series, because `tsc` + # strips private member types and that was the only place the column's + # domain parameter appeared bare. + - name: Typecheck (stack — emitted declarations, not source) + run: pnpm exec turbo run test:types:dist --filter @cipherstash/stack + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners + # The gates above only mean something if they compile source. A tsconfig + # with no `include` globs `**/*`, which sweeps the package's own `dist/` + # into the program — and whether `dist/` exists during a gate depends on + # what an earlier step built transitively, so the gate compiles a + # different program in CI than it does locally. + - name: Lint — typecheck gates compile source, not build output + run: pnpm run lint:typecheck-scope + # Deleting or renaming a package leaves `packages/` references # dangling in docs and CI config. Nothing else catches it (#760 review). - name: Lint — no references to deleted package directories diff --git a/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md new file mode 100644 index 000000000..c8c1cdb93 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md @@ -0,0 +1,275 @@ +# `Encryption` signature fixes and typecheck gates + +**Date:** 2026-07-24 +**Branch:** `fix/encryption-signature-and-typecheck-gates` (off `8b3f0b15`) +**Addresses:** A-4, A-6, A-7, C-1 from `.work/2026-07-24-eql-v2-removal-verification.md` +**Issue:** #778 (review remediation for #772) + +## Why these four, and why not more + +A-6 and A-7 exist because `Encryption` is overloaded, and it is overloaded because there +are two kinds of client: the typed EQL v3 one and the nominal one that EQL v2 and loose +introspection-derived schemas need. Two overloads means two discriminators that can +disagree — overload resolution reads the `config` argument, the runtime reads the +`schemas` argument (`encryption/index.ts:947`). Remove EQL v2 and there is one client, +one signature, and both defects delete themselves. + +So this change deliberately does **not** attempt the type-level repair the verification +doc explores (a single generic signature returning `ClientFor`, a discriminated-union +`WireConfig`, `ConfigFor`). That machinery exists only to make two clients coexist +safely, and it would be deleted by the v2 removal that motivates it. It is issue #637 and +it belongs with PRs 9–12. + +What is left is the subset that is either independent of the v2 removal (A-4, C-1) or +cheap and non-throwaway (A-6, A-7). + +## A-4 — non-tuple schema arrays are rejected + +### Problem + +`Encryption({ schemas })` accepts only an array *literal*. Every indirect form fails with +`TS2769`: + +```ts +export const all: AnyV3Table[] = [users, orders] // shared module +await Encryption({ schemas: all }) // TS2769 +``` + +Also broken: `ReadonlyArray` (the type `prisma-next` exposes publicly), +push-built arrays, spreads, and unannotated `const all = [...]`. + +The constraint at `encryption/index.ts:872-877` is a non-empty *tuple*: + +```ts +export function Encryption< + const S extends readonly [AnyV3Table, ...AnyV3Table[]], +>(config: { schemas: S; config?: V3ClientConfig }): Promise> +``` + +It was narrowed to a tuple by `7e0092f3` for one reason: `readonly AnyV3Table[]` admits +`readonly []`, so `Encryption({ schemas: [] })` type-checked and then threw at runtime. + +This is a live break on a released surface. `prisma-next` already had to work around it +with a destructure-and-respread through three coupled edits in `from-stack-v3.ts`; any +customer with schema-building indirection hits the same wall. + +### Design + +Widen the type parameter to the array and move the non-emptiness check to the property, so +`[]` is rejected without the tuple constraining every other form: + +```ts +type NonEmptyV3 = S['length'] extends 0 ? never : S + +export function Encryption(config: { + schemas: NonEmptyV3 + config?: V3ClientConfig +}): Promise> +``` + +`schemas` must resolve through `NonEmptyV3` rather than being wrapped in a conditional +alias applied to the whole config — wrapping defeats `const` inference and degrades the +tuple to an array, losing per-column typing on the literal path. + +The runtime throw at `encryption/index.ts:891` stays as the backstop for JavaScript callers. + +### Acceptance + +A `.test-d.ts` probe pinning both directions: + +- compile: inline literal, shared `AnyV3Table[]`, `ReadonlyArray`, push-built, + spread, unannotated `const`; +- still error: `{ schemas: [] }`, wrong plaintext type for a column's domain, a table that + is not a member of the registered tuple. + +## A-6 — `ReturnType` resolves to the nominal client + +### Problem + +TypeScript's `ReturnType` reads the *last* overload, which is the nominal one. So +`Awaited>` yields `EncryptionClient` even for an all-v3 +schema set, and assigning the real client to it fails: + +```text +Type 'TypedEncryptionClient<…>' is missing the following properties +from type 'EncryptionClient': client, encryptConfig, init +``` + +Overload order cannot fix this — whichever signature is last wins, so one of the two forms +is always mis-resolved. Reordering additionally destroys the typed client, because a v3 +table structurally satisfies `BuildableTable` and so matches the nominal overload. + +This is a regression from the released surface, not a pre-existing wart: all 15 instances +were introduced by `d7ff8471`, which turned a single-signature `EncryptionV3` into an alias +of an overloaded `Encryption`. `packages/bench/src/drizzle/setup.ts:38-45` already carries a +hand-rolled workaround. + +15 sites, none visible to CI: + +| Package | Sites | +|---|---| +| `packages/stack` | `__tests__/dynamodb/encrypted-dynamodb-v3.test.ts` (×3), `__tests__/encrypt-lock-context-guards.test.ts`, `__tests__/encrypt-query-searchable-json.test.ts`, `__tests__/encrypt-query-stevec.test.ts`, `integration/shared/{matrix-crypto,matrix-sql,schema-pg,schema-v3-client}.integration.test.ts` | +| `packages/stack-drizzle` | `integration/{adapter,json-adapter}.ts`, `integration/{lock-context,null-persistence,relational}.integration.test.ts` | + +### Design + +No signature change. `EncryptionClientFor` (`encryption/v3.ts:399-402`) already exists and +is the correct idiom — the prior-art survey in the verification doc found that no library +dispatches a schema-dependent result type through `ReturnType`; they all expose a named +extraction type (`z.infer`, `typeof x.infer`, hono's `Client`). Convert the call sites to +it and document it as *the* way to name the client. + +**`EncryptionClientFor` must be widened in step with A-4.** It carries the same narrow tuple +guard: + +```ts +S extends readonly [AnyV3Table, ...AnyV3Table[]] ? TypedEncryptionClient : EncryptionClient +``` + +Left alone, `EncryptionClientFor` falls through to `EncryptionClient` — +so the type A-6 tells callers to use would silently hand back the nominal client for exactly +the non-tuple schemas A-4 just enabled. It becomes: + +```ts +export type EncryptionClientFor = + S extends readonly AnyV3Table[] + ? S['length'] extends 0 + ? EncryptionClient + : TypedEncryptionClient + : EncryptionClient +``` + +The `readonly []` arm must be checked *inside* the v3 branch and before the tuple is used: +`never extends X` is true, so an empty tuple otherwise satisfies "all elements are v3". + +Erased sites that pass `[schema as never]` (the generic `stack-drizzle` integration adapters) +are declared `EncryptionClientFor`, which resolves to +`TypedEncryptionClient` and accepts `TypedEncryptionClient` +by method bivariance. + +`encryption-overloads.test-d.ts:84-88` currently asserts the defect as expected behaviour and +is green in CI. It is rewritten to assert `EncryptionClientFor` resolves correctly instead. + +## A-7 — the type says nominal, the runtime hands back typed + +### Problem + +Overload resolution matches on the `config` argument; the runtime picks its client by +inspecting the `schemas` (`encryption/index.ts:947`, `isV3Only && eqlVersion === 3`). They +disagree whenever a config is hoisted into a `ClientConfig`-typed variable: + +```ts +const cfg: ClientConfig = {} // not assignable to V3ClientConfig +const c = await Encryption({ schemas: [users], config: cfg }) +// type: EncryptionClient runtime: TypedEncryptionClient +c.init(...) // TypeError: init is not a function +``` + +`ClientConfig.eqlVersion` is `2 | 3`; the v3 overload requires `eqlVersion?: 3`. So the +variable form selects the nominal overload while the runtime still returns the typed client. +This bites whenever the variable's runtime `eqlVersion` is anything but `2` — `{}`, +`{ keyset }`, `{ authStrategy }`: the common case. + +Measured blast radius: `init` is the **only** member on `EncryptionClient.prototype` absent +from the typed client at runtime. The other ten are all present. + +### Design + +Add an `init` passthrough to the object `typedClient()` returns, declared `@internal` on +`TypedEncryptionClient` so `satisfies` still checks the shape: + +```ts +init: (config) => client.init(config), +``` + +This reduces the runtime gap to zero. What remains is a silent capability downgrade — the +type says nominal, so the caller loses the typed surface with no diagnostic. No type-level +design closes that: the runtime inspects values while the type inspects an erasable static +type. It ends when v2 removal collapses the two clients into one. + +### Acceptance + +A unit test reproducing the exact shape (config declared `ClientConfig`, v3 schemas, +`EncryptionClient.prototype.init` stubbed so no credentials are needed) that fails with +`TypeError: client.init is not a function` before the change and passes after. + +## C-1 — typecheck gates + +### Problem + +The root `typecheck` gate for `examples/*` already exists (`.github/workflows/tests.yml:155-161`, +added by `5fab1cf6`) — the verification doc refutes the original framing. The remaining gap is +the surfaces nothing typechecks at all. + +Measured after a full `pnpm run build` (most apparent failures were unbuilt workspace deps +resolving to `dist/*.d.ts`, not real errors): + +| Surface | Errors | Has script | +|---|---|---| +| `packages/bench` | 0 | no | +| `packages/migrate` | 0 | no | +| `packages/prisma-next` | 0 | `typecheck` | +| `packages/test-kit` | 0 | `test:types` | +| `packages/wizard` | 0 | `typecheck` | +| `examples/basic` | 0 | `typecheck` (gated) | +| `examples/prisma` | 0 | `typecheck` (never invoked) | +| `e2e` | 0 | no | +| `packages/nextjs` | 2 | no | +| `packages/stack-supabase` | 11 | `test:types` (narrower config) | +| `packages/cli` | 21 | no | +| `packages/stack-drizzle` | 69 | `test:types` (narrower config) | +| `packages/stack` | 168 | `test:types` (narrower config) | + +The three `test:types` scripts run `vitest --typecheck.only` against a `tsconfig.typecheck.json` +whose `include` is `__tests__/**/*.test-d.ts` — so the package's real `tsconfig.json`, which +covers `src`, `__tests__/**/*.test.ts` and `integration/**`, is never checked. + +### Design + +Gate what is green, and record what is not with its count rather than silently skipping it. + +1. Add `typecheck` scripts (`tsc --noEmit -p tsconfig.json`) to `bench`, `migrate`, `nextjs`, + `e2e`; keep the existing ones on `prisma-next`, `wizard`, `examples/*`. +2. One CI job running the eight green surfaces plus `nextjs`, after `pnpm run build` (they + resolve workspace deps through `dist/*.d.ts`). +3. Fix `packages/nextjs`'s 2 errors: `vi.Mock` is used as a *type* in + `__tests__/nextjs.test.ts:68,81`; it needs `import type { Mock } from 'vitest'`. +4. Add `"outputs": ["dist/**"]` to `turbo.json`'s `build` task. It currently declares none, + so a cached build restores nothing and the `examples/basic` gate — which typechecks + against `packages/stack/dist/*.d.ts` — holds today only because fresh CI runners have no + cache. +5. Record `stack-supabase` (11), `cli` (21), `stack-drizzle` (69), `stack` (168) as + documented follow-ups. + +### Deliberately out of scope + +Making `stack`, `stack-drizzle`, `stack-supabase` and `cli` green. 51 of `stack-drizzle`'s 69 +and all 11 of `stack-supabase`'s are one root cause — `spec.indexes.unique` / `.ore` / `.ope` +against `V3_MATRIX` in `packages/test-kit`'s catalog, where `typedEntries` collapses the spec +to a union whose members do not all carry those keys. A single fix in `test-kit` likely clears +~62 of the ~80 errors across those two packages. That is the highest-leverage next step and it +is its own change. + +## Interactions and ordering + +A-4 and A-6 must land together: widening `Encryption` without widening `EncryptionClientFor` +leaves the documented idiom resolving to the wrong client. + +A-7 is independent but shares the same files, and it removes `init` from A-6's `TS2739` +message (leaving only the private `client` / `encryptConfig` fields), so it lands first to +keep the A-6 diffs legible. + +C-1 is independent of all three. It is sequenced last because the A-6 conversions remove 15 +of the errors in the two packages it reports counts for. + +## Other deliverables + +- Changeset: `@cipherstash/stack` **minor** — A-4 widens a released signature and A-7 adds a + member to `TypedEncryptionClient`. `@cipherstash/stack-drizzle` patch for the integration + adapter conversions. +- Delete the migration paragraph in `.changeset/stack-audit-on-decrypt.md` telling callers to + narrow `AnyV3Table[]` to `readonly [AnyV3Table, ...AnyV3Table[]]` — A-4 makes that advice + obsolete, and it was never correct for `ReadonlyArray` anyway. +- Skills sweep: `skills/stash-encryption/SKILL.md` for any `ReturnType` + guidance and for schema-array examples that the widening now permits. +- `packages/stack/README.md` and `packages/prisma-next` docs for the same. diff --git a/e2e/package.json b/e2e/package.json index 06cf30efb..dd10ac340 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,8 @@ "description": "End-to-end tests that exercise built CipherStash binaries and cross-package behaviour.", "type": "module", "scripts": { - "test:e2e": "vitest run" + "test:e2e": "vitest run", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "stash": "workspace:*", diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index 238655f2c..28110041f 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -23,5 +23,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/package.json b/package.json index 13cfd258b..f25fe0c29 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "code:check": "biome check", "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", + "lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs", "lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs", "release": "pnpm run build && changeset publish", "test": "turbo test --filter './packages/*'", diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 16ba56d2e..87da15873 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -1,4 +1,4 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3' import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle' import { drizzle } from 'drizzle-orm/node-postgres' import { pgTable, serial } from 'drizzle-orm/pg-core' @@ -34,19 +34,9 @@ export type BenchPlaintextRow = { enc_jsonb: { idx: number; group: number } } -/** - * Build the typed EQL v3 client this bench drives. Wrapped in a - * single-signature helper because `EncryptionV3` is now overloaded (typed v3 - * vs. nominal) — `ReturnType` resolves to the *last* - * (nominal) overload, so we infer the return type through this helper instead. - */ -function makeEncryptionClient() { - return EncryptionV3({ schemas: [encryptionBenchTable] }) -} - /** The typed EQL v3 client this bench drives. */ -export type BenchEncryptionClient = Awaited< - ReturnType +export type BenchEncryptionClient = EncryptionClientFor< + readonly [typeof encryptionBenchTable] > export type BenchHandle = { @@ -69,7 +59,9 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await makeEncryptionClient() + const encryptionClient = await EncryptionV3({ + schemas: [encryptionBenchTable], + }) return { pgClient, pool, db, encryptionClient } } diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index dd5ba0de7..c0e22ba21 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -303,7 +303,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '#### Backfill and switch — after dual-writes are live', '', `3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, - `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2 (legacy data only):** update the schema file to declare the encrypted column under its final name (drop the twin suffix), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`). The adapters no longer author v2 — \`@cipherstash/stack-drizzle\` removed \`encryptedType\` — so declare the column with a \`types.*\` v3 domain and reach v2 rows through \`@cipherstash/stack\`'s decrypt path.`, + `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2 (legacy data only):** update the schema file to declare the encrypted column under its final name (drop the twin suffix), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`). Do **not** declare a v2 column with a \`types.*\` domain — those are EQL v3 only. The adapters no longer author v2 (\`@cipherstash/stack-drizzle\` removed \`encryptedType\`), so a v2 column is a read path: declare it with the deprecated \`@cipherstash/stack/schema\` builders and decrypt through \`@cipherstash/stack\`.`, '5. **Wire the read path through the encryption client.** The read column now holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw encrypted payloads to end users. The integration skill has the exact API.', '6. **Remove the dual-write code.** The plaintext column (still `` on v3; renamed `_plaintext` on v2) is no longer authoritative. Delete the dual-write logic from the persistence layer.', `7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused plaintext column (on v3 it first verifies no rows are still plaintext-only). Apply with the project's normal migration tooling.`, diff --git a/packages/migrate/package.json b/packages/migrate/package.json index b14343caa..c96f2f9db 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -42,6 +42,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "test": "vitest run", "lint": "biome check ." diff --git a/packages/migrate/tsconfig.json b/packages/migrate/tsconfig.json index 982c07850..f479fb74d 100644 --- a/packages/migrate/tsconfig.json +++ b/packages/migrate/tsconfig.json @@ -19,5 +19,10 @@ "paths": { "@/*": ["./src/*"] } - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/packages/nextjs/__tests__/nextjs.test.ts b/packages/nextjs/__tests__/nextjs.test.ts index c15f05b52..e03df244c 100644 --- a/packages/nextjs/__tests__/nextjs.test.ts +++ b/packages/nextjs/__tests__/nextjs.test.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' // cts.test.ts -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' // --------------------------------------------- // 1) Mock next/headers before importing it @@ -65,7 +65,7 @@ describe('getCtsToken', () => { accessToken: 'fake_token', expiry: 999999, } - ;(cookies as unknown as vi.Mock).mockReturnValueOnce({ + ;(cookies as unknown as Mock).mockReturnValueOnce({ get: vi.fn().mockReturnValue({ value: JSON.stringify(mockCookieValue) }), }) @@ -78,7 +78,7 @@ describe('getCtsToken', () => { }) it('should return null if the cookie is not present', async () => { - ;(cookies as unknown as vi.Mock).mockReturnValueOnce({ + ;(cookies as unknown as Mock).mockReturnValueOnce({ get: vi.fn().mockReturnValue(undefined), }) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 2d7deebad..df6e8a461 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -33,6 +33,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", "release": "tsup" }, diff --git a/packages/nextjs/tsconfig.json b/packages/nextjs/tsconfig.json index 6da81c927..ad991f9c5 100644 --- a/packages/nextjs/tsconfig.json +++ b/packages/nextjs/tsconfig.json @@ -24,5 +24,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index 2f569b550..071446b4d 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -59,8 +59,8 @@ export interface CipherstashFromStackV3Options { * Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). * * `V3ClientConfig`, not `ClientConfig`: this package is EQL v3 only, and the - * legacy `eqlVersion: 2` escape hatch returns the nominal (untyped) client at - * runtime, which is not what this entry point hands back. + * legacy `eqlVersion: 2` escape hatch throws at setup over the all-v3 schema + * set this entry point derives. */ readonly encryptionConfig?: V3ClientConfig } @@ -89,11 +89,8 @@ export async function cipherstashFromStack( ) } - // Destructured rather than length-checked so the non-emptiness survives into - // the type layer: `Encryption` requires a non-empty schema tuple (an empty one - // used to type-check and then throw at runtime). - const [firstDerived, ...restDerived] = deriveStackSchemasV3(opts.contractJson) - if (firstDerived === undefined) { + const derived = deriveStackSchemasV3(opts.contractJson) + if (derived.length === 0) { throw new Error( 'cipherstashFromStack: no v3 cipherstash columns found in contract.json. ' + 'Declare at least one v3 `cipherstash.*()` column (e.g. `cipherstash.TextSearch()`) in prisma/schema.prisma ' + @@ -101,10 +98,7 @@ export async function cipherstashFromStack( ) } - const schemas = resolveV3Schemas( - [firstDerived, ...restDerived], - opts.schemasV3, - ) + const schemas = resolveV3Schemas(derived, opts.schemasV3) const encryptionClient = await EncryptionV3({ schemas, @@ -144,18 +138,15 @@ function collectNonV3CipherstashCodecIds( return [...ids].sort() } -/** A schema list carrying its non-emptiness in the type, as `Encryption` wants. */ -type NonEmptyV3Schemas = readonly [AnyV3Table, ...AnyV3Table[]] - /** * Validate contract-declared tables against their overrides (exact * domain identity) and append override-only tables — same merge * semantics as the v2 `resolveSchemas`. */ function resolveV3Schemas( - derived: NonEmptyV3Schemas, + derived: ReadonlyArray, override: ReadonlyArray | undefined, -): NonEmptyV3Schemas { +): ReadonlyArray { if (override === undefined || override.length === 0) return derived const derivedByName = new Map(derived.map((t) => [t.tableName, t])) @@ -168,8 +159,7 @@ function resolveV3Schemas( } return [ - derived[0], - ...derived.slice(1), + ...derived, ...override.filter((t) => !derivedByName.has(t.tableName)), ] } diff --git a/packages/stack-drizzle/__tests__/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts index b5e1be918..600a62d63 100644 --- a/packages/stack-drizzle/__tests__/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -4,7 +4,7 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedQueryResult } from '@cipherstash/stack/types' -import type { EncryptionV3 } from '@cipherstash/stack/v3' +import type { AnyV3Table, EncryptionClientFor } from '@cipherstash/stack/v3' import { describe, expectTypeOf, it } from 'vitest' import { createEncryptionOperators } from '../src/index.js' @@ -21,7 +21,7 @@ import { createEncryptionOperators } from '../src/index.js' * existing typecheck scope without dragging the loose-typed runtime suites in. */ describe('createEncryptionOperators - client parameter (M1)', () => { - type V3Client = Awaited> + type V3Client = EncryptionClientFor // A query operation resolving `Result` — the surface the factory drives. type QueryOp = { diff --git a/packages/stack-drizzle/integration/adapter.ts b/packages/stack-drizzle/integration/adapter.ts index a2e1ea848..635572de3 100644 --- a/packages/stack-drizzle/integration/adapter.ts +++ b/packages/stack-drizzle/integration/adapter.ts @@ -1,4 +1,8 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, type IntegrationAdapter, @@ -57,7 +61,7 @@ type AnyTable = any export function makeDrizzleAdapter(): IntegrationAdapter { let sqlClient: postgres.Sql let db: ReturnType - let client: Awaited> + let client: EncryptionClientFor let ops: ReturnType let table: AnyTable let schema: ReturnType diff --git a/packages/stack-drizzle/integration/json-adapter.ts b/packages/stack-drizzle/integration/json-adapter.ts index 2ed37577b..44743a1d3 100644 --- a/packages/stack-drizzle/integration/json-adapter.ts +++ b/packages/stack-drizzle/integration/json-adapter.ts @@ -1,4 +1,8 @@ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, type JsonIntegrationAdapter, @@ -25,7 +29,7 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { let db: ReturnType let tableName: string let table: AnyTable - let client: Awaited> + let client: EncryptionClientFor let ops: ReturnType const rowsFor = async ( diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index 8167c0edf..489a78100 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -33,7 +33,11 @@ * control. */ import { OidcFederationStrategy } from '@cipherstash/stack' -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, unwrapResult, V3_MATRIX } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' @@ -71,7 +75,7 @@ const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } -let client: Awaited> +let client: EncryptionClientFor let ops: ReturnType let db: ReturnType diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index f97c1d29f..6a0e0f418 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -13,7 +13,11 @@ * as SQL NULL, and the present cell still decrypts to its plaintext. */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' @@ -84,7 +88,7 @@ const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } -let client: Awaited> +let client: EncryptionClientFor let ops: ReturnType let db: ReturnType diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index 8e6eecb38..5fac50151 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -19,7 +19,11 @@ * `stash eql install`. This suite throws rather than skips when unconfigured. */ -import { EncryptionV3 } from '@cipherstash/stack/v3' +import { + type AnyV3Table, + type EncryptionClientFor, + EncryptionV3, +} from '@cipherstash/stack/v3' import { type DomainSpec, databaseUrl, @@ -136,7 +140,7 @@ type RowKey = (typeof ROWS)[number] type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType -type Client = Awaited> +type Client = EncryptionClientFor type Ops = ReturnType type ComparisonOperator = 'gt' | 'gte' | 'lt' | 'lte' diff --git a/packages/stack-drizzle/tsconfig.typecheck.json b/packages/stack-drizzle/tsconfig.typecheck.json index 9d06679f0..16d312f2f 100644 --- a/packages/stack-drizzle/tsconfig.typecheck.json +++ b/packages/stack-drizzle/tsconfig.typecheck.json @@ -1,4 +1,17 @@ { + // The typecheck gate CI runs (`test:types` -> vitest typecheck) covers the + // `.test-d.ts` files plus the two `integration/**` adapters that #772's + // review found were erroring in a file nothing compiled. + // + // `src/`, the runtime `__tests__/*.test.ts` and the rest of `integration/**` + // are still ungated: 63 errors under the full `tsconfig.json`, most of them + // one root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit` + // (#778). Widen this `include` as that count comes down; do not widen it to + // the whole project until it is zero, or the gate is useless. "extends": "./tsconfig.json", - "include": ["__tests__/**/*.test-d.ts"] + "include": [ + "__tests__/**/*.test-d.ts", + "integration/adapter.ts", + "integration/json-adapter.ts" + ] } diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index b9cebc982..083af15e7 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -259,9 +259,9 @@ describe('encryptedSupabaseV3 factory', () => { }) }) - // `eqlVersion` is forced, not defaulted. A caller who passes `eqlVersion: 2` - // against v3 domains would otherwise get a v2 encryption client and fail at - // runtime with a 23514 CHECK violation, far from the cause. + // `eqlVersion` is forced, not defaulted. Without the force a caller's + // `eqlVersion: 2` would now make `Encryption` throw at setup, against the + // all-v3 schema set introspection synthesizes. it('forces eqlVersion 3 over a caller-supplied config, passing other keys through', async () => { await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x', diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index 6755b8b56..e19f685e9 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -464,8 +464,10 @@ export interface TypedEncryptedSupabaseInstance { * * What IS carried is exactly: `then` (via `PromiseLike`), `abortSignal`, * `throwOnError`, `returns`, `withLockContext` and `audit`. That is a - * hand-written list, not a passthrough — `single()`/`maybeSingle()` return the - * same builder instance, so a method absent here is absent at runtime too. + * hand-written list, not a passthrough. It narrows the static API only: + * `single()`/`maybeSingle()` return the same builder instance, so a method + * omitted here is still a property of that object at runtime — the type is what + * stops you reaching it, not the implementation. * * It is therefore NOT parity with postgrest-js, which carries a different set: * its `single()` returns a `PostgrestBuilder`, carrying diff --git a/packages/stack/__tests__/audit.test.ts b/packages/stack/__tests__/audit.test.ts index bb14447bd..edf2b1df2 100644 --- a/packages/stack/__tests__/audit.test.ts +++ b/packages/stack/__tests__/audit.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -20,7 +21,7 @@ type User = { number?: number } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/backward-compat.test.ts b/packages/stack/__tests__/backward-compat.test.ts index 915108cc7..cdf5c930c 100644 --- a/packages/stack/__tests__/backward-compat.test.ts +++ b/packages/stack/__tests__/backward-compat.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -8,7 +9,7 @@ const users = encryptedTable('users', { }) describe('k-field discriminator (EQL v2.3)', () => { - let protectClient: Awaited> + let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) diff --git a/packages/stack/__tests__/basic-protect.test.ts b/packages/stack/__tests__/basic-protect.test.ts index a889328b0..af687818c 100644 --- a/packages/stack/__tests__/basic-protect.test.ts +++ b/packages/stack/__tests__/basic-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -9,7 +10,7 @@ const users = encryptedTable('users', { json: encryptedColumn('json').dataType('json'), }) -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/bulk-protect.test.ts b/packages/stack/__tests__/bulk-protect.test.ts index 45342f91b..10d615c7c 100644 --- a/packages/stack/__tests__/bulk-protect.test.ts +++ b/packages/stack/__tests__/bulk-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -19,7 +20,7 @@ type User = { number?: number } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts index 3953d0a8b..f69519e81 100644 --- a/packages/stack/__tests__/decrypt-audit-forwarding.test.ts +++ b/packages/stack/__tests__/decrypt-audit-forwarding.test.ts @@ -40,6 +40,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClientFor } from '@/encryption/v3' // Imported after the mock so the v3 table builder is available; `Encryption` // returns the typed client for an all-v3 schema set. import { encryptedTable, types } from '@/encryption/v3' @@ -66,7 +67,7 @@ const lastDecryptOpts = () => (ffi.decryptBulk as any).mock.calls.at(-1)[1] const lastCiphertextLockContext = () => lastDecryptOpts().ciphertexts[0]?.lockContext -let client: Awaited>> +let client: EncryptionClientFor let prevWorkspaceCrn: string | undefined beforeEach(async () => { diff --git a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts index 971b641a2..b8995a67f 100644 --- a/packages/stack/__tests__/dynamodb/client-compat.test-d.ts +++ b/packages/stack/__tests__/dynamodb/client-compat.test-d.ts @@ -15,7 +15,7 @@ import { describe, expectTypeOf, it } from 'vitest' import type { EncryptedAttributes, EncryptedDynamoDBInstance } from '@/dynamodb' import { encryptedDynamoDB } from '@/dynamodb' import type { EncryptionClient } from '@/encryption' -import type { EncryptionV3 } from '@/encryption/v3' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' @@ -42,12 +42,17 @@ const searchV3 = encryptedTableV3('search_v3', { type V3Model = { pk: string; email?: string; age?: number; role?: string } -// The two client shapes. `EncryptionV3` returns a `TypedEncryptionClient` -// parameterised by its own schema tuple; `Encryption` returns the nominal -// `EncryptionClient`. Both must be accepted by the factory WITHOUT a cast. -declare const typedClient: Awaited< - ReturnType> -> +// The two client shapes. A v3 schema tuple yields a `TypedEncryptionClient` +// parameterised by it; a v2 set yields the nominal `EncryptionClient`. Both must +// be accepted by the factory WITHOUT a cast. +// +// Named with `EncryptionClientFor`, not `Awaited>` +// — `ReturnType` reads the LAST overload, so that idiom resolves to the nominal +// client whatever schemas you pass. Supplying an explicit type argument happens +// to dodge it today, but only because it filters the non-generic nominal +// overload out of the instantiation; give that overload a type parameter and +// this assertion would silently start checking the wrong client. +declare const typedClient: EncryptionClientFor declare const nominalClient: EncryptionClient describe('encryptedDynamoDB accepts both client shapes without a cast', () => { diff --git a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts index 4f1f42096..1e2b5fd94 100644 --- a/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts +++ b/packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts @@ -21,9 +21,8 @@ import { beforeAll, describe, expect, it } from 'vitest' import { encryptedDynamoDB } from '@/dynamodb' import { toItemWithEqlPayloads } from '@/dynamodb/helpers' import type { EncryptedDynamoDBInstance } from '@/dynamodb/types' -import type { EncryptionClient } from '@/encryption' -import { EncryptionV3 } from '@/encryption/v3' -import type { JsonValue } from '@/eql/v3' +import { type EncryptionClientFor, EncryptionV3 } from '@/encryption/v3' +import type { AnyV3Table, JsonValue } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -66,7 +65,7 @@ type User = { /** The typed client from `EncryptionV3` — the documented v3 entry point. */ let typedDynamo: EncryptedDynamoDBInstance /** The nominal chainable client, forced into v3 mode. */ -let nominalClient: EncryptionClient +let nominalClient: EncryptionClientFor let nominalDynamo: EncryptedDynamoDBInstance beforeAll(async () => { @@ -362,7 +361,7 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { } let nestedDynamo: EncryptedDynamoDBInstance - let nestedClient: EncryptionClient + let nestedClient: EncryptionClientFor beforeAll(async () => { nestedClient = await Encryption({ @@ -432,8 +431,8 @@ describe('nested attributes with a v3 table', liveSuiteOptions, () => { if (stored.failure) throw new Error(stored.failure.message) const term = await nestedClient.encryptQuery(ssn, { - table: nested as never, - column: nested['profile.ssn'] as never, + table: nested, + column: nested['profile.ssn'], }) if (term.failure) throw new Error(term.failure.message) @@ -477,7 +476,7 @@ describe( }) let renamedDynamo: EncryptedDynamoDBInstance - let renamedClient: EncryptionClient + let renamedClient: EncryptionClientFor beforeAll(async () => { renamedClient = await Encryption({ @@ -517,8 +516,8 @@ describe( expect(decrypted.data).toEqual(original) const term = await renamedClient.encryptQuery('c@d.com', { - table: renamed as never, - column: renamed.emailAddress as never, + table: renamed, + column: renamed.emailAddress, }) if (term.failure) throw new Error(term.failure.message) @@ -580,8 +579,8 @@ describe( if (stored.failure) throw new Error(stored.failure.message) const term = await nominalClient.encryptQuery(email, { - table: users as never, - column: users.email as never, + table: users, + column: users.email, }) if (term.failure) throw new Error(term.failure.message) diff --git a/packages/stack/__tests__/empty-schemas-boundary.test.ts b/packages/stack/__tests__/empty-schemas-boundary.test.ts new file mode 100644 index 000000000..7d67ab2b2 --- /dev/null +++ b/packages/stack/__tests__/empty-schemas-boundary.test.ts @@ -0,0 +1,100 @@ +/** + * Where the compile-time non-emptiness guard stops, and the runtime one starts. + * + * A-4 widened `Encryption`'s v3 overload from a non-empty TUPLE to any array, + * moving the guard onto the `schemas` property via + * `NonEmptyV3 = S['length'] extends 0 ? never : S`. That guard fires only + * when the argument's TYPE has a statically known length of `0`. Once the type + * widens to `AnyV3Table[]` — a shared module export, a push-built array, a + * `.filter()` result, or a spread of any of those — `S['length']` is `number`, + * `number extends 0` is false, and the call compiles no matter how many tables + * are actually in it. + * + * So the widening deliberately traded a compile error for a runtime one on + * exactly the forms it exists to enable. `skills/stash-encryption` documents + * that boundary, and this pins the runtime half of it: the guard must reject an + * empty set BEFORE any FFI client is constructed, on every entry point. + * + * The compile-time half is pinned in `encryption-overloads.test-d.ts`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AnyV3Table } from '@/eql/v3' +import { Encryption } from '@/index' +import { encryptedColumn, encryptedTable } from '@/schema' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' + +const EMPTY_SCHEMAS = /At least one encryptedTable must be provided/ + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('empty schema sets are refused at runtime', () => { + // The forms below all COMPILE — that is the point. Each is a shape the A-4 + // widening admits, and each is empty at runtime. + it('rejects a shared array annotated AnyV3Table[] that is empty', async () => { + const shared: AnyV3Table[] = [] + + await expect(Encryption({ schemas: shared })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('rejects a ReadonlyArray that is empty', async () => { + const frozen: ReadonlyArray = [] + + await expect(Encryption({ schemas: frozen })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('rejects a spread of an empty array — a literal at the call site', async () => { + // Spreading erases the tuple length, so even an array literal here carries + // no compile-time non-emptiness. This is the form most likely to be read as + // "a literal, therefore checked". + const src: AnyV3Table[] = [] + + await expect(Encryption({ schemas: [...src] })).rejects.toThrow( + EMPTY_SCHEMAS, + ) + }) + + it('rejects an array emptied by a filter', async () => { + const all: AnyV3Table[] = [] + + await expect( + Encryption({ schemas: all.filter(() => false) }), + ).rejects.toThrow(EMPTY_SCHEMAS) + }) + + // The v2 builders reach the same guard — it predates the v3 typed client and + // is not part of the overload machinery. + it('rejects an empty v2 schema set', async () => { + const v2: Array> = [] + + await expect(Encryption({ schemas: v2 })).rejects.toThrow(EMPTY_SCHEMAS) + }) + + it('refuses before constructing the FFI client, so no credentials are needed', async () => { + const shared: AnyV3Table[] = [] + + await expect(Encryption({ schemas: shared })).rejects.toThrow(EMPTY_SCHEMAS) + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + // Control: the same call shape with one table gets past the guard, proving the + // assertions above fail on emptiness rather than on the array form itself. + it('accepts a non-empty array of the same widened type', async () => { + const shared: AnyV3Table[] = [] + shared.push( + encryptedTable('users', { + email: encryptedColumn('email'), + }) as unknown as AnyV3Table, + ) + + await expect(Encryption({ schemas: shared })).resolves.toBeDefined() + expect(ffi.newClient).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts index 981845eb6..2162b359a 100644 --- a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts +++ b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts @@ -19,6 +19,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' import { LockContext } from '@/identity' import { Encryption } from '@/index' @@ -53,8 +55,8 @@ const usersV3 = encryptedTableV3('users_v3', { // biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union const failure = (result: any) => result.failure -let clientV2: Awaited> -let clientV3: Awaited> +let clientV2: EncryptionClient +let clientV3: EncryptionClientFor beforeEach(async () => { vi.clearAllMocks() @@ -66,8 +68,13 @@ beforeEach(async () => { clientV3 = await Encryption({ schemas: [usersV3] }) }) -const clientFor = (variant: 'v2' | 'v3') => - variant === 'v2' ? clientV2 : clientV3 +// The two clients have different `encrypt` signatures — `clientV3` pins the +// plaintext to each column's domain — so the shared `describe.each` call below +// cannot resolve against their union. The guards under test are runtime value +// checks that predate both surfaces and behave identically on each, so the +// dispatch (and only the dispatch) erases to the nominal shape. +const clientFor = (variant: 'v2' | 'v3'): EncryptionClient => + variant === 'v2' ? clientV2 : (clientV3 as unknown as EncryptionClient) describe.each([ ['v2', { column: users.score, table: users }], diff --git a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts index bc99fcb4f..d65e791ed 100644 --- a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts +++ b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts @@ -1,11 +1,10 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { createMockLockContext, expectFailure, unwrapResult } from './fixtures' -type EncryptionClient = Awaited> - const documents = encryptedTable('documents', { metadata: types.Json('metadata'), }) @@ -21,10 +20,19 @@ function expectContainment(value: unknown): void { } describe('encryptQuery with searchableJson', () => { + // NOTE: this suite holds the client through the NOMINAL surface on purpose. + // The typed client derives `encryptQuery`'s plaintext from the column's domain, + // so every query type on a `types.Json()` column is typed `JsonDocument` — but + // the searchable-JSON query types take a JSONPath string, a `{ path, value }` + // pair, or a bare scalar. This file exercises exactly those, so typing it + // against the typed client would mean casting away the argument type at every + // call and hiding the gap. Cast once, here, where it is visible and explained. let client: EncryptionClient beforeAll(async () => { - client = await Encryption({ schemas: [documents] }) + client = (await Encryption({ + schemas: [documents], + })) as unknown as EncryptionClient }) it('infers a selector hash for string plaintext', async () => { diff --git a/packages/stack/__tests__/encrypt-query-stevec.test.ts b/packages/stack/__tests__/encrypt-query-stevec.test.ts index 54d9f1396..894592878 100644 --- a/packages/stack/__tests__/encrypt-query-stevec.test.ts +++ b/packages/stack/__tests__/encrypt-query-stevec.test.ts @@ -1,11 +1,10 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import { expectFailure, unwrapResult } from './fixtures' -type EncryptionClient = Awaited> - const documents = encryptedTable('documents', { metadata: types.Json('metadata'), }) @@ -27,10 +26,20 @@ function expectOrderingTerm(value: unknown): void { } describe('encryptQuery with protect-ffi 0.30 SteVec operations', () => { + // NOTE: this suite holds the client through the NOMINAL surface on purpose. + // The typed client derives `encryptQuery`'s plaintext from the column's domain, + // so every query type on a `types.Json()` column is typed `JsonDocument` — but + // the SteVec query types take a JSONPath string (`steVecSelector`), a + // `{ path, value }` pair (`steVecValueSelector`) or a bare scalar + // (`steVecTerm`). This file exercises exactly those, so typing it against the + // typed client would mean casting away the argument type at every call and + // hiding the gap. Cast once, here, where it is visible and explained. let client: EncryptionClient beforeAll(async () => { - client = await Encryption({ schemas: [documents, plain] }) + client = (await Encryption({ + schemas: [documents, plain], + })) as unknown as EncryptionClient }) it('returns a bare selector hash for a JSONPath', async () => { diff --git a/packages/stack/__tests__/encryption-overloads.test-d.ts b/packages/stack/__tests__/encryption-overloads.test-d.ts index e3afd5929..aae0fc789 100644 --- a/packages/stack/__tests__/encryption-overloads.test-d.ts +++ b/packages/stack/__tests__/encryption-overloads.test-d.ts @@ -16,7 +16,7 @@ import { encryptedTable, type TypedEncryptionClient, } from '@/encryption/v3' -import { types } from '@/eql/v3' +import { type AnyV3Table, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' const users = encryptedTable('users', { @@ -49,11 +49,114 @@ describe('overload selection', () => { Encryption({ schemas: [] }) }) - // S-4: forcing v2 wire over v3 schemas returns the NOMINAL client at runtime - // (the typed client cannot author v3 columns in v2 mode). The types used to - // claim the typed client, so `decryptModel(row, table, lockContext)` compiled - // and then silently dropped `table` and `lockContext`. - it('forcing eqlVersion 2 over v3 schemas yields the nominal client', async () => { + // ...but only when the ARGUMENT'S TYPE has a statically known length of 0. + // `NonEmptyV3` keys off `S['length'] extends 0`, so once the type widens to + // `AnyV3Table[]` the length is `number` and emptiness stops being visible — + // including for a literal at the call site, because a spread erases the tuple. + // These compile ON PURPOSE: rejecting them is what the A-4 tuple constraint + // did, and it broke every non-literal caller. The runtime guard is what + // catches them; `empty-schemas-boundary.test.ts` pins that half, and + // `skills/stash-encryption` documents the split for users. + // A generic passthrough is the shape `EncryptionClientFor` and the skill both + // advertise ("code that is generic over its schemas"), and it is the one form + // a WIDENING change must not break. It compiled before A-4 and must still. + // + // A deferred conditional on the property is what broke it: `S` is assignable + // to `NonEmptyV3` only if it is assignable to BOTH branches, and one branch + // is `never`. The emptiness rejection does not need it — overload 2's + // `AtLeastOneCsTable` rejects `[]` on its own. + it('accepts schemas from a generic wrapper function', async () => { + async function makeTypedClient< + S extends readonly [AnyV3Table, ...AnyV3Table[]], + >(schemas: S) { + return await Encryption({ schemas }) + } + + expectTypeOf(await makeTypedClient([users])).toEqualTypeOf< + TypedEncryptionClient + >() + + // A wrapper generic over a LOOSE `readonly AnyV3Table[]` is still rejected, + // here and on the pre-A-4 signature alike. That is the honest answer rather + // than a gap: such an `S` admits `readonly []`, so the wrapper cannot + // promise what `Encryption` requires. Constrain it to a non-empty tuple, as + // above, and it compiles. + async function makeLooseClient( + schemas: S, + ) { + // @ts-expect-error - a loose `S` cannot prove it is non-empty + return await Encryption({ schemas }) + } + void makeLooseClient + }) + + it('accepts arrays whose type cannot prove non-emptiness', () => { + const shared: AnyV3Table[] = [] + expectTypeOf(Encryption).toBeCallableWith({ schemas: shared }) + + const frozen: ReadonlyArray = [] + expectTypeOf(Encryption).toBeCallableWith({ schemas: frozen }) + + expectTypeOf(Encryption).toBeCallableWith({ schemas: [...shared] }) + expectTypeOf(Encryption).toBeCallableWith({ + schemas: shared.filter(() => false), + }) + }) + + // A-4: closing S-6 with a non-empty TUPLE constraint rejected every schema + // array that is not a literal, which is most real code — a shared module + // export, anything built from introspection, anything `readonly`. The + // non-emptiness check moved to the `schemas` property so these compile again + // while `[]` above still does not. One case per form that was broken. + it('accepts schema arrays that are not literals', async () => { + const shared: AnyV3Table[] = [users] + expectTypeOf(await Encryption({ schemas: shared })).toEqualTypeOf< + TypedEncryptionClient + >() + + // `ReadonlyArray` is the form `@cipherstash/prisma-next` exposes publicly. + const frozen: ReadonlyArray = [users] + expectTypeOf(await Encryption({ schemas: frozen })).toEqualTypeOf< + TypedEncryptionClient + >() + + const built: AnyV3Table[] = [] + built.push(users) + expectTypeOf(await Encryption({ schemas: built })).toEqualTypeOf< + TypedEncryptionClient + >() + + expectTypeOf(await Encryption({ schemas: [...shared] })).toEqualTypeOf< + TypedEncryptionClient + >() + }) + + // The widening must not cost the literal path its precision — that typing is + // the entire reason the typed client exists. `const` inference has to survive. + it('keeps per-column typing on the literal path', async () => { + const client = await Encryption({ schemas: [users] }) + + expectTypeOf(client.encrypt).toBeCallableWith('a@b.com', { + table: users, + column: users.email, + }) + // @ts-expect-error - `email` is a text domain, not a number + client.encrypt(123, { table: users, column: users.email }) + // @ts-expect-error - `createdAt` is a timestamp domain, not a string + client.encrypt('2020-01-01', { table: users, column: users.createdAt }) + }) + + // S-4: `eqlVersion: 2` selects the NOMINAL overload, because the typed client + // cannot author v3 columns in v2 mode. The types used to claim the typed + // client, so `decryptModel(row, table, lockContext)` compiled and then + // silently dropped `table` and `lockContext`. + // + // Over an all-v3 schema set this combination is now REJECTED AT RUNTIME + // (#772 review, finding 8) — v2 wire into `eql_v3_*` columns is a + // contradiction, and `EncryptionV3` used to force `eqlVersion: 3` precisely + // to stop it. This assertion therefore records overload resolution only; the + // call itself throws. `init-strategy.test.ts` pins the throw. + it('forcing eqlVersion 2 selects the nominal overload (and is refused at runtime for v3 schemas)', async () => { const client = await Encryption({ schemas: [users], config: { eqlVersion: 2 }, @@ -77,16 +180,6 @@ describe('overload selection', () => { }) describe('naming the client type', () => { - // S-2: `ReturnType` reads the LAST overload, so this idiom resolves to the - // nominal client no matter what schemas you pass. Pinned rather than fixed — - // overload order cannot satisfy both forms — so the surprise is documented and - // cannot change silently. - it('ReturnType resolves to the NOMINAL client', () => { - expectTypeOf< - Awaited> - >().toEqualTypeOf() - }) - it('EncryptionClientFor names the typed client for a v3 tuple', async () => { const client: EncryptionClientFor = await Encryption({ schemas: [users] }) @@ -95,10 +188,39 @@ describe('naming the client type', () => { >() }) + // A-6: the form generic code needs — an integration adapter that builds its + // table per test family cannot name a tuple. This must track `Encryption`'s + // own constraint: while `EncryptionClientFor` still required a non-empty + // TUPLE it fell through to the nominal client here, silently handing the + // wrong type to exactly the callers the widening above exists to serve. + it('EncryptionClientFor names the typed client for a loose v3 array', () => { + expectTypeOf>().toEqualTypeOf< + TypedEncryptionClient + >() + }) + it('EncryptionClientFor falls back to the nominal client', () => { expectTypeOf< EncryptionClientFor >().toEqualTypeOf() + + // `never extends X` is true, so an empty tuple satisfies "every element is + // a v3 table" — the emptiness arm has to be checked inside the v3 branch. + expectTypeOf< + EncryptionClientFor + >().toEqualTypeOf() + }) + + // S-2: `ReturnType` reads the LAST overload, so this idiom resolves to the + // nominal client no matter what schemas you pass. Overload order cannot + // satisfy both forms — putting the nominal signature first mis-resolves v3 + // schemas instead, because a v3 table structurally satisfies `BuildableTable`. + // `EncryptionClientFor` above is the supported idiom; this pins the trap so it + // cannot start silently resolving differently. + it('ReturnType resolves to the NOMINAL client', () => { + expectTypeOf< + Awaited> + >().toEqualTypeOf() }) }) diff --git a/packages/stack/__tests__/init-strategy.test.ts b/packages/stack/__tests__/init-strategy.test.ts index 1b29b4359..b9357193d 100644 --- a/packages/stack/__tests__/init-strategy.test.ts +++ b/packages/stack/__tests__/init-strategy.test.ts @@ -228,8 +228,23 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('lets an explicit eqlVersion 2 override v3 auto-detection (migration escape hatch)', async () => { - await Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }) + // #772 review, finding 8. This used to be honoured, and the resulting client + // wrote `eql_v2_encrypted` payloads into `eql_v3_*` columns with no + // diagnostic at any layer — the type surface types it as the nominal client, + // which matches what the runtime returns, so nothing disagreed loudly enough + // to notice. + it('rejects an explicit eqlVersion 2 over a v3 schema set before constructing the FFI client', async () => { + await expect( + Encryption({ schemas: [v3Table()], config: { eqlVersion: 2 } }), + ).rejects.toThrow(/entirely EQL v3/) + + expect(ffi.newClient).not.toHaveBeenCalled() + }) + + // The escape hatch survives where it is actually used: minting v2 wire from + // a v2 schema set, which is how the v2-read-compat fixtures are produced. + it('still honours an explicit eqlVersion 2 for a v2 schema set', async () => { + await Encryption({ schemas: [users], config: { eqlVersion: 2 } }) expect(lastNewClientOpts().eqlVersion).toBe(2) }) @@ -254,17 +269,20 @@ describe('Encryption config.eqlVersion', () => { expect(lastNewClientOpts().eqlVersion).toBe(3) }) - it('EncryptionV3 is a deprecated alias of Encryption — it honours an explicit eqlVersion identically', async () => { - // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it no - // longer independently pins the wire format: an explicit `eqlVersion: 2` over - // a v3 schema set is honoured exactly as `Encryption` honours it (the - // migration escape hatch above). A v2-mode client still cannot encrypt v3 - // concrete-type columns — this only pins the wire flag reaching the FFI. - await EncryptionV3({ - schemas: [v3Table() as never], - config: { eqlVersion: 2 }, - }) + it('EncryptionV3 rejects an explicit eqlVersion 2 identically', async () => { + // Post-collapse `EncryptionV3` IS `Encryption` (a deprecated alias), so it + // no longer independently pins the wire format. The invariant it used to + // enforce by forcing `eqlVersion: 3` now lives in `resolveEqlVersion`, so + // the outcome for a caller upgrading from the old `EncryptionV3` is the + // same in the case that matters: the contradiction is refused rather than + // silently building a v2-wire client over v3 concrete domains. + await expect( + EncryptionV3({ + schemas: [v3Table() as never], + config: { eqlVersion: 2 }, + }), + ).rejects.toThrow(/entirely EQL v3/) - expect(lastNewClientOpts().eqlVersion).toBe(2) + expect(ffi.newClient).not.toHaveBeenCalled() }) }) diff --git a/packages/stack/__tests__/json-protect.test.ts b/packages/stack/__tests__/json-protect.test.ts index ea55f4213..34e3eb2b1 100644 --- a/packages/stack/__tests__/json-protect.test.ts +++ b/packages/stack/__tests__/json-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' @@ -33,7 +34,7 @@ type User = { } } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/lock-context-wiring.test.ts b/packages/stack/__tests__/lock-context-wiring.test.ts index cf4b967cb..9a578c014 100644 --- a/packages/stack/__tests__/lock-context-wiring.test.ts +++ b/packages/stack/__tests__/lock-context-wiring.test.ts @@ -44,6 +44,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClient } from '@/encryption' const users = encryptedTable('users', { email: encryptedColumn('email').equality(), @@ -78,7 +79,7 @@ function unwrap(result: any) { // biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] -let client: Awaited> +let client: EncryptionClient beforeEach(async () => { vi.clearAllMocks() diff --git a/packages/stack/__tests__/number-protect.test.ts b/packages/stack/__tests__/number-protect.test.ts index 82222377a..8bc084982 100644 --- a/packages/stack/__tests__/number-protect.test.ts +++ b/packages/stack/__tests__/number-protect.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it, test } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedField, encryptedTable } from '@/schema' @@ -29,7 +30,7 @@ type User = { } } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/protect-ops.test.ts b/packages/stack/__tests__/protect-ops.test.ts index df9c40da4..c92f4595e 100644 --- a/packages/stack/__tests__/protect-ops.test.ts +++ b/packages/stack/__tests__/protect-ops.test.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClient } from '@/encryption' import { LockContext } from '@/identity' import { Encryption } from '@/index' import { encryptedColumn, encryptedTable } from '@/schema' @@ -18,7 +19,7 @@ type User = { number?: number } -let protectClient: Awaited> +let protectClient: EncryptionClient beforeAll(async () => { protectClient = await Encryption({ diff --git a/packages/stack/__tests__/resolve-eql-version.test.ts b/packages/stack/__tests__/resolve-eql-version.test.ts index e2e93f0ce..2ec1d0c5c 100644 --- a/packages/stack/__tests__/resolve-eql-version.test.ts +++ b/packages/stack/__tests__/resolve-eql-version.test.ts @@ -86,14 +86,47 @@ describe('resolveEqlVersion — legacy v2 searchable JSON', () => { }) describe('resolveEqlVersion — explicit config.eqlVersion', () => { - it('honours an explicit 2 over v3 schemas, for minting v2 wire during a migration', () => { - expect(resolveEqlVersion([usersV3], 2)).toBe(2) + // #772 review, finding 8. `EncryptionV3` used to force `eqlVersion: 3` over + // whatever the caller passed, with a comment saying it existed to stop + // exactly this. It is now a bare alias of `Encryption`, so the override is + // gone and nothing rejected the combination — the client built v2-wire over + // v3 concrete domains, which writes an `eql_v2_encrypted` payload into an + // `eql_v3_*` column. `resolveEqlVersion` already throws for a mixed set and + // for v2 SteVec; this was the one contradiction it let through. + it('rejects an explicit 2 over an all-v3 schema set', () => { + expect(() => resolveEqlVersion([usersV3], 2)).toThrow( + /cannot emit EQL v2 wire for a schema set that is entirely EQL v3/, + ) + }) + + it('rejects an explicit 2 over several v3 tables', () => { + expect(() => resolveEqlVersion([usersV3, ordersV3], 2)).toThrow( + /entirely EQL v3/, + ) + }) + + // The escape hatch itself survives, and this is the shape that actually uses + // it: `integration/shared/v2-decrypt-compat.integration.test.ts` mints its v2 + // fixtures from a v2 schema set. Nothing mints v2 wire from a v3 table. + it('still honours an explicit 2 over a v2 schema set', () => { + expect(resolveEqlVersion([usersV2], 2)).toBe(2) }) it('honours an explicit 3 over a v2 schema set', () => { expect(resolveEqlVersion([usersV2], 3)).toBe(3) }) + it('honours an explicit 3 over a v3 schema set', () => { + expect(resolveEqlVersion([usersV3], 3)).toBe(3) + }) + + // An empty set never reaches here through `Encryption` — the caller throws + // first — but the function is exported, so pin that it does not invent a + // wire version for a set with no evidence in it either way. + it('does not reject an explicit 2 for an empty schema set', () => { + expect(resolveEqlVersion([], 2)).toBe(2) + }) + it('does not let an explicit version rescue a mixed schema set', () => { // Mixing is unfixable by declaration: the two generations target different // column types, so no single wire format serves both. diff --git a/packages/stack/__tests__/typed-client-init-wire-version.test.ts b/packages/stack/__tests__/typed-client-init-wire-version.test.ts new file mode 100644 index 000000000..e535cc001 --- /dev/null +++ b/packages/stack/__tests__/typed-client-init-wire-version.test.ts @@ -0,0 +1,96 @@ +/** + * The typed client's `init` passthrough must not reopen the door A-8 closed. + * + * A-7 added `init` to the typed EQL v3 client so that a caller holding it + * through its declared `EncryptionClient` type could not hit + * `TypeError: init is not a function`. But `EncryptionClient.init` takes its own + * `eqlVersion?: 2 | 3`, and forwards `undefined` straight to `newClient`, where + * the FFI's default is EQL **v2**. `resolveEqlVersion` — which refuses exactly + * that combination — is only ever consulted by `Encryption`, never by `init`. + * + * So a bare passthrough lets a v3 client be silently re-initialised into v2 wire + * while keeping the typed v3 surface: the same contradiction A-8 rejects at + * construction, reachable one method call later. + * + * const client = await Encryption({ schemas: [users] }) // eqlVersion: 3 + * await client.init({ encryptConfig }) // eqlVersion: undefined -> v2 + * + * `typed-client-nominal-parity.test.ts` cannot catch this: it stubs `init` out + * entirely and asserts only that it was called. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), +})) + +import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/encryption/v3' +import { Encryption } from '@/index' +import { buildEncryptConfig } from '@/schema' + +const users = encryptedTable('users', { email: types.TextSearch('email') }) + +// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args +const newClientCalls = () => (ffi.newClient as any).mock.calls +const lastEqlVersion = () => newClientCalls().at(-1)[0].eqlVersion + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('typed client init keeps the v3 wire format', () => { + it('constructs at eqlVersion 3', async () => { + await Encryption({ schemas: [users] }) + + expect(lastEqlVersion()).toBe(3) + }) + + it('stays at eqlVersion 3 when re-initialised without one', async () => { + const client = await Encryption({ schemas: [users] }) + expect(lastEqlVersion()).toBe(3) + + await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + }) + + // Without pinning, this is `undefined` — the FFI's v2 default — leaving a + // typed v3 surface over a client emitting v2 payloads into eql_v3_* columns. + expect(newClientCalls()).toHaveLength(2) + expect(lastEqlVersion()).toBe(3) + }) + + it('refuses an explicit eqlVersion 2 on re-init, as construction does', async () => { + const client = await Encryption({ schemas: [users] }) + + // `Encryption` throws for this combination, but `init` declares + // `Promise>` — the Result shape is contract, so this refuses with + // a failure rather than rejecting. Either way it never reaches the FFI. + const result = await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + eqlVersion: 2, + }) + + expect(result.failure?.message).toMatch(/eqlVersion 2|eql_v3_\* domains/) + expect(newClientCalls()).toHaveLength(1) + }) + + it('returns the TYPED client, not the bare nominal one', async () => { + const client = await Encryption({ schemas: [users] }) + + const result = await (client as unknown as EncryptionClient).init({ + encryptConfig: buildEncryptConfig(users), + }) + + // `EncryptionClient.init` resolves `{ data: this }` — the nominal client. + // Reassigning from it (`client = (await client.init(c)).data`) is the + // natural idiom, and it must not silently drop the typed surface. + if (result.failure) throw new Error(result.failure.message) + expect( + typeof (result.data as { encryptQuery?: unknown }).encryptQuery, + ).toBe('function') + expect(result.data).toBe(client) + }) +}) diff --git a/packages/stack/__tests__/typed-client-nominal-parity.test.ts b/packages/stack/__tests__/typed-client-nominal-parity.test.ts new file mode 100644 index 000000000..dc0bcc1c2 --- /dev/null +++ b/packages/stack/__tests__/typed-client-nominal-parity.test.ts @@ -0,0 +1,82 @@ +/** + * Runtime parity between the two clients `Encryption` can return. + * + * `Encryption` decides its return value with two discriminators that can + * disagree: overload resolution reads the `config` argument, while the runtime + * reads the `schemas` argument (`encryption/index.ts`, the `isV3Only && + * eqlVersion === 3` branch). Hoisting a config into a `ClientConfig`-typed + * variable is enough to split them — `ClientConfig.eqlVersion` is `2 | 3`, which + * is not assignable to the v3 overload's `eqlVersion?: 3`, so the call selects + * the NOMINAL overload while the runtime still hands back the TYPED client. + * + * No type-level design closes that: the runtime inspects values, the type + * inspects a static type that can be widened away. It ends when the EQL v2 + * removal collapses the two clients into one (#637). Until then the mismatch + * must not be able to crash, which means every member of `EncryptionClient` has + * to exist on the typed client at runtime. + */ +import { describe, expect, it, vi } from 'vitest' +import { Encryption, EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/encryption/v3' +import type { ClientConfig } from '@/types' + +const users = encryptedTable('users', { email: types.TextSearch('email') }) + +/** + * Build a client without credentials by stubbing `init` to resolve to itself — + * `Encryption` only needs a successful `Result` to reach its return branch. + */ +async function buildWithStubbedInit(config: ClientConfig) { + const spy = vi + .spyOn(EncryptionClient.prototype, 'init') + .mockImplementation(async function (this: EncryptionClient) { + return { data: this } + } as never) + try { + return await Encryption({ schemas: [users], config }) + } finally { + spy.mockRestore() + } +} + +describe('typed client / nominal client runtime parity', () => { + it('a ClientConfig-typed variable types as nominal but returns the typed client', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + // The static type here is `EncryptionClient`. The runtime disagrees. + expect('encryptQuery' in client).toBe(true) + expect(client).not.toBeInstanceOf(EncryptionClient) + }) + + it('exposes every EncryptionClient member, so the mismatch cannot crash', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + const nominalMembers = Object.getOwnPropertyNames( + EncryptionClient.prototype, + ).filter((name) => name !== 'constructor') + + // `init` was the only member missing, which turned the type/runtime + // mismatch above into `TypeError: client.init is not a function` for + // anything holding the client through its declared `EncryptionClient` type. + const missing = nominalMembers.filter( + (name) => typeof (client as Record)[name] !== 'function', + ) + expect(missing).toEqual([]) + }) + + it('delegates init to the underlying client', async () => { + const config: ClientConfig = {} + const client = await buildWithStubbedInit(config) + + const spy = vi + .spyOn(EncryptionClient.prototype, 'init') + .mockResolvedValue({ data: {} } as never) + + await (client as unknown as EncryptionClient).init({} as never) + expect(spy).toHaveBeenCalledTimes(1) + + spy.mockRestore() + }) +}) diff --git a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts index e7456b947..0024902d6 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts @@ -37,6 +37,7 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('users', { @@ -70,7 +71,7 @@ const lastOpts = (fn: any) => fn.mock.calls.at(-1)[1] // `Encryption` returns the typed client directly for an all-v3 schema set (the // collapse of `EncryptionV3`), so there is no separate `typedClient` wrap here. -let typed: Awaited>> +let typed: EncryptionClientFor let prevWorkspaceCrn: string | undefined beforeEach(async () => { diff --git a/packages/stack/dist-types/README.md b/packages/stack/dist-types/README.md new file mode 100644 index 000000000..69e727c9c --- /dev/null +++ b/packages/stack/dist-types/README.md @@ -0,0 +1,15 @@ +# Declaration-emit contract + +These files typecheck against `../dist`, not `../src`. + +Every other type test in this package imports `@/…`, which resolves to source. +That is the right default — it keeps the tests fast and independent of a build — +but it means nothing checked what customers actually consume, and a defect lived +in the published `.d.ts` for the whole rc series because of it: +`EncryptedV3Column`'s domain parameter `D` was recoverable in source and not in +the emitted declarations, so typed `encryptQuery` was uncallable for every column +against the published package. + +Anything whose correctness depends on what `tsc` *emits* — rather than on what +the source says — belongs here. Requires a build first; wired into CI as +`test:types:dist`. diff --git a/packages/stack/dist-types/encrypt-query.ts b/packages/stack/dist-types/encrypt-query.ts new file mode 100644 index 000000000..a8b0ac83d --- /dev/null +++ b/packages/stack/dist-types/encrypt-query.ts @@ -0,0 +1,69 @@ +/** + * Typed `encryptQuery` must be callable against the BUILT package. + * + * `PlaintextForColumn` / `QueryTypesForColumn` recover a column's domain with + * `C extends EncryptedV3Column`, which needs `D` to appear bare in the + * instance type. Its only bare site in source is a PRIVATE field, and `tsc` + * strips private member types on declaration emit — so in the published `.d.ts` + * the inference fell back to the `V3DomainDefinition` constraint and every + * derived helper collapsed. `QueryTypesForColumn` became `never`, which made + * `QueryableColumnsOf` `never`, which typed the plaintext `never`: + * + * client.encryptQuery('a@b.com', { table: users, column: users.email }) + * // TS2345: Argument of type '"a@b.com"' is not assignable to type 'never' + * + * `encrypt` was unaffected (it resolves through `ColumnsOf`), so a smoke test + * of the built types would have missed it. Assert the query path explicitly. + */ + +import { Encryption, encryptedTable, types } from '../dist/encryption/v3.js' +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '../dist/eql/v3/index.js' + +// The two helpers, on the concrete column class, straight from the emitted types. +const plaintext: string = + null as unknown as PlaintextForColumn +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + // The narrowing must survive emit too, or the assertions above pass + // vacuously. Each directive sits on the exact line the error is reported at — + // `@ts-expect-error` only covers the following line, and the formatter is free + // to split these calls across several. + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) + client.encryptQuery('x', { + table: users, + column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/node16/encrypt-query.cts b/packages/stack/dist-types/node16/encrypt-query.cts new file mode 100644 index 000000000..32b48e7b1 --- /dev/null +++ b/packages/stack/dist-types/node16/encrypt-query.cts @@ -0,0 +1,67 @@ +/** + * The CommonJS declaration surface — `require` → `dist/**\/*.d.cts`. + * + * The sibling `../encrypt-query.ts` gate reaches into `../dist/*.js` by relative + * path under `moduleResolution: bundler`, so it never consults the `exports` + * map and only ever sees the ESM `.d.ts` set. tsup emits the `.d.cts` set in a + * separate pass, and `package.json` routes `require` at it. A defect confined to + * that pass would ship to every CJS consumer with nothing here to catch it. + * + * This file is `.cts`, so TypeScript treats it as CommonJS and resolves + * `@cipherstash/stack/v3` through the `require` condition. Importing BY PACKAGE + * NAME (Node self-reference, which works because `package.json` has both a + * `name` and an `exports` map) is the point: it exercises the conditions a + * customer's resolver walks, not a path we happen to know. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/eql/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +// The two helpers, on the concrete column class, straight from the emitted +// `.d.cts`. These collapse to `never` / a wide union if the domain parameter is +// not recoverable after declaration emit. +const plaintext: string = + null as unknown as PlaintextForColumn +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + // The narrowing must survive emit too, or the assertions above pass + // vacuously. Each directive sits on the exact line the error is reported at. + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) + client.encryptQuery('x', { + table: users, + column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/node16/encrypt-query.mts b/packages/stack/dist-types/node16/encrypt-query.mts new file mode 100644 index 000000000..ba8ac246f --- /dev/null +++ b/packages/stack/dist-types/node16/encrypt-query.mts @@ -0,0 +1,66 @@ +/** + * The ESM declaration surface under NODE16 resolution — `import` → `*.d.ts`. + * + * `../encrypt-query.ts` already covers the `.d.ts` set, but through a relative + * path under `moduleResolution: bundler`. That combination cannot catch a broken + * `exports` map, a missing `types` condition, or a subpath that resolves under a + * bundler but not under Node's own algorithm — the resolution mode most server + * consumers actually use. + * + * Same assertions as the `.cts` twin; the only difference is which condition the + * resolver takes, which is exactly what is under test. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/eql/v3' +import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3' + +const plaintext: string = + null as unknown as PlaintextForColumn +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn +void plaintext +void queryTypes + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + age: types.IntegerOrd('age'), +}) + +// The domain carrier that makes the inference above possible must not be part +// of the public surface. There is no `stripInternal` in this build, so a plainly +// named property would be reachable here — typed `D | undefined`, and always +// `undefined` at runtime. Keyed by a non-exported `unique symbol`, it is not. +// @ts-expect-error - the phantom domain carrier is not nameable by consumers +void users.email.__domain + +export async function callable() { + const client = await Encryption({ schemas: [users] }) + + client.encrypt('a@b.com', { table: users, column: users.email }) + client.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }) + client.encryptQuery(30, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + + client.encryptQuery( + // @ts-expect-error - `email` is a text domain, so the plaintext is a string + 123, + { table: users, column: users.email }, + ) + client.encryptQuery('x', { + table: users, + column: users.email, + // @ts-expect-error - `searchableJson` is not a capability of text_search + queryType: 'searchableJson', + }) +} diff --git a/packages/stack/dist-types/node16/tsconfig.json b/packages/stack/dist-types/node16/tsconfig.json new file mode 100644 index 000000000..034b0ae3f --- /dev/null +++ b/packages/stack/dist-types/node16/tsconfig.json @@ -0,0 +1,26 @@ +{ + // The sibling gate resolves `../dist/*.js` by RELATIVE path under + // `moduleResolution: bundler`, which never consults the `exports` map. This + // one resolves `@cipherstash/stack/*` BY PACKAGE NAME under `node16` — the + // way a customer does — so the conditions in `exports` select the artifact. + // + // The package is `"type": "module"`, so the file extension picks the mode: + // `.cts` is CommonJS and takes the `require` branch (`*.d.cts`), `.mts` is + // ESM and takes the `import` branch (`*.d.ts`). Both must be probed: tsup + // emits the two declaration sets in separate passes, and a third for + // `wasm-inline`. + // + // No `customConditions` here: `node` is implicit under node16, and this + // package's own `exports` map does not branch on it. + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["**/*.cts", "**/*.mts"] +} diff --git a/packages/stack/dist-types/node16/wasm-inline.mts b/packages/stack/dist-types/node16/wasm-inline.mts new file mode 100644 index 000000000..947e2b4ca --- /dev/null +++ b/packages/stack/dist-types/node16/wasm-inline.mts @@ -0,0 +1,33 @@ +/** + * The THIRD declaration artifact: `dist/wasm-inline.d.ts`. + * + * `tsup.config.ts` runs a second, independent DTS pass for the wasm-inline + * entry, which inlines its own copy of `EncryptedV3Column` and the helpers that + * invert its domain parameter. Neither the bundler gate nor the `.cts`/`.mts` + * probes above reach it — they resolve `./v3` and `./eql/v3`, which come from + * the first pass. So the entry documented for Workers, Deno, Bun and Supabase + * Edge — the runtimes with the least margin for a broken type — was the one + * artifact nothing typechecked. + * + * `./wasm-inline` is ESM-only in the `exports` map (no `require` branch, by + * design: the inlined WASM blob cannot be `require`d), hence `.mts` and no + * `.cts` twin. + * + * `encryptQuery` is deliberately NOT column-narrowed on this entry + * (`WasmEncryptQueryOptions.column` is wide), so this asserts the helpers + * directly rather than through a call — they are what carries the domain, and + * what collapses if the phantom carrier is lost on emit. + */ + +import type { + EncryptedTextSearchColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from '@cipherstash/stack/wasm-inline' + +const plaintext: string = + null as unknown as PlaintextForColumn +const queryTypes: 'equality' | 'orderAndRange' | 'freeTextSearch' = + null as unknown as QueryTypesForColumn +void plaintext +void queryTypes diff --git a/packages/stack/dist-types/tsconfig.json b/packages/stack/dist-types/tsconfig.json new file mode 100644 index 000000000..4e52e3b05 --- /dev/null +++ b/packages/stack/dist-types/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "customConditions": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + // `node16/` is the same contract checked under Node's own resolution, by + // package name rather than relative path. It needs `module: node16`, so it + // carries its own tsconfig and must not be swept up by this one. + "exclude": ["node16"], + "include": ["**/*.ts"] +} diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index 5009a4a74..6f4bfa709 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -18,6 +18,7 @@ import { OidcFederationStrategy } from '@cipherstash/auth' import { unwrapResult } from '@cipherstash/test-kit' import { clerkJwtProvider } from '@cipherstash/test-kit/integration-clerk' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const users = encryptedTable('v3_identity_live_users', { @@ -35,7 +36,7 @@ const INFRA_FAULT = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|timed? ?out|network error/i describe('v3 typed client identity-aware operations (live)', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { const crn = process.env.CS_WORKSPACE_CRN @@ -114,7 +115,7 @@ describe('v3 typed client identity-aware operations (live)', () => { // test on a Clerk outage or a rotated `CLERK_MACHINE_TOKEN` — for behaviour it // never exercises. describe('v3 typed client audit metadata (live)', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { client = await EncryptionV3({ schemas: [users] }) diff --git a/packages/stack/integration/shared/json-crypto.integration.test.ts b/packages/stack/integration/shared/json-crypto.integration.test.ts index dd66d412b..0a54d708a 100644 --- a/packages/stack/integration/shared/json-crypto.integration.test.ts +++ b/packages/stack/integration/shared/json-crypto.integration.test.ts @@ -7,6 +7,7 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const docs = encryptedTable('v3_json_docs', { @@ -14,7 +15,7 @@ const docs = encryptedTable('v3_json_docs', { }) describe('v3 typed client — encrypted JSONB round-trip', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { client = await EncryptionV3({ schemas: [docs] }) diff --git a/packages/stack/integration/shared/match-bloom.integration.test.ts b/packages/stack/integration/shared/match-bloom.integration.test.ts index 95fb70115..7b2543627 100644 --- a/packages/stack/integration/shared/match-bloom.integration.test.ts +++ b/packages/stack/integration/shared/match-bloom.integration.test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3 } from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' import { encryptedTable, types } from '@/eql/v3' /** @@ -36,7 +38,7 @@ const docs = encryptedTable('match_bloom_probe', { const HAYSTACK = 'ada@example.com' async function bloomOf( - client: Awaited>, + client: EncryptionClientFor, value: string, ) { const result = await client.encrypt(value, { column: docs.bio, table: docs }) diff --git a/packages/stack/integration/shared/matrix-bulk.integration.test.ts b/packages/stack/integration/shared/matrix-bulk.integration.test.ts index 0ef71beea..e9123ae54 100644 --- a/packages/stack/integration/shared/matrix-bulk.integration.test.ts +++ b/packages/stack/integration/shared/matrix-bulk.integration.test.ts @@ -7,6 +7,7 @@ */ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' +import type { EncryptionClientFor } from '@/encryption/v3' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' const people = encryptedTable('v3_bulk_people', { @@ -15,7 +16,7 @@ const people = encryptedTable('v3_bulk_people', { }) describe('v3 typed client bulk-at-scale (live)', () => { - let client: Awaited>> + let client: EncryptionClientFor beforeAll(async () => { client = await EncryptionV3({ schemas: [people] }) diff --git a/packages/stack/integration/shared/matrix-crypto.integration.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts index 03d9fcd0d..8358d4c2d 100644 --- a/packages/stack/integration/shared/matrix-crypto.integration.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -26,7 +26,12 @@ import { V3_MATRIX, } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' +import { + type EncryptionClientFor, + EncryptionV3, + encryptedTable, +} from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' // `as const satisfies Record<...>` gives `V3_MATRIX` a narrower type than // `Record` (rows that omit the optional @@ -70,7 +75,7 @@ const errorCases = domains.flatMap(([t, spec]) => ) describe('v3 matrix live round-trip (all domains × samples)', () => { - let client: Awaited> + let client: EncryptionClientFor let encrypted: Array> let decrypted: Array> diff --git a/packages/stack/integration/shared/matrix-sql.integration.test.ts b/packages/stack/integration/shared/matrix-sql.integration.test.ts index 3532cdcbc..2fb113887 100644 --- a/packages/stack/integration/shared/matrix-sql.integration.test.ts +++ b/packages/stack/integration/shared/matrix-sql.integration.test.ts @@ -67,7 +67,12 @@ import { } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' +import { + type EncryptionClientFor, + EncryptionV3, + encryptedTable, +} from '@/encryption/v3' +import type { AnyV3Table } from '@/eql/v3' // Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed // with `PostgresError: invalid input syntax for type json` on the dynamic @@ -203,7 +208,7 @@ const comparePlaintext = (a: unknown, b: unknown): number => { type Row = { id: number } -let client: Awaited> +let client: EncryptionClientFor let idA: number let idB: number // Separate run id for the multi-row ordering rows. Kept DISTINCT from @@ -399,7 +404,9 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { const col = slug(eqlType) const column = (table as unknown as Record)[col] as never const encrypted = unwrapResult( - await client.encrypt('', { + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { table: table as never, column, }), @@ -416,7 +423,9 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { const col = slug(eqlType) const column = (table as unknown as Record)[col] as never const encrypted = unwrapResult( - await client.encrypt('', { + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { table: table as never, column, }), diff --git a/packages/stack/integration/shared/schema-pg.integration.test.ts b/packages/stack/integration/shared/schema-pg.integration.test.ts index a8c8992de..152b94801 100644 --- a/packages/stack/integration/shared/schema-pg.integration.test.ts +++ b/packages/stack/integration/shared/schema-pg.integration.test.ts @@ -1,7 +1,7 @@ import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import type { Encrypted } from '@/types' @@ -28,7 +28,9 @@ type InsertedRow = { type EncryptionPayload = postgres.JSONValue -let protectClient: EncryptionClient +let protectClient: EncryptionClientFor< + readonly [typeof table, typeof typedTable] +> async function encryptValue(value: string): Promise { return unwrapResult( @@ -48,7 +50,7 @@ async function encryptValue(value: string): Promise { // term, so which SQL function you call selects which term is compared. async function encryptOperand( value: unknown, - opts: Parameters[1], + opts: Parameters[1], ): Promise { return unwrapResult( await protectClient.encrypt(value as never, opts), diff --git a/packages/stack/integration/shared/schema-v3-client.integration.test.ts b/packages/stack/integration/shared/schema-v3-client.integration.test.ts index a3b31b3a5..80e60e647 100644 --- a/packages/stack/integration/shared/schema-v3-client.integration.test.ts +++ b/packages/stack/integration/shared/schema-v3-client.integration.test.ts @@ -1,6 +1,6 @@ import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' -import type { EncryptionClient } from '@/encryption' +import type { EncryptionClientFor } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' @@ -19,7 +19,7 @@ const users = encryptedTable('schema_v3_client_users', { }) describe('eql_v3 client integration', () => { - let protectClient: EncryptionClient + let protectClient: EncryptionClientFor beforeAll(async () => { protectClient = await Encryption({ schemas: [users] }) diff --git a/packages/stack/package.json b/packages/stack/package.json index 32a167b60..1f3b5b0c5 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -198,6 +198,7 @@ "db:eql-v3:install": "tsx scripts/install-eql-v3.ts", "test": "vitest run", "test:types": "vitest --run --typecheck.only", + "test:types:dist": "tsc --noEmit -p dist-types/tsconfig.json && tsc --noEmit -p dist-types/node16/tsconfig.json", "release": "tsup", "test:integration": "vitest run --config integration/vitest.config.ts" }, diff --git a/packages/stack/src/dynamodb/index.ts b/packages/stack/src/dynamodb/index.ts index a950c8f7c..a19f1a691 100644 --- a/packages/stack/src/dynamodb/index.ts +++ b/packages/stack/src/dynamodb/index.ts @@ -31,10 +31,10 @@ import type { * hand: the operation methods below, when a table is supplied. `encryptedDynamoDB` * itself receives no table, so it cannot check earlier. * - * RESIDUAL GAP: a client explicitly forced to `eqlVersion: 2` over a v3 schema - * set (a deliberate migration path) DOES register the table yet emits v2 wire; - * that is not detectable without a wire-version accessor the client does not - * provide, so it is out of scope here. + * The one case this could not detect — a client forced to `eqlVersion: 2` while + * registering a v3 table, emitting v2 wire for an `eql_v3_*` domain — is now + * refused by `resolveEqlVersion` at client construction, so no such client can + * reach here. */ function assertClientTableVersionMatch( encryptionClient: EncryptedDynamoDBConfig['encryptionClient'], diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 5a47a930a..079ac2b28 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -89,9 +89,10 @@ export const noClientError = () => * columns and the v3 tables target `eql_v3` domains, so no single wire * format serves both. Split them across two clients. * - * An explicit `config.eqlVersion` bypasses version detection (the wire format - * is then unambiguous — e.g. writing v2 wire from a v3 schema set during a - * migration), but mixed schemas and legacy v2 SteVec schemas still throw. + * An explicit `config.eqlVersion` bypasses DETECTION, not validation: mixed + * schemas and legacy v2 SteVec schemas still throw, and so does an explicit `2` + * over an all-v3 set. What survives is `2` over a v2 schema set — minting v2 + * wire during a migration. * * @internal exported for unit-test coverage of the detection matrix. */ @@ -124,6 +125,28 @@ export function resolveEqlVersion( ) } + // An explicit 2 over a set that is ENTIRELY v3 is a contradiction, and a + // silent one: the FFI client emits `eql_v2_encrypted` payloads for columns + // whose Postgres domain is `eql_v3_*`, so the write either trips the domain + // CHECK or lands v2 wire wherever the check is looser. + // + // `EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over + // whatever the caller passed — its comment said so explicitly. It is now a + // bare alias of `Encryption`, so a caller upgrading from + // `EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — which + // was silently corrected and worked — now gets a v2-wire client with no + // diagnostic at any layer (#772 review, finding 8). + // + // The escape hatch itself stays: an explicit 2 over a V2 schema set is how + // `integration/shared/v2-decrypt-compat.integration.test.ts` mints the + // fixtures that prove v2 payloads still decrypt. That is the only shape that + // uses it — nothing mints v2 wire from a v3 table. + if (explicit === 2 && schemas.length > 0 && v3Count === schemas.length) { + throw new Error( + "[encryption]: cannot emit EQL v2 wire for a schema set that is entirely EQL v3 — the payloads would not match the columns' eql_v3_* domains. Drop `config.eqlVersion` to emit v3, or build the client from the EQL v2 schema you actually want to write.", + ) + } + if (explicit !== undefined) { return explicit } @@ -862,19 +885,58 @@ export function __resetStrategyDeprecationWarningForTests(): void { * @see {@link ClientConfig.authStrategy} for the auth strategy field. * @see {@link EncryptionClient} for available methods after initialization. */ +/** + * The schema-tuple guard for {@link Encryption}'s v3 overload. + * + * Resolves to `S` for any non-empty array of v3 tables and to `never` for + * `readonly []`, so `Encryption({ schemas: [] })` stays a compile error while + * every non-literal form (a shared `AnyV3Table[]`, a `ReadonlyArray`, a + * push-built or spread array) still selects this overload. + */ +type NonEmptyV3 = S['length'] extends 0 + ? never + : S + // Overload 1 — v3-typed: an array literal of concrete EQL v3 tables (from // `@cipherstash/stack/v3`) yields the strongly-typed {@link TypedEncryptionClient}, // the collapse of the former `EncryptionV3`. The wire format is forced to v3. // -// The schema tuple is constrained NON-EMPTY: `readonly AnyV3Table[]` admits -// `readonly []`, so `Encryption({ schemas: [] })` type-checked and then threw at -// runtime. The nominal overload has always required at least one table. +// `S` is the ARRAY, not a non-empty tuple. Constraining the type parameter to +// `readonly [AnyV3Table, ...AnyV3Table[]]` — which is how the `[]` case was +// first closed — rejected every form that is not an array literal: a shared +// `export const all: AnyV3Table[]`, the `ReadonlyArray` prisma-next exposes, +// anything push-built or spread. Non-emptiness is enforced on the PROPERTY via +// {@link NonEmptyV3} instead, which leaves `Encryption({ schemas: [] })` a +// compile error without constraining the rest. +// +// `NonEmptyV3` must sit on `schemas` and nowhere else: wrapping the whole +// config in a conditional alias defeats `const` inference, degrading the tuple +// to an array and erasing per-column plaintext typing on the literal path. +// +// It also cannot be the ONLY v3 signature, which is why there are two. A type +// parameter is assignable to a deferred conditional only if it is assignable to +// BOTH branches, and one branch here is `never` — so `NonEmptyV3` rejects +// every caller that is itself generic over its schemas: +// +// async function make(schemas: S) { +// return await Encryption({ schemas }) // TS2769 against `NonEmptyV3` +// } +// +// That shape compiled before A-4 and is the one the `EncryptionClientFor` docs +// point generic code at, so it must keep compiling. The overload below carries +// the non-emptiness in its CONSTRAINT instead of a conditional, which a generic +// `S` can satisfy; the widened one after it then only has to serve arrays that +// are concrete at the call site, where the conditional resolves eagerly. export function Encryption< const S extends readonly [AnyV3Table, ...AnyV3Table[]], >(config: { schemas: S config?: V3ClientConfig }): Promise> +export function Encryption(config: { + schemas: NonEmptyV3 + config?: V3ClientConfig +}): Promise> // Overload 2 — nominal: loose/dynamic schemas (introspection-derived, e.g. // stack-supabase) or EQL v2 tables yield the generation-neutral // {@link EncryptionClient}, which auto-detects its wire version. Declared LAST @@ -923,9 +985,10 @@ export async function Encryption( const isV3Only = schemas.every(hasBuildColumnKeyMap) // Resolve the wire format: an explicit `config.eqlVersion` wins (the retained - // migration escape hatch — e.g. deliberately writing v2 wire from a v3 schema - // set), otherwise it is auto-detected (v3 tables → 3, v2 tables → the FFI's v2 - // default). A mixed v2 + v3 schema set throws inside `resolveEqlVersion`. + // migration escape hatch — `2` over a v2 schema set), otherwise it is + // auto-detected (v3 tables → 3, v2 tables → the FFI's v2 default). A mixed + // v2 + v3 set, and an explicit `2` over an all-v3 set, throw inside + // `resolveEqlVersion`. const eqlVersion = resolveEqlVersion(schemas, clientConfig?.eqlVersion) const result = await client.init({ @@ -940,10 +1003,9 @@ export async function Encryption( } // Return the typed client only when the client is genuinely in EQL v3 mode: an - // all-v3 schema set that resolved to the v3 wire format. A caller that forced - // `eqlVersion: 2` over v3 schemas gets the nominal client for that deliberate, - // low-level v2-wire migration path (the typed client cannot encrypt v3 columns - // in v2 mode anyway). + // all-v3 schema set that resolved to the v3 wire format. The `eqlVersion === 3` + // conjunct is now belt-and-braces: `resolveEqlVersion` refuses an explicit `2` + // over an all-v3 set, so an all-v3 set cannot reach here in v2 mode. if (isV3Only && eqlVersion === 3) { // biome-ignore lint/plugin: the runtime `isV3Only` guard (every schema has // buildColumnKeyMap) proves these are AnyV3Table — the compiler can't see it. diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 3ba2f19ce..cc79cb306 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,3 +1,4 @@ +import type { Result } from '@byteslice/result' import type { AnyV3Table, ColumnsOf, @@ -150,6 +151,36 @@ export interface TypedEncryptionClient { ): BulkEncryptOperation bulkDecrypt(payloads: BulkDecryptPayload): BulkDecryptOperation getEncryptConfig(): ReturnType + + /** + * Re-initialize the underlying client, staying on EQL v3. + * + * @internal Present for runtime parity with {@link EncryptionClient}, not as + * part of the typed authoring surface. `Encryption` picks its return value by + * inspecting the *schemas* while overload resolution inspects the *config*, so + * the two can disagree: a config hoisted into a `ClientConfig`-typed variable + * selects the nominal overload but still yields this client at runtime. Every + * other `EncryptionClient` method already existed here; without `init` that + * mismatch turned into `TypeError: client.init is not a function`. + * + * This is NOT a bare passthrough, because `EncryptionClient.init` would + * otherwise be a way around the guard `Encryption` applies at construction. + * `init` takes its own `eqlVersion`, forwards `undefined` to the FFI (whose + * default is **v2**), and never consults `resolveEqlVersion` — so delegating + * verbatim would let a typed v3 client be re-initialized into v2 wire while + * keeping this v3 surface, the exact contradiction `resolveEqlVersion` + * refuses. The wire version is pinned to `3` and an explicit `2` is rejected. + * + * It also resolves to the TYPED client rather than the underlying nominal one, + * so `client = (await client.init(cfg)).data` — the natural idiom, since + * `EncryptionClient.init` resolves `{ data: this }` — keeps the typed surface + * instead of silently degrading to the nominal one. + */ + init( + config: Omit[0], 'eqlVersion'> & { + eqlVersion?: 3 + }, + ): Promise, EncryptionError>> } /** @@ -353,7 +384,11 @@ export function typedClient( ) as never } - return { + // Annotated rather than `satisfies`, because `init` resolves to `typed` + // itself and a self-referencing initializer has no inferrable type. The + // annotation checks the same shape the `satisfies` did, and the function's + // declared return type is this type anyway, so nothing is widened. + const typed: TypedEncryptionClient = { encrypt: (plaintext, opts) => client.encrypt(plaintext as never, opts as never), encryptQuery, @@ -367,38 +402,72 @@ export function typedClient( bulkEncrypt: (plaintexts, opts) => client.bulkEncrypt(plaintexts, opts), bulkDecrypt: (payloads) => client.bulkDecrypt(payloads), getEncryptConfig: () => client.getEncryptConfig(), - } satisfies TypedEncryptionClient + // See the interface member for why this is not `client.init(config)`: + // `init` bypasses `resolveEqlVersion` and defaults the FFI to v2 wire, so a + // verbatim delegation would reopen the contradiction A-8 closes. Pin v3, + // refuse an explicit 2, and resolve to the typed client so the surface + // survives the round trip. + init: async (config) => { + if (config.eqlVersion !== undefined && config.eqlVersion !== 3) { + return { + failure: { + type: EncryptionErrorTypes.EncryptionError, + message: + "[eql/v3]: cannot re-initialize a typed EQL v3 client at eqlVersion 2 — the payloads would not match the columns' eql_v3_* domains. Build a separate client from the EQL v2 schema you want to write.", + }, + } + } + + const result = await client.init({ ...config, eqlVersion: 3 }) + return result.failure ? result : { data: typed } + }, + } + + return typed } /** * The client type {@link Encryption} resolves to for the schema tuple `S`. * - * **Use this instead of `Awaited>`.** `Encryption` - * is overloaded, and TypeScript's `ReturnType` reads the LAST overload — the - * nominal one — so that expression yields `EncryptionClient` even for an all-v3 - * schema set, and assigning the real (typed) client to it is an error: - * - * ``` - * Type 'TypedEncryptionClient<…>' is missing the following properties - * from type 'EncryptionClient': client, encryptConfig, init - * ``` - * - * Overload order cannot fix that — whichever signature is last wins, so one of - * the two forms is always mis-resolved. Name the schema tuple instead: + * This is **the** way to name the client — reach for it whenever you need to + * declare a variable, field or return type before the `await` that produces it: * * ```typescript * const users = encryptedTable("users", { email: types.TextSearch("email") }) + * * let client: EncryptionClientFor * client = await Encryption({ schemas: [users] }) * ``` * - * The equivalent inline workaround — inferring through a single-signature - * helper, `Awaited>` — also works, and is what - * `packages/bench` does. + * For code that is generic over its schemas — integration adapters that build a + * table per test family, say — name the loose array and keep the typed surface: + * + * ```typescript + * let client: EncryptionClientFor + * ``` + * + * **Do not use `Awaited>`.** `Encryption` is + * overloaded, and TypeScript's `ReturnType` reads the LAST overload — the + * nominal one — so that expression yields `EncryptionClient` even for an all-v3 + * schema set, and assigning the real client to it is an error: + * + * ``` + * Type 'TypedEncryptionClient<…>' is missing the following properties + * from type 'EncryptionClient': client, encryptConfig + * ``` + * + * Overload order cannot fix that — whichever signature is last wins, so one of + * the two forms is always mis-resolved, and putting the nominal signature first + * mis-resolves v3 schemas instead (a v3 table structurally satisfies + * `BuildableTable`). A named extraction type is what every comparable library + * does for the same reason: `z.infer`, arktype's `typeof T.infer`, hono's + * `Client`. */ export type EncryptionClientFor = - S extends readonly [AnyV3Table, ...AnyV3Table[]] - ? TypedEncryptionClient + S extends readonly AnyV3Table[] + ? S['length'] extends 0 + ? EncryptionClient + : TypedEncryptionClient : EncryptionClient /** diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index 30c46ce81..faf4a0560 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -431,7 +431,46 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { * `EncryptedDateColumn`, both storage-only) are NOT mutually assignable. This * nominality is what keeps plaintext inference precise. */ +/** + * Key for {@link EncryptedV3Column}'s phantom domain carrier. Declared, never + * defined — it exists only in the type layer, and is deliberately not exported. + */ +declare const domainCarrier: unique symbol + export class EncryptedV3Column { + /** + * Phantom carrier for `D`, and the reason this class is usable from the + * BUILT package at all. + * + * `PlaintextForColumn` / `QueryTypesForColumn` recover the domain with + * `C extends EncryptedV3Column`. That inference needs `D` to appear + * BARE somewhere in the instance type. In source the only such site is + * `private readonly definition: D` — but `tsc` strips the types of private + * members on declaration emit, so the shipped `.d.ts` says + * `private readonly definition;` and the site disappears. What is left is + * `getEqlType(): D['eqlType']`, `getQueryCapabilities(): D['capabilities']` + * and `isQueryable(): QueryableFlag` — all uninvertible, so `infer D` fell + * back to the `V3DomainDefinition` constraint and every helper collapsed: + * `QueryTypesForColumn` to `never`, `PlaintextForColumn` to the union of + * every domain's plaintext. Typed `encryptQuery` was therefore uncallable for + * ANY column against the published package. + * + * `declare` means type-only: no field is emitted, no runtime cost, no + * constructor change — but the declaration survives emit and gives `infer D` + * the bare site it needs. Optional so no call site has to supply it. + * + * Keyed by a non-exported `unique symbol` rather than a plain name. There is + * no `stripInternal` in this build, so `@internal` is documentation only — a + * `__domain` property would be reachable from customer code and show up in + * autocomplete on every column, typed `D | undefined` while being `undefined` + * always. The symbol is not exported, so the carrier is unnameable outside + * this module while remaining exactly as inferrable. + * + * @internal Not for consumption; read the domain via `getEqlType()` / + * `getQueryCapabilities()`. + */ + declare readonly [domainCarrier]?: D + constructor( private readonly columnName: string, private readonly definition: D, diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 7cca68207..7195c4f7c 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -208,10 +208,10 @@ export type ClientConfig = { * minus the legacy `eqlVersion: 2` escape hatch. * * `Encryption` accepts this (not the full `ClientConfig`) alongside an all-v3 - * schema set. Forcing v2 wire over v3 schemas returns the NOMINAL client at - * runtime, so admitting `2` there typed the call as `TypedEncryptionClient` - * while handing back a client that silently ignores the typed client's extra - * `decryptModel` arguments. Adapters that are v3-only (`@cipherstash/prisma-next`, + * schema set. Forcing v2 wire over v3 schemas THROWS at setup — v2 payloads + * cannot satisfy an `eql_v3_*` domain — so admitting `2` there typed the call + * as `TypedEncryptionClient` for a call that returns no client at all. + * Adapters that are v3-only (`@cipherstash/prisma-next`, * `@cipherstash/stack-drizzle`) should take this type for their pass-through * config for the same reason. */ diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index ddc4ca84c..7b8c05f90 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -58,5 +58,10 @@ // depend on `build`, so CI can run type tests against an older dist). "@cipherstash/stack/errors": ["./src/errors/index.ts"] } - } + }, + // `dist-types/` deliberately typechecks against the EMITTED declarations, so + // it has its own tsconfig with none of the `@/*` source paths above. Checking + // it here too would resolve its imports differently and report failures that + // say nothing about what ships. Run it with `pnpm test:types:dist`. + "exclude": ["dist-types"] } diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json index d88a9b847..7cc1059cb 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -30,5 +30,10 @@ "paths": { "@/*": ["./src/*"] } - } + }, + // The `typecheck` gate must compile source, not build output. TypeScript's + // default `exclude` covers `node_modules` but NOT `dist`, and specifying + // `exclude` replaces that default — hence both. Enforced by + // `pnpm run lint:typecheck-scope`. + "exclude": ["dist", "node_modules"] } diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json new file mode 100644 index 000000000..c35a0d5ac --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/excludes-dist", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json new file mode 100644 index 000000000..0aecccec4 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/excludes-dist/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json new file mode 100644 index 000000000..46a9e7c53 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/has-include", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json new file mode 100644 index 000000000..e0ba56aa8 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/has-include/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json new file mode 100644 index 000000000..f6c511632 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/jsonc-paths", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json new file mode 100644 index 000000000..f384a6d17 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/jsonc-paths/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + /* A block comment, and below a path mapping whose KEY contains `/*` — a + naive strip eats from inside that string to the next comment close. */ + "paths": { + "@/*": ["./src/*"] + }, + "strict": true + }, + // A trailing comma follows, which `JSON.parse` also rejects. + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json new file mode 100644 index 000000000..24edb2530 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/no-gate", + "scripts": { "build": "tsup" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json new file mode 100644 index 000000000..98865a3ea --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/no-gate/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json new file mode 100644 index 000000000..f1126d4b3 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/unscoped-too", + "scripts": { "typecheck": "tsc --project tsconfig.json --noEmit" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json new file mode 100644 index 000000000..636f8a894 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped-too/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "exclude": ["node_modules"] +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json new file mode 100644 index 000000000..abd692cf5 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/unscoped", + "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" } +} diff --git a/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json new file mode 100644 index 000000000..98865a3ea --- /dev/null +++ b/scripts/__tests__/fixtures/lint-typecheck-scope/unscoped/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true } +} diff --git a/scripts/__tests__/lint-typecheck-scope.test.mjs b/scripts/__tests__/lint-typecheck-scope.test.mjs new file mode 100644 index 000000000..093f9cf0a --- /dev/null +++ b/scripts/__tests__/lint-typecheck-scope.test.mjs @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-typecheck-scope.mjs', +) + +function run(...targets) { + try { + execFileSync('node', [SCRIPT, ...targets], { encoding: 'utf8' }) + return { exitCode: 0, output: '' } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +const fx = (name) => `scripts/__tests__/fixtures/lint-typecheck-scope/${name}` + +describe('lint-typecheck-scope', () => { + it('passes on the repo as it stands', () => { + const r = run() + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + }) + + it('fails a gated package whose tsconfig scopes nothing', () => { + const r = run(fx('unscoped')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/@fixture\/unscoped/) + expect(r.output).toMatch(/compiles its own build output/) + }) + + it('names the offending tsconfig and the gate command', () => { + const r = run(fx('unscoped')) + expect(r.output).toMatch(/unscoped\/tsconfig\.json/) + expect(r.output).toMatch(/tsc --noEmit -p tsconfig\.json/) + }) + + it('passes when `exclude` covers dist', () => { + expect(run(fx('excludes-dist')).exitCode).toBe(0) + }) + + it('passes when an explicit `include` scopes the program', () => { + expect(run(fx('has-include')).exitCode).toBe(0) + }) + + it('ignores a package with no typecheck gate', () => { + // An unscoped tsconfig nothing runs is an editor setting, not a CI + // contract — flagging it would be noise. + expect(run(fx('no-gate')).exitCode).toBe(0) + }) + + it('parses a tsconfig with comments, block comments and a `/*` inside a path key', () => { + // Regression: the first cut stripped block comments with a regex, which ate + // from the `/*` inside the `"@/*"` mapping key to the next comment close and + // reported four real tsconfigs as unparseable. + const r = run(fx('jsonc-paths')) + expect(r.output).not.toMatch(/could not be parsed/) + expect(r.exitCode).toBe(0) + }) + + it('counts only the offenders among the targets it was given', () => { + const r = run(fx('unscoped'), fx('no-gate'), fx('excludes-dist')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/Found 1 typecheck gate\(s\)/) + }) + + it('reports every offender, not just the first', () => { + const r = run(fx('unscoped'), fx('unscoped-too')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/Found 2 typecheck gate\(s\)/) + expect(r.output).toMatch(/@fixture\/unscoped\b/) + expect(r.output).toMatch(/@fixture\/unscoped-too/) + }) + + it('explains the fix, including that `exclude` replaces the default', () => { + const r = run(fx('unscoped')) + expect(r.output).toMatch(/"exclude": \["dist", "node_modules"\]/) + expect(r.output).toMatch(/REPLACES the default/) + }) +}) diff --git a/scripts/__tests__/turbo-skills-inputs.test.mjs b/scripts/__tests__/turbo-skills-inputs.test.mjs new file mode 100644 index 000000000..7706ca767 --- /dev/null +++ b/scripts/__tests__/turbo-skills-inputs.test.mjs @@ -0,0 +1,119 @@ +/** + * A build that copies `skills/` must declare `skills/` as an input. + * + * `packages/cli` and `packages/wizard` both `cpSync('../../skills', + * 'dist/skills')` — they consume a directory outside their own package, which + * turbo's `$TURBO_DEFAULT$` does not cover. On its own that only meant a stale + * cache entry stayed valid; once `build` declared `outputs: ["dist/**"]`, a + * cache hit began actively RESTORING the previous `dist/skills` over the tree. + * + * `skills/` ships inside the `stash` and `@cipherstash/wizard` tarballs and + * `stash init` copies it into customer repos, so the failure mode is publishing + * guidance that was edited but never rebuilt — invisible, because the build is + * green and the source file on disk is correct. + * + * Verified by hand at the time of writing: edit a `SKILL.md`, run + * `turbo run build --filter=stash`, observe FULL TURBO and a `dist/skills` copy + * without the edit. This pins the fix so it cannot silently come undone. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** Strip comments so `JSON.parse` accepts turbo.json (it is JSONC). */ +function readJsonc(path) { + const raw = readFileSync(path, 'utf8') + let out = '' + let inString = false + let escaped = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (inString) { + out += ch + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && raw[i + 1] === '/') { + while (i < raw.length && raw[i] !== '\n') i++ + out += '\n' + continue + } + if (ch === '/' && raw[i + 1] === '*') { + i += 2 + while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++ + i++ + continue + } + out += ch + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) +} + +/** Packages whose tsup config copies the repo-root `skills/` into `dist/`. */ +function packagesCopyingSkills() { + const pkgsDir = join(REPO_ROOT, 'packages') + const found = [] + for (const entry of readdirSync(pkgsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const tsup = join(pkgsDir, entry.name, 'tsup.config.ts') + const pkgJson = join(pkgsDir, entry.name, 'package.json') + if (!existsSync(tsup) || !existsSync(pkgJson)) continue + if ( + !/cpSync\(\s*['"]\.\.\/\.\.\/skills['"]/.test(readFileSync(tsup, 'utf8')) + ) + continue + found.push(JSON.parse(readFileSync(pkgJson, 'utf8')).name) + } + return found +} + +describe('turbo build inputs cover the skills directory', () => { + const turbo = readJsonc(join(REPO_ROOT, 'turbo.json')) + const copiers = packagesCopyingSkills() + + it('finds the packages that copy skills into their bundle', () => { + // If this drops to zero the rest of the suite silently passes, so pin it. + expect(copiers).toEqual( + expect.arrayContaining(['stash', '@cipherstash/wizard']), + ) + }) + + it.each( + packagesCopyingSkills().map((name) => [name]), + )('%s#build declares skills as an input', (name) => { + const task = turbo.tasks[`${name}#build`] + + expect( + task, + `turbo.json has no "${name}#build" task, so it inherits the generic ` + + '`build` inputs, which do not include the repo-root skills/ directory', + ).toBeDefined() + + const inputs = task.inputs ?? [] + expect( + inputs.some((i) => i.includes('skills')), + `"${name}#build".inputs must name the repo-root skills/ directory ` + + '(e.g. "$TURBO_ROOT$/skills/**") or a skills-only edit will not ' + + 'invalidate the build, and `outputs: ["dist/**"]` will restore a stale copy', + ).toBe(true) + }) + + it('keeps the generic build outputs on the overrides', () => { + // An override replaces the generic task wholesale — dropping `outputs` + // would stop the cache restoring dist at all for these two packages. + for (const name of copiers) { + expect(turbo.tasks[`${name}#build`].outputs).toEqual(['dist/**']) + } + }) +}) diff --git a/scripts/lint-typecheck-scope.mjs b/scripts/lint-typecheck-scope.mjs new file mode 100644 index 000000000..51198fb02 --- /dev/null +++ b/scripts/lint-typecheck-scope.mjs @@ -0,0 +1,165 @@ +/** + * A `typecheck` gate must compile SOURCE, and only source. + * + * TypeScript's default `exclude` is `["node_modules", "bower_components", + * "jspm_packages"]` plus `outDir` — it does NOT cover `dist/`. So a tsconfig + * with no `include`, no `exclude` and no `outDir` globs `**\/*`, which sweeps the + * package's own build output into the program alongside its source. + * + * That makes a CI gate non-deterministic in a way that is invisible when it + * passes. `turbo run typecheck --filter ` only builds the package's + * DEPENDENCIES (`dependsOn: ["^build"]`), not the package itself, so whether + * `dist/` exists during the gate depends on what an unrelated earlier CI step + * happened to build transitively. Reorder the steps and the gate silently + * compiles a different set of files. Locally, after a full build, it compiles a + * different set again. + * + * `skipLibCheck` hides most of the consequences — a stale `.d.ts` importing a + * deleted package still exits 0 — so this cannot be left to "CI is green". + * + * Fix by scoping the tsconfig: either an explicit `include` naming the source + * roots, or an `exclude` listing `dist`. Note that specifying `exclude` REPLACES + * the default list, so `node_modules` must be re-listed alongside `dist`. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +// Roots holding workspace members, mirroring `pnpm-workspace.yaml`. Override +// with argv[2..] for tests / ad-hoc checks (each arg is a package directory). +const WORKSPACE_ROOTS = ['packages', 'examples'] + +/** Every directory that looks like a workspace member. */ +function discoverPackages() { + const found = [] + for (const root of WORKSPACE_ROOTS) { + const abs = resolve(REPO_ROOT, root) + if (!existsSync(abs)) continue + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + found.push(join(abs, entry.name)) + } + } + const e2e = resolve(REPO_ROOT, 'e2e') + if (existsSync(e2e)) found.push(e2e) + return found +} + +const targets = process.argv.slice(2).length + ? process.argv.slice(2).map((t) => resolve(REPO_ROOT, t)) + : discoverPackages() + +/** + * Strip comments and trailing commas so `JSON.parse` accepts a tsconfig. + * + * Scans character by character tracking string state rather than pattern + * matching: these tsconfigs map `"@/*": ["../stack/src/*"]`, and a regex for + * block comments happily eats from the `/*` inside that key to the next `*\/`, + * silently destroying the file it was meant to read. + */ +function readJsonc(path) { + const raw = readFileSync(path, 'utf8') + let out = '' + let inString = false + let escaped = false + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + + if (inString) { + out += ch + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && raw[i + 1] === '/') { + while (i < raw.length && raw[i] !== '\n') i++ + out += '\n' + continue + } + if (ch === '/' && raw[i + 1] === '*') { + i += 2 + while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++ + i++ + continue + } + out += ch + } + + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) +} + +const offenders = [] + +for (const pkgDir of targets) { + const pkgJsonPath = join(pkgDir, 'package.json') + const tsconfigPath = join(pkgDir, 'tsconfig.json') + if (!existsSync(pkgJsonPath) || !existsSync(tsconfigPath)) continue + + let pkgJson + let tsconfig + try { + pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + tsconfig = readJsonc(tsconfigPath) + } catch (err) { + offenders.push( + `${relative(REPO_ROOT, tsconfigPath)}: could not be parsed — ${err.message}`, + ) + continue + } + + // Only packages wired as a gate. A tsconfig nothing runs is an editor + // setting, not a CI contract. + const scripts = pkgJson.scripts ?? {} + const gate = + scripts.typecheck ?? + (scripts.build === 'tsc --noEmit' ? scripts.build : undefined) + if (gate === undefined) continue + + // An explicit `include` scopes the program by itself. + if (Array.isArray(tsconfig.include) && tsconfig.include.length > 0) continue + + // Otherwise `exclude` has to carry it. `outDir` also excludes by default, but + // these gates all set `noEmit`, so relying on it would be a trap. + const exclude = Array.isArray(tsconfig.exclude) ? tsconfig.exclude : [] + const excludesDist = exclude.some( + (e) => typeof e === 'string' && /(^|\/)dist(\/|$|\*)/.test(e), + ) + if (excludesDist) continue + + offenders.push( + `${relative(REPO_ROOT, tsconfigPath)}: \`${pkgJson.name}\` runs a typecheck gate ` + + `(\`${gate}\`) but the tsconfig declares neither an \`include\` nor an ` + + '`exclude` covering `dist`, so the gate compiles its own build output', + ) +} + +if (offenders.length > 0) { + console.error( + `Found ${offenders.length} typecheck gate(s) that compile build output as well as source:\n`, + ) + for (const o of offenders) console.error(` ${o}`) + console.error( + "\nTypeScript's default `exclude` does not cover `dist/`, so a tsconfig with\n" + + "no `include` globs `**/*` and sweeps the package's own emitted `.d.ts`\n" + + 'and `.js` into the gate. Whether `dist/` exists during CI depends on what\n' + + 'an earlier, unrelated step built transitively — so the gate compiles a\n' + + 'different program in CI than it does locally, and silently changes if the\n' + + 'steps are reordered.\n\n' + + 'Add to the tsconfig:\n\n' + + ' "exclude": ["dist", "node_modules"]\n\n' + + '(`exclude` REPLACES the default list, so `node_modules` must be re-listed.)\n' + + 'Or give it an explicit `include` naming the source roots, as `e2e` and\n' + + '`examples/prisma` do.', + ) + process.exit(1) +} diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 3c780c1cb..d766079df 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -312,6 +312,21 @@ const client = await Encryption({ schemas: [users] }) - `EncryptionV3` (from `@cipherstash/stack/v3`) is a **deprecated, type-identical alias** of `Encryption`, kept for backwards compatibility. New code should use `Encryption`. - `typedClient(client, ...schemas)` (exported from `@cipherstash/stack/v3`) wraps an already-built untyped `EncryptionClient` in the typed surface, if you built one via a lower-level path. - **v2 and v3 tables cannot be mixed in one client** — a mixed schema set throws at init. Create separate clients if you need both. +- `schemas` takes any non-empty array of v3 tables — a shared `export const schemas: AnyV3Table[]`, a `ReadonlyArray`, one built at runtime. It does not have to be an array literal. Writing `Encryption({ schemas: [] })` is a compile error, but an array typed `AnyV3Table[]` that is empty at runtime compiles and throws on init instead. +- **To name the client's type, use `EncryptionClientFor`** (from `@cipherstash/stack/v3`), not `Awaited>`. `Encryption` is overloaded and `ReturnType` reads the last overload, so that idiom always resolves to the untyped nominal client: + +```typescript +import { type AnyV3Table, Encryption, type EncryptionClientFor, encryptedTable, types } from "@cipherstash/stack/v3" + +const users = encryptedTable("users", { email: types.TextSearch("email") }) + +// A named schema tuple keeps per-column typing. +let client: EncryptionClientFor +client = await Encryption({ schemas: [users] }) + +// Code that is generic over its schemas keeps the typed surface too. +function withClient(c: EncryptionClientFor) { /* … */ } +``` ```typescript // Error handling @@ -858,7 +873,7 @@ Once dual-writes are recorded as live in `cs_migrations`: |---|---| | `stash encrypt backfill` | Walks the table in keyset-pagination order, encrypts each chunk, writes a single transactional `UPDATE` per chunk plus a `cs_migrations` checkpoint. SIGINT-safe; idempotent re-runs converge. | | Schema rename (**v2 only**) | Update the schema file: drop the `_encrypted` suffix; switch the original column declaration onto the encrypted type. **v3:** there is no rename — leave `_encrypted` under its own name and point the schema/queries at that name instead. | -| `stash encrypt cutover` (**v2 only**) | One transaction: renames `` → `_plaintext`, `_encrypted` → ``, and promotes the `eql_v2_configuration` row `pending` → `active`. Application reads of `` now return decrypted ciphertext transparently. **v3:** cutover does not apply — on a backfilled v3 column it reports "not applicable" and exits 0 without changing anything; skip this row. | +| `stash encrypt cutover` (**v2 only**) | One transaction: renames `` → `_plaintext`, `_encrypted` → ``, and promotes the `eql_v2_configuration` row `pending` → `active`. Reads of `` through **CipherStash Proxy** are decrypted on the wire; SDK/ORM reads still return ciphertext and need the decrypt path in the next row. **v3:** cutover does not apply — on a backfilled v3 column it reports "not applicable" and exits 0 without changing anything; skip this row. | | Wire reads through the encryption client | Read paths must decrypt before returning the value to callers (`decryptModel(row, table)` for Drizzle; the Supabase wrapper for Supabase; `decrypt`/`bulkDecryptModels` otherwise). Without this step, reads return raw EQL payloads to end users (a `public.eql_v3_*` jsonb document on v3; an `eql_v2_encrypted` composite on a legacy v2 column). | | Remove dual-write code | The plaintext column is no longer authoritative — **v2:** it is now `_plaintext` (the cutover renamed it); **v3:** it is still the original ``, since nothing was renamed. Either way, delete the dual-write logic once reads are served from the encrypted column. | | `stash encrypt drop` | Emits a migration that drops the plaintext column — and *which* column that is depends on the generation. **v2** (precondition: phase `cut-over`): a plain `ALTER TABLE … DROP COLUMN "_plaintext"`. **v3** (precondition: phase `backfilled`): it drops the **original ``** — there is no `_plaintext` — and the generated SQL is a `DO` block that takes `ACCESS EXCLUSIVE` on the table, re-counts rows with `` set and `_encrypted` NULL *at apply time*, and raises instead of dropping if any remain. Apply with the project's normal migration tooling. | diff --git a/turbo.json b/turbo.json index 6ed7b4a76..f86fa0a04 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], + "outputs": ["dist/**"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "env": ["STASH_POSTHOG_KEY"] }, @@ -19,6 +20,37 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "cache": false }, + // `packages/bench`'s "build" is `tsc --noEmit` — a typecheck, not a bundle — + // so it emits nothing and the repo-wide `dist/**` would warn on every run. + "@cipherstash/bench#build": { + "dependsOn": ["^build"], + "outputs": [] + }, + // These two builds `cpSync('../../skills', 'dist/skills')` — they consume a + // directory OUTSIDE their own package, which `$TURBO_DEFAULT$` does not + // cover. Without naming it here a skills-only edit does not invalidate the + // build, and since `build` now declares `outputs: ["dist/**"]`, the cache + // hit actively RESTORES the previous `dist/skills`. `skills/` ships inside + // the `stash` and `@cipherstash/wizard` tarballs and is copied into + // customer repos by `stash init`, so that silently publishes stale guidance. + "stash#build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], + "env": ["STASH_POSTHOG_KEY"] + }, + "@cipherstash/wizard#build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], + "env": ["STASH_POSTHOG_KEY"] + }, + // Typechecks `packages/stack/dist-types` against the BUILT declarations, so + // it must run after `build` — unlike `test:types`, which reads source. + "test:types:dist": { + "dependsOn": ["build"], + "inputs": ["$TURBO_DEFAULT$"] + }, "typecheck": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$"]