Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/cli-v2-cutover-prompt-correction.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions .changeset/domain-carrier-not-public.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions .changeset/encryption-schema-arrays.md
Original file line number Diff line number Diff line change
@@ -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<AnyV3Table> = [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<S>` 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<readonly AnyV3Table[]>` 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<S extends readonly [AnyV3Table, ...AnyV3Table[]]>(
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<readonly AnyV3Table[]>` as a parameter instead of
building the client inside the generic function.
6 changes: 3 additions & 3 deletions .changeset/prisma-next-v3-client-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions .changeset/reject-v2-wire-over-v3-schemas.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions .changeset/skills-encryption-client-naming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'stash': patch
---

`skills/stash-encryption` now documents how to name the client's type
(`EncryptionClientFor<S>`, not `Awaited<ReturnType<typeof Encryption>>`, 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.
18 changes: 18 additions & 0 deletions .changeset/skills-ship-current-copy.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 4 additions & 6 deletions .changeset/stack-audit-on-decrypt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof Encryption>>` names the nominal client whatever you
pass, because `ReturnType` reads the last overload; use the exported
`EncryptionClientFor<S>` to name the client for a schema tuple.
Expand Down
20 changes: 20 additions & 0 deletions .changeset/typecheck-gates-scope-source.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions .changeset/typed-client-init-parity.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions .changeset/typed-encrypt-query-dist.md
Original file line number Diff line number Diff line change
@@ -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<infer D>`, 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.
41 changes: 41 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` references
# dangling in docs and CI config. Nothing else catches it (#760 review).
- name: Lint — no references to deleted package directories
Expand Down
Loading
Loading