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
14 changes: 9 additions & 5 deletions .changeset/eql-v3-text-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
"@cipherstash/stack": minor
---

Add the EQL v3 `text_search` authoring DSL on a new `@cipherstash/stack/schema/v3`
subpath (`encryptedTextSearchColumn`, v3 `encryptedTable` / `buildEncryptConfig`).
The v3 builders emit the existing `EncryptConfig` shape, so encryption, payloads,
and query paths are unchanged at runtime.
Add the EQL v3 `text_search` authoring DSL on a new `@cipherstash/stack/eql/v3`
subpath (`types.TextSearch`, v3 `encryptedTable` / `buildEncryptConfig`). The v3
builders emit the existing `EncryptConfig` shape, so encryption, payloads, and
query paths are unchanged at runtime.

Also widens the public client types (`EncryptionClientConfig.schemas`,
`EncryptOptions`, `SearchTerm`/`EncryptQueryOptions`) to a structural contract so
both v2 and v3 builders are accepted by `Encryption` / `encrypt` / `decrypt` /
`encryptQuery`. This is a backward-compatible widening — existing v2 usage is
unaffected.
unaffected. The structural contracts themselves (`BuildableColumn`,
`BuildableQueryColumn`, `BuildableV3QueryableColumn`, `BuildableTable`,
`BuildableTableColumns`) and the `encryptModel` return-type mapper
(`EncryptedFromBuildableTable`) are exported from `@cipherstash/stack/types` so
consumers can name them.
4 changes: 2 additions & 2 deletions .changeset/eql-v3-typed-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

Add a strongly-typed EQL v3 client surface on a new `@cipherstash/stack/v3`
subpath (`EncryptionV3`, `typedClient`, `TypedEncryptionClient`). It re-exports
the v3 schema builders, so a single import provides everything needed to author
and use a v3 schema.
the v3 `types` namespace and table API (from `@cipherstash/stack/eql/v3`), so a
single import provides everything needed to author and use a v3 schema.

Every method derives its types from the concrete `table` / `column` builder
arguments:
Expand Down
2 changes: 1 addition & 1 deletion .changeset/eql-v3-typed-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
'@cipherstash/stack': minor
---

Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/schema/v3`, including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).
Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per EQL v3 domain, e.g. `types.TextEq` / `types.Int4Ord` / `types.Timestamptz`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).

Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` and `bigint` (via the new `Plaintext` type), so v3 `date` / `timestamptz` / `int8` domains can be encrypted and queried with their natural JavaScript values.
4 changes: 2 additions & 2 deletions .github/workflows/fta-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ on:
branches:
- 'main'
paths:
- 'packages/stack/src/schema/v3/**'
- 'packages/stack/src/eql/v3/**'
- 'packages/stack/package.json'
- '.github/workflows/fta-v3.yml'
pull_request:
branches:
- "**"
paths:
- 'packages/stack/src/schema/v3/**'
- 'packages/stack/src/eql/v3/**'
- 'packages/stack/package.json'
- '.github/workflows/fta-v3.yml'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# EQL v3 `text_search` Schema DSL Implementation Plan

> **Superseded (2026-07-03):** this plan built the DSL on `@cipherstash/stack/schema/v3`
> with `encryptedTextSearchColumn(...)`. That surface has since been renamed to
> `@cipherstash/stack/eql/v3` and the factories replaced by the `types` namespace
> (`types.TextSearch(...)`). Retained as a historical execution record — do not
> re-run against the current tree.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add an EQL v3 authoring DSL (`encryptedTextSearchColumn`, plus v3 `encryptedTable` / `buildEncryptConfig`) on a new `@cipherstash/stack/schema/v3` subpath that emits the existing `EncryptConfig` shape with zero native-client changes.
Expand Down
5 changes: 5 additions & 0 deletions docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# EQL v3 Typed Schema Implementation Plan

> **Superseded (2026-07-03):** this plan added per-domain `encrypted<Domain>Column`
> builders on `@cipherstash/stack/schema/v3`. Those builders are now the `types`
> namespace (`types.TextEq` / `types.Int4Ord` / …) on `@cipherstash/stack/eql/v3`.
> Retained as a historical execution record — do not re-run against the current tree.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Expand `@cipherstash/stack/schema/v3` from the current `text_search` slice to all generated EQL v3 SQL domains with domain-precise builders, explicit query capability metadata, and structurally widened client/model support while preserving v2 behavior.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# EQL v3 Schema DSL — `text_search` (Increment 1)

> **Superseded (2026-07-03):** the authoring surface described below has moved.
> The subpath is now `@cipherstash/stack/eql/v3` (not `schema/v3`) and columns
> are authored via the `types` namespace — `types.TextSearch('email')` replaces
> `encryptedTextSearchColumn('email')`. This document is retained as the original
> design record for the increment; the code examples show the historical API.

**Date:** 2026-06-30
**Status:** Approved (design)
**Package:** `@cipherstash/stack`
Expand Down
33 changes: 19 additions & 14 deletions docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Branch: feat/eql-v3-text-search-schema
## Goal

Add StrykerJS mutation testing scoped to **EQL v3 only**
(`packages/stack/src/schema/v3/`) and wire it into CI as a **blocking** check.
(`packages/stack/src/eql/v3/`) and wire it into CI as a **blocking** check.
The gate mirrors the existing `fta-v3.yml` complexity gate: paths-filtered to v3,
directory-scoped, and blocking (no `continue-on-error`).

Expand All @@ -29,27 +29,31 @@ test-effectiveness gate (dynamic).

1. **stack uses Vitest 3.2.4, not Jest.** rundown's Jest-runner config is not
reusable. We use `@stryker-mutator/vitest-runner`.
2. **v3 scope is a single file:** `packages/stack/src/schema/v3/index.ts`
(~992 lines). Because the mutate scope is one file, the project-wide aggregate
mutation score *is* that file's score. rundown needed a custom per-file gate
script (`assert-mutation-score.mjs`) only because a single collapsing file can
hide behind a green aggregate across many files. **We do not need that script.**
2. **v3 scope is a small directory:** `packages/stack/src/eql/v3/` — four
cohesive files (`columns.ts`, `types.ts`, `table.ts`, `index.ts`), split out
of the former single `schema/v3/index.ts`. NOTE: an earlier draft of this
spec assumed a *single file*, from which it concluded the project-wide
aggregate score *is* that file's score and no per-file gate script was needed.
That premise no longer holds — the mutate scope is now four files. With only
four small files the aggregate is still a faithful signal, so start with an
aggregate `break` threshold only (no `assert-mutation-score.mjs`), but add
per-file gating if any one file's score later diverges from the aggregate.
3. **Live/DB tests self-skip without env.** `schema-v3-pg.test.ts` (guarded by
`DATABASE_URL` + `CS_*`) and `schema-v3-client.test.ts` (guarded by `CS_*`)
skip their `describe` blocks when the env vars are absent. So a CI run with no
Postgres service and no `.env` still runs cleanly — the live blocks skip; the
pure `schema-v3.test.ts` (~21 KB) and `typed-client-v3.test.ts` provide the
coverage. The gate stays as lean as the FTA job (no DB, no credentials).
4. **No build step needed.** Vitest transpiles TS on the fly and the tests import
from `@/schema/v3` (source, via the `@/` alias), so Stryker instruments source
from `@/eql/v3` (source, via the `@/` alias), so Stryker instruments source
directly — no `pnpm build` required.
5. **Supply-chain rule.** Tooling must be a pinned devDependency installed via
`--frozen-lockfile` (no `pnpm dlx` / `npx`), matching how `fta-cli@3.0.0` is
handled.

## Scope decisions

- **Mutate scope: `schema/v3` only** (`src/schema/v3/**/*.ts`). Matches the FTA
- **Mutate scope: `eql/v3` only** (`src/eql/v3/**/*.ts`). Matches the FTA
gate exactly. `src/encryption/v3.ts` is also v3 but is outside the current FTA
scope and is **excluded here** to keep the gate consistent and lean.
- **Test execution: lean, no DB.** Stryker runs only the v3 runtime test files;
Expand Down Expand Up @@ -89,7 +93,7 @@ export default {
testRunner: 'vitest',
plugins: ['@stryker-mutator/vitest-runner'],
vitest: { configFile: 'vitest.stryker.config.ts' },
mutate: ['src/schema/v3/**/*.ts'],
mutate: ['src/eql/v3/**/*.ts'],
coverageAnalysis: 'perTest',
reporters: ['clear-text', 'progress', 'html', 'json'],
htmlReporter: { fileName: 'reports/mutation/index.html' },
Expand Down Expand Up @@ -122,7 +126,7 @@ Add to `packages/stack/package.json` scripts:
A near-clone of `fta-v3.yml`:

- `on: push (main) / pull_request (**)` with `paths:`:
- `packages/stack/src/schema/v3/**`
- `packages/stack/src/eql/v3/**`
- `packages/stack/package.json`
- `packages/stack/stryker.config.mjs`
- `packages/stack/vitest.stryker.config.ts`
Expand All @@ -141,7 +145,7 @@ Implementation therefore includes a **baseline step**:

1. Install deps and run `pnpm --filter @cipherstash/stack run test:mutation`
locally.
2. Record the reported mutation score for `schema/v3`.
2. Record the reported mutation score for `eql/v3`.
3. Set `thresholds.break` just **below** the measured score (a small buffer, the
way FTA sets `--score-cap 72` against a current 71.08). This ensures the
current state passes while any regression that lowers the score fails the gate.
Expand All @@ -163,12 +167,13 @@ implementation, not a silent choice.
## Out of scope (YAGNI)

- Stryker Dashboard reporter and incremental baseline.
- Per-file gate script (`assert-mutation-score.mjs`) — unnecessary for a single
mutate file.
- Per-file gate script (`assert-mutation-score.mjs`) — deferred: start with an
aggregate `break` across the four `src/eql/v3/**` files; add per-file gating
only if one file's score later diverges from the aggregate (see fact #2).
- Advisory PR comment job.
- Postgres-backed mutation runs / mutating `src/encryption/v3.ts`.

These can be added later if the single-file gate proves insufficient.
These can be added later if the aggregate gate proves insufficient.

## Decisions confirmed

Expand Down
15 changes: 9 additions & 6 deletions packages/stack/__tests__/cjs-require.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,19 @@ describe('CJS consumers can require the built bundles', () => {
it('discovers at least the public entry points', () => {
expect(cjsEntries).toContain('dist/index.cjs')
expect(cjsEntries).toContain('dist/encryption/index.cjs')
expect(cjsEntries).toContain('dist/schema/v3/index.cjs')
expect(cjsEntries).toContain('dist/eql/v3/index.cjs')
})

it('exposes v3 schema builders from the CJS bundle', () => {
const v3Bundle = path.join(distDir, 'schema', 'v3', 'index.cjs')
it('exposes the v3 `types` namespace + table API from the CJS bundle', () => {
const v3Bundle = path.join(distDir, 'eql', 'v3', 'index.cjs')
const script = [
`const v3 = require(${JSON.stringify(v3Bundle)})`,
`const required = ['encryptedTextSearchColumn', 'encryptedInt4Column', 'encryptedBoolColumn', 'encryptedTimestamptzColumn', 'encryptedTable', 'buildEncryptConfig']`,
`const missing = required.filter((k) => typeof v3[k] !== 'function')`,
`if (missing.length > 0) { throw new Error('missing v3 CJS exports: ' + missing.join(', ')) }`,
`if (typeof v3.encryptedTable !== 'function') { throw new Error('missing v3 CJS export: encryptedTable') }`,
`if (typeof v3.buildEncryptConfig !== 'function') { throw new Error('missing v3 CJS export: buildEncryptConfig') }`,
`if (typeof v3.types !== 'object' || v3.types === null) { throw new Error('missing v3 CJS export: types namespace') }`,
`const requiredTypes = ['TextSearch', 'TextEq', 'Int4Ord', 'Bool', 'Timestamptz']`,
`const missing = requiredTypes.filter((k) => typeof v3.types[k] !== 'function')`,
`if (missing.length > 0) { throw new Error('missing v3 types.* CJS members: ' + missing.join(', ')) }`,
].join('\n')

expect(() =>
Expand Down
103 changes: 103 additions & 0 deletions packages/stack/__tests__/encrypt-lock-context-guards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Offline guard tests for the lock-context encrypt path.
Comment thread
tobyhede marked this conversation as resolved.
*
* `EncryptOperationWithLockContext.execute()` re-applies the NaN / Infinity
* runtime guards that the non-lock `EncryptOperation.execute()` has. The
* non-lock guards are exercised by the live `number-protect.test.ts` (its
* `beforeAll` builds a real client), but the lock-context arm — reached via
* `encrypt(value).withLockContext(...)` — had no coverage in any suite. These
* tests mock `@cipherstash/protect-ffi` so they run in CI without credentials
* and assert that:
* 1. NaN / +Infinity / -Infinity are rejected as failures with the same
* messages as the non-lock path, and
* 2. the guard short-circuits *before* the FFI encrypt call (a leaked NaN
* must never reach the ciphertext boundary).
*
* Every case runs against both a v2 fluent-builder column and a v3 domain
* column: the guards live on the shared `EncryptOperationWithLockContext`, so
* both schema styles must take the identical short-circuit path.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { encryptedTable as encryptedTableV3, types } from '@/eql/v3'
import { LockContext } from '@/identity'
import { Encryption } from '@/index'
import { encryptedColumn, encryptedTable } from '@/schema'

vi.mock('@cipherstash/protect-ffi', () => ({
// `getErrorCode` does `error instanceof ProtectError` on the failure path,
// so the mock must export the class even though the guards throw plain Errors.
ProtectError: class ProtectError extends Error {},
newClient: vi.fn(async () => ({ __mock: 'client' })),
encrypt: vi.fn(async () => ({ v: 2, c: 'ciphertext' })),
decrypt: vi.fn(async () => 'decrypted'),
}))

import * as ffi from '@cipherstash/protect-ffi'

const users = encryptedTable('users', {
score: encryptedColumn('score').dataType('number').equality().orderAndRange(),
Comment thread
tobyhede marked this conversation as resolved.
})

const usersV3 = encryptedTableV3('users_v3', {
score: types.Int4Ord('score'),
})

// biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union
const failure = (result: any) => result.failure

let client: Awaited<ReturnType<typeof Encryption>>

beforeEach(async () => {
vi.clearAllMocks()
process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace'
Comment thread
tobyhede marked this conversation as resolved.
client = await Encryption({ schemas: [users, usersV3] })
})

describe.each([
['v2 fluent builder', { column: users.score, table: users }],
['v3 domain type', { column: usersV3.score, table: usersV3 }],
] as const)('encrypt with lock context rejects non-finite numbers (%s)', (_variant, target) => {
it('rejects NaN and never reaches the FFI', async () => {
const result = await client
.encrypt(Number.NaN, target)
.withLockContext(new LockContext())

expect(failure(result)).toBeDefined()
expect(failure(result)?.message).toContain('Cannot encrypt NaN value')
expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled()
})

it('rejects +Infinity and never reaches the FFI', async () => {
const result = await client
.encrypt(Number.POSITIVE_INFINITY, target)
.withLockContext(new LockContext())

expect(failure(result)).toBeDefined()
expect(failure(result)?.message).toContain('Cannot encrypt Infinity value')
expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled()
})

it('rejects -Infinity and never reaches the FFI', async () => {
const result = await client
.encrypt(Number.NEGATIVE_INFINITY, target)
.withLockContext(new LockContext())

expect(failure(result)).toBeDefined()
expect(failure(result)?.message).toContain('Cannot encrypt Infinity value')
expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled()
})

it('accepts a finite number and forwards it to the FFI', async () => {
// Positive control: proves the guards above reject *because* of the value,
// not because the lock-context path is broken for all numbers.
const result = await client
.encrypt(42, target)
.withLockContext(new LockContext())

expect(failure(result)).toBeUndefined()
expect(vi.mocked(ffi.encrypt)).toHaveBeenCalledTimes(1)
const opts = vi.mocked(ffi.encrypt).mock.calls[0][1]
expect(opts.plaintext).toBe(42)
})
})
28 changes: 28 additions & 0 deletions packages/stack/__tests__/helpers/live-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe } from 'vitest'

/**
* Shared env gates for the live (network-touching) suites. Previously each
* live suite re-declared these; one definition keeps the credential list —
* and therefore what "live" means — from drifting between files.
*
* Callers must `import 'dotenv/config'` BEFORE importing this module (all
* live suites already do, as their first import) so the env is populated
* when these are evaluated.
*/

/** True when live CipherStash (ZeroKMS/CTS) credentials are configured. */
export const LIVE_CIPHERSTASH_ENABLED = Boolean(
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY &&
process.env.CS_CLIENT_ACCESS_KEY,
)

/** True when live credentials AND a Postgres `DATABASE_URL` are configured. */
export const LIVE_EQL_V3_PG_ENABLED = Boolean(
process.env.DATABASE_URL && LIVE_CIPHERSTASH_ENABLED,
)

export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip

export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip
10 changes: 3 additions & 7 deletions packages/stack/__tests__/model-column-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers'
import { encryptedTable, types } from '@/eql/v3'
import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema'
import {
encryptedDateColumn,
encryptedTable,
encryptedTextColumn,
} from '@/schema/v3'

// `resolveEncryptColumnMap` is how the model path reconciles the two keyings a
// table can use: models are matched by JS property name, but the FFI / encrypt
Expand All @@ -14,8 +10,8 @@ import {
describe('resolveEncryptColumnMap', () => {
it('v3: matches by JS property, addresses the FFI by DB name', () => {
const users = encryptedTable('users', {
createdOn: encryptedDateColumn('created_on'),
notes: encryptedTextColumn('notes'), // property == name
createdOn: types.Date('created_on'),
notes: types.Text('notes'), // property == name
})

const { columnPaths, toColumnName } = resolveEncryptColumnMap(users)
Expand Down
Loading
Loading