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/stack-diagnostics-subpath.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@cipherstash/stack': minor
---

Add a `@cipherstash/stack/diagnostics` subpath, for tooling that needs to prove the protect-ffi native binding is installed.

It exports one function, `assertNativeBindingAvailable()`. Calling it forces the platform binary to load and throws the loader's own `MODULE_NOT_FOUND` — unwrapped, naming the missing `@cipherstash/protect-ffi-<platform>-<arch>` package — if it is absent. Importing the subpath does not force anything, so the laziness that makes the native load cost nothing for callers that never encrypt is preserved.

The subpath exists because there is no way to do this from outside: the package's loader is not in its `exports` map, and reading an export never reaches the `@neon-rs/load` proxy. Importing `@cipherstash/stack` itself is not a substitute either — the root entry re-exports the auth strategies, so evaluating it resolves `@cipherstash/auth`'s binding instead. This entry reaches protect-ffi and nothing else.

It probes by calling `isEncrypted`, which has been published since 0.28.0, rather than protect-ffi's own `assertNativeBindingAvailable` — that export arrived with the lazy native load and is not in any released version, so re-exporting it would build here and fail wherever it shipped (a link-time error under ESM, an `undefined` under CJS).

Available as both `import` and `require`.
15 changes: 15 additions & 0 deletions .changeset/stash-doctor-probes-native-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'stash': patch
---

`stash doctor` now detects a missing native binary. Both of its checks had stopped doing so, in different ways, and each reported a green row instead.

**The encryption engine check never loaded anything.** Since the protect-ffi native load became lazy, importing the package resolves no platform binary — `@neon-rs/load`'s proxy resolves on first use — so the probe passed with nothing installed and the failure surfaced later, at the first encrypt. It now calls `assertNativeBindingAvailable()` through the new `@cipherstash/stack/diagnostics` subpath, which forces the load.

**It was also reporting the wrong package.** Importing `@cipherstash/stack` reaches `@cipherstash/auth`, whose binding is eager, so the encryption row was really a second auth check: one signal rendered as two rows. The diagnostics subpath does not reach auth, so each row now means what it says.

**A missing `@cipherstash/auth` binary produced a bare `Fatal error`.** That package's napi loader swallows the resolver's `MODULE_NOT_FOUND` and throws a summary carrying no error `code`, which the CLI's native-binary classifier did not recognise — so every command, not only `doctor`, skipped the recovery guidance and printed a raw message. The classifier now recognises that shape, and prints the missing package with the reinstall steps.

`stash doctor` exits non-zero when either platform package is missing, and reports an install of `@cipherstash/stack` that predates the diagnostics subpath as unprobeable rather than failing on it. A run that could not complete a check now ends with "stash doctor could not run every check." instead of claiming they all passed — still exit 0, since an unrunnable check is not a diagnosis.

**A package that is installed but broken is no longer reported as "not installed".** The check for an absent package matched the package name anywhere in the failure message, and the probe's own import path contains it — so a partially installed or partially built `@cipherstash/stack` was reported as one you simply had not installed yet, in green, with nothing to suggest looking further. It now matches on the specifier Node failed to resolve.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ If these variables are missing, tests that require live encryption will fail or
## Repository Layout

- `packages/stack`: Main package (`@cipherstash/stack`) containing the encryption client and all integrations
- Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline` (the Drizzle and Supabase integrations moved to their own packages — see below)
- Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline`, `@cipherstash/stack/diagnostics` (the Drizzle and Supabase integrations moved to their own packages — see below)
- `packages/cli`: The `stash` CLI — auth, init, encryption schema, and database setup (`stash eql install`). Has its own `AGENTS.md`.
- `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`)
- `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state
Expand Down
25 changes: 24 additions & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This package has **two** Vitest configs (plus a self-skipping live-Postgres mode
| Command | Config | Scope | Needs build? |
| --- | --- | --- | --- |
| `pnpm --filter stash test` | `vitest.config.ts` | Unit tests under `src/__tests__/**` and `src/**/__tests__/**` | **Partly** — needs `@cipherstash/stack` built (see below). Turbo's `^build` supplies it in CI. |
| `pnpm --filter stash test:e2e` | `vitest.integration.config.ts` | E2E tests under `tests/e2e/**.e2e.test.ts` driving the built `dist/bin/stash.js` through a real pty (`node-pty`) | **Yes** — run `pnpm --filter stash build` first, or use the turbo `test:e2e` task which depends on `build`. |
| `pnpm --filter stash test:e2e` | `vitest.integration.config.ts` | E2E tests under `tests/e2e/**.e2e.test.ts` driving the built `dist/bin/stash.js` through a real pty (`node-pty`) | **Yes** — run `pnpm --filter stash build` first, or use the turbo `test:e2e` task which depends on `build`. One test also needs protect-ffi's native binding, which no `build` produces (see below). |

The unit config explicitly excludes `tests/e2e/**` so the default `pnpm test`
stays fast.
Expand Down Expand Up @@ -86,6 +86,29 @@ exercise the same code paths.
- **Build before E2E.** `dist/bin/stash.js` is the artifact under test. The
turbo `test:e2e` task already depends on `build`, but if you invoke the
script directly you must build first.
- **`doctor.e2e.test.ts` also needs protect-ffi's native binding, and no
`build` produces one.** `stash doctor` probes the encryption engine by
*calling* through `@cipherstash/stack/diagnostics` — importing it proves
nothing, since the neon load is lazy. `@cipherstash/stack` is a devDependency
of this package, so in the workspace the probe always resolves it and never
takes the "not installed, that's fine" arm; and the workspace-linked
`@cipherstash/protect-ffi-<platform>` carries no `index.node` until cargo has
run (protect-ffi's `build` is `tsc`, deliberately cargo-free — see the root
`AGENTS.md`). Without a binding the healthy-install test fails on a red
encryption row and exit 1, and `doctor` offers the recovery it has for an npm
user — reinstall `node_modules` — which does not fix this. **That is a
missing binding, not a broken checkout.** Build one (needs a Rust toolchain),
from `packages/protect-ffi`:

```bash
mise run build:debug # or: pnpm --filter @cipherstash/protect-ffi build:native
```

CI never hits this: the `run-tests` job in `tests.yml` runs
`.github/actions/build-ffi-binding` long before the CLI E2E step, and that
action caches on a hash of the Rust inputs, so a JS-only PR pays a restore.
Only the healthy-path test needs a real binding: `doctor-missing-binary`
stages the absence itself, in the spawned CLI, and passes either way.
- **macOS spawn-helper exec bit.** pnpm strips the executable bit when
unpacking node-pty's prebuilds. The helper auto-fixes this at module load
via `ensureSpawnHelperExecutable`. If you see `posix_spawnp failed` after
Expand Down
160 changes: 160 additions & 0 deletions packages/cli/src/__tests__/module-error-classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { isPackageMissing, isSubpathUnavailable } from '../module-error.js'
import { isNativeBinaryMissing } from '../native.js'

// The `module-error.ts` classifiers `stash doctor` sorts a failed probe with.
// Each arm renders a different row and a different exit code, so a probe error
// landing in the wrong one is a wrong diagnosis, not a cosmetic slip — and the
// two here are the ones whose answers are indistinguishable to the user: a
// green "not installed", or advice to upgrade.
//
// Every fixture below is an error NODE raised, never one built by hand with the
// code and message pasted on. A hand-built fixture asserts on itself: it keeps
// passing when Node changes the shape the classifier has to recognise, which is
// exactly how `isNativeBinaryMissing` came to have a `@cipherstash/auth` arm
// that could never fire.
const require = createRequire(import.meta.url)

function resolutionError(specifier: string): unknown {
try {
require.resolve(specifier)
} catch (err) {
return err
}
throw new Error(`${specifier} resolved; it was expected to fail`)
}

// `stash` declares `@cipherstash/stack` as an OPTIONAL PEER at `>=1.0.0-rc.0`,
// and the encryption probe imports its `./diagnostics` subpath. So any install
// predating that subpath — a range the CLI itself permits — fails resolution
// with neither a missing package nor a missing binary. Without its own arm that
// error reaches doctor's `else` and is rethrown, surfacing as the launcher's
// bare `Fatal error` for an install that may be perfectly healthy.
describe('isSubpathUnavailable', () => {
// The two probes as `doctor` declares them: one imports a subpath, one does
// not.
const encryption = { pkg: '@cipherstash/stack', subpath: './no-such-subpath' }
const auth = { pkg: '@cipherstash/auth' }

it('matches an installed package that does not publish the subpath', () => {
// A real package (so resolution gets as far as reading its exports map)
// with a subpath it has never had.
const err = resolutionError('@cipherstash/stack/no-such-subpath')

expect((err as { code?: string }).code).toBe(
'ERR_PACKAGE_PATH_NOT_EXPORTED',
)
expect(isSubpathUnavailable(err, encryption)).toBe(true)
})

it('does not match a probe that imports no subpath', () => {
// The advice this arm renders names `@cipherstash/stack` and tells the user
// to upgrade it. Applied to the auth probe — which imports the package root
// — any exports failure raised somewhere inside auth's own dependency graph
// would answer a broken install with an unrelated upgrade and exit 0.
const err = resolutionError('@cipherstash/stack/no-such-subpath')

expect(isSubpathUnavailable(err, auth)).toBe(false)
})

it('does not match a failure on a different subpath of the same package', () => {
// A dependency deeper in the probe's own import graph with an exports
// problem of its own is not "your @cipherstash/stack is too old".
const err = resolutionError('@cipherstash/stack/no-such-subpath')

expect(
isSubpathUnavailable(err, {
pkg: '@cipherstash/stack',
subpath: './diagnostics',
}),
).toBe(false)
})

it('does not match the errors the other arms own', () => {
// An absent package: Node names the BASE package here, which is why the
// probe classifies against `@cipherstash/stack` while importing the
// subpath, and why this must not be mistaken for a stale install.
const missing = resolutionError('@cipherstash/no-such-package/diagnostics')
expect((missing as { code?: string }).code).toBe('MODULE_NOT_FOUND')
expect(isSubpathUnavailable(missing, encryption)).toBe(false)

const binary = new Error(
"Cannot find module '@cipherstash/protect-ffi-darwin-arm64'",
) as Error & { code?: string }
binary.code = 'MODULE_NOT_FOUND'
expect(isSubpathUnavailable(binary, encryption)).toBe(false)
expect(isNativeBinaryMissing(binary)).toBe(true)
})

it('ignores non-Error values', () => {
expect(isSubpathUnavailable(undefined, encryption)).toBe(false)
expect(
isSubpathUnavailable(
{ code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' },
encryption,
),
).toBe(false)
})
})

// The arm that decides whether a probe failure means "you have not installed
// this yet" — a green row for the optional peer — or something the user has to
// act on. Getting a false positive here is the worst outcome doctor has: it
// tells a user with a broken install that there is nothing to fix.
describe('isPackageMissing', () => {
it('matches the package the probe named', () => {
const err = resolutionError('@cipherstash/no-such-package/diagnostics')

expect(isPackageMissing(err, '@cipherstash/no-such-package')).toBe(true)
})

it('does not match a load failure for a file inside an installed package', () => {
// The probe imports `@cipherstash/stack/diagnostics`, so its failures name
// paths INSIDE the package — `…/node_modules/@cipherstash/stack/dist/
// diagnostics.js`. A substring test for the package name matches that
// happily and reports a package that is installed but broken (an
// interrupted install, a partially built workspace) as one the user has
// simply not installed yet: a green row, and no reason to look further.
//
// The path the CLI's own resolution produces — `packages/cli/node_modules/
// @cipherstash/stack/…`, the workspace's stand-in for a user's install.
// Not `require.resolve('@cipherstash/stack/package.json')`: that returns
// the symlink's REAL path (`packages/stack/…`), which drops the scoped
// name the bug turns on. Node raises the error either way; only the path
// handed to it is composed here.
//
// Resolved through CJS to keep Node's own resolver in play rather than
// Vitest's module pipeline. The ESM form of this message differs only by a
// trailing `imported from …`, and both quote the same specifier.
const cliRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
)
const err = resolutionError(
path.join(
cliRoot,
'node_modules/@cipherstash/stack/dist/no-such-file.js',
),
)

expect((err as { code?: string }).code).toBe('MODULE_NOT_FOUND')
expect((err as Error).message).toContain('@cipherstash/stack')
expect(isPackageMissing(err, '@cipherstash/stack')).toBe(false)
})

it('does not match a sibling package that merely shares a prefix', () => {
const err = resolutionError('@cipherstash/no-such-package-extra')

expect(isPackageMissing(err, '@cipherstash/no-such-package')).toBe(false)
})

it('ignores errors that are not module resolution failures', () => {
expect(
isPackageMissing(new Error('@cipherstash/stack'), '@cipherstash/stack'),
).toBe(false)
expect(isPackageMissing(undefined, '@cipherstash/stack')).toBe(false)
})
})
55 changes: 45 additions & 10 deletions packages/cli/src/__tests__/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ function moduleError(
return err
}

/**
* What napi-rs's generated loader throws when the platform package is absent —
* `@cipherstash/auth`'s shape. Verbatim from `stack-auth-node.js`, whose
* `loadBinding()` swallows each candidate's resolver error (`try { … } catch
* (_) {}`) and ends with this, so no `code` and no `requireStack` survive.
*/
function napiLoadError(pkg: string): Error {
return new Error(
`Failed to load native binding for ${process.platform}-${process.arch}. ` +
`Ensure the optional dependency "${pkg}" is installed, ` +
'or run "napi build" for local development.',
)
}

describe('isNativeBinaryMissing', () => {
it('matches a missing platform-specific protect-ffi binary', () => {
// The real-world failure: npm skipped the optional native dependency.
Expand All @@ -29,17 +43,38 @@ describe('isNativeBinaryMissing', () => {
expect(isNativeBinaryMissing(err)).toBe(true)
})

it('matches the auth native binary on linux/windows targets', () => {
expect(
isNativeBinaryMissing(
moduleError("Cannot find module '@cipherstash/auth-linux-x64-gnu'"),
),
).toBe(true)
it('matches the auth native binary across targets', () => {
// Auth is napi-rs, so these are the shape below, not `moduleError` — kept
// as a set because the platform token varies (`-gnu`, `-msvc`, plain).
for (const pkg of [
'@cipherstash/auth-linux-x64-gnu',
'@cipherstash/auth-win32-x64-msvc',
'@cipherstash/auth-darwin-arm64',
]) {
expect(isNativeBinaryMissing(napiLoadError(pkg)), pkg).toBe(true)
}
})

it('matches the napi loader failure, which carries no code at all', () => {
// The shape that made this whole helper a no-op for `@cipherstash/auth`.
// Its loader requires each candidate inside `try { … } catch (_) {}` and
// throws its own summary, so the resolver's MODULE_NOT_FOUND never
// escapes: no `code`, no `requireStack`, only the message. This assertion
// existed before as a hand-built error carrying `code =
// 'MODULE_NOT_FOUND'` — a shape `@cipherstash/auth` has never thrown, so
// it passed over a path that could not work, and `stash doctor` printed a
// bare `Fatal error` for a missing auth binary instead of the guidance.
const err = napiLoadError('@cipherstash/auth-darwin-arm64')
expect((err as ModuleError).code).toBeUndefined()
expect(isNativeBinaryMissing(err)).toBe(true)
})

it('does not match a napi loader failure from someone else', () => {
// Both halves of the message test have to hold: the platform package is
// what makes it ours.
expect(
isNativeBinaryMissing(
moduleError("Cannot find module '@cipherstash/auth-win32-x64-msvc'"),
),
).toBe(true)
isNativeBinaryMissing(napiLoadError('@other/thing-darwin-arm64')),
).toBe(false)
})

it('matches when only the neon loader appears in the require stack', () => {
Expand Down
Loading
Loading