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
43 changes: 0 additions & 43 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,46 +102,3 @@ Fumadocs UI theme with custom purple primary color (`hsl(269, 70%, 45%)`). Dark
## Formatting

Biome handles both linting and formatting. 2-space indentation. Biome organizes imports automatically. Run `bun run format` before committing.

## Required Skills

Always use the following agent skills when working in this repository. These skills contain the canonical API references and must be consulted to ensure documentation accuracy.

### CipherStash Product Skills

Use these skills whenever writing or editing documentation about the corresponding product area. They contain the complete, up-to-date API surface, code examples, and type signatures.

| Skill | Use when editing docs in |
|---|---|
| `encryption` | `content/docs/encryption/` — Schema definition, encrypt/decrypt, searchable encryption, bulk operations, identity-aware encryption, error handling, migration |
| `secrets` | `content/docs/secrets/` — Secrets SDK (`set`/`get`/`getMany`/`list`/`delete`), `stash` CLI usage, environment isolation |
| `drizzle` | `content/docs/encryption/drizzle.mdx` — `encryptedType` column, `extractEncryptionSchema`, `createEncryptionOperators`, encrypted query operators, batched and/or, EQL migrations |
| `supabase` | `content/docs/encryption/supabase.mdx` — `encryptedSupabase` wrapper, transparent encrypt/decrypt on insert/update/select, query filters (eq, like, gt, in, or, match), identity-aware encryption |
| `dynamodb` | `content/docs/encryption/dynamodb.mdx` — `encryptedDynamoDB` helper, `__source`/`__hmac` attribute naming, encrypted partition/sort keys, bulk operations, audit logging |

### Documentation Update Skill

Use the `update-docs` skill whenever:
- Creating new documentation pages
- Updating existing documentation based on code changes
- Reviewing documentation completeness for a PR
- Scaffolding docs for a new feature

The `update-docs` skill enforces the following workflow:

1. **Analyze changes** — Diff the branch to identify affected files
2. **Map to docs** — Use the code-to-docs mapping to find which MDX files need updates
3. **Review each doc** — Walk through updates with user confirmation before editing
4. **Validate** — Run `bun run lint` to check formatting
5. **Commit** — Stage documentation changes

#### Documentation Validation Checklist

Before committing documentation changes, verify:

- Frontmatter has `title` and `description`
- Code blocks have `filename` attribute
- TypeScript examples come first, with `switcher` and a JS variant where appropriate
- Props/options tables are properly formatted
- Notes use the `> **Good to know**:` callout pattern
- `bun run lint` passes
16 changes: 13 additions & 3 deletions scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ const stackConfig: DocsConfig = {
baseOutputDir: path.join(process.cwd(), "content/stack/reference/stack"),
// Stack 1.0 split the Drizzle and Supabase adapters into their own packages
// (@cipherstash/stack-drizzle, @cipherstash/stack-supabase) and removed the
// secrets module, so those three entry points no longer exist under
// packages/stack/src. Keep only the modules @cipherstash/stack still ships.
// secrets module, so those entry points no longer exist under
// packages/stack/src.
//
// `stack-supabase` is documented from its new home. It is a separate npm
// package but the same reference surface to a reader, and without it nothing
// in the Supabase adapter — `encryptedSupabaseV3`, the query builder, the
// EQL-version constraints on its encrypted operators — reaches the generated
// reference at all.
entryPoints: [
"./packages/stack/src/encryption/index.ts",
"./packages/stack/src/schema/index.ts",
Expand All @@ -29,8 +35,12 @@ const stackConfig: DocsConfig = {
"./packages/stack/src/types-public.ts",
"./packages/stack/src/client.ts",
"./packages/stack/src/errors/index.ts",
"./packages/stack-supabase/src/index.ts",
],
tsconfigInclude: [
"packages/stack/src/**/*",
"packages/stack-supabase/src/**/*",
],
tsconfigInclude: ["packages/stack/src/**/*"],
tagFilter: (tag: string) =>
tag.includes("@cipherstash/stack@") && !tag.includes("stack-"),
referencePathSegment: "stack",
Expand Down
50 changes: 50 additions & 0 deletions scripts/lib/docs-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,55 @@ export function getVersionsToGenerate(
});
}

/**
* TypeScript `paths` that resolve a workspace package to its SOURCE.
*
* An adapter package (`stack-supabase`) imports its sibling by package name —
* `@cipherstash/stack`, `@cipherstash/stack/schema`, `.../adapter-kit` — which
* resolves through that package's `exports` map to `./dist/*`. The clone is
* never built, so there is no `dist` and every such import is TS2307. TypeDoc
* then documents the adapter with all its cross-package types missing.
*
* Derive the mapping from the sibling's own `exports` map rather than hardcoding
* it: rewrite each subpath's `./dist/x.js` target to `./packages/<pkg>/src/x.ts`.
* A hand-written list would silently rot the next time Stack adds a subpath, and
* the failure mode is exactly the one this is fixing — a missing type quietly
* becoming `any` in the published reference.
*/
async function workspaceSourcePaths(
workingDir: string,
packageName: string,
): Promise<Record<string, string[]>> {
const dir = packageName.split("/").pop();
const manifest = path.join(workingDir, "packages", dir ?? "", "package.json");

let exportsMap: Record<string, unknown>;
try {
const pkg = JSON.parse(await fs.readFile(manifest, "utf8"));
exportsMap = pkg.exports ?? {};
} catch {
// The layout moved. Better to emit nothing and let TS2307 name the missing
// module than to invent paths that point at files which do not exist.
console.warn(` ! no exports map at ${manifest}; skipping source paths`);
return {};
}

const paths: Record<string, string[]> = {};
for (const [subpath, target] of Object.entries(exportsMap)) {
if (subpath.endsWith("package.json")) continue;
// Any condition will do — every branch points at the same module, and only
// the path shape matters here.
const dist = JSON.stringify(target).match(/\.\/dist\/[^"]+\.js/)?.[0];
if (!dist) continue;

const stem = dist.replace(/^\.\/dist\//, "").replace(/\.js$/, "");
const specifier =
subpath === "." ? packageName : `${packageName}/${subpath.slice(2)}`;
paths[specifier] = [`./packages/${dir}/src/${stem}.ts`];
}
return paths;
}

/**
* Generate documentation for a specific tag
*/
Expand Down Expand Up @@ -229,6 +278,7 @@ export async function generateDocsForTag(
"@/*": ["./packages/stack/src/*"],
"@cipherstash/schema": ["./packages/schema/src/index.ts"],
"@cipherstash/schema/*": ["./packages/schema/src/*"],
...(await workspaceSourcePaths(workingDir, "@cipherstash/stack")),
},
},
};
Expand Down
43 changes: 36 additions & 7 deletions scripts/validate-content-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@
/**
* Content API gate.
*
* Fails the build when documentation teaches an API that is deprecated, was
* removed, or never existed. Two long-lived content branches plus a fast-moving
* SDK means a page fixed on one branch can be silently reintroduced by a port
* from the other — which is exactly how the deprecated `LockContext.identify()`
* flow survived a fix and came back in a comparison-page port.
* Fails the build when documentation teaches one of the specific APIs listed in
* RULES below. Two long-lived content branches plus a fast-moving SDK means a
* page fixed on one branch can be silently reintroduced by a port from the
* other — which is exactly how the deprecated `LockContext.identify()` flow
* survived a fix and came back in a comparison-page port.
*
* ── What this does NOT do ──────────────────────────────────────────────────
* This is a DENYLIST, not verification. It catches only what someone thought to
* add, and cannot see an API that changed MEANING rather than name. Stack's
* `contains()` → `matches()` rename is the worked example: `contains()` still
* exists, so nothing here (or in any symbol-presence diff) fires, yet calling it
* on an encrypted text column now raises. That shipped in the docs.
*
* Catching that class needs the examples themselves typechecked against the
* installed SDK, the way Rust doctests are compiled. Until then, treat a pass
* as "none of the known-bad patterns appeared", which is what it prints.
*
* Run via `bun run validate-content`; wired into prebuild.
*
Expand Down Expand Up @@ -138,11 +149,17 @@ function inScope(relative: string, rule: Rule): boolean {

const root = process.cwd();
const findings: Finding[] = [];
let filesScanned = 0;
let filesSkipped = 0;

for (const dir of ["content/docs", "content/stack"]) {
for (const file of collectFiles(path.join(root, dir))) {
const relative = path.relative(root, file).split(path.sep).join("/");
if (isSkipped(relative)) continue;
if (isSkipped(relative)) {
filesSkipped++;
continue;
}
filesScanned++;

const lines = fs.readFileSync(file, "utf8").split("\n");
lines.forEach((text, i) => {
Expand Down Expand Up @@ -192,4 +209,16 @@ if (findings.length > 0) {
process.exit(1);
}

console.log("✓ no deprecated or non-existent API found in documentation");
// Say what was actually checked. This gate is a DENYLIST: it can only catch the
// APIs listed in RULES, so an unqualified "no non-existent API found" overstates
// it — and did, while `content/docs/reference/stack/supabase.mdx` taught
// `.contains()` for encrypted free-text, which raises. Reporting the rule count
// makes the coverage legible at a glance, so the next reader can tell the
// difference between "verified" and "matched nothing on a short list".
console.log(
`✓ content API gate: no occurrences of ${RULES.length} known-retired API pattern(s) ` +
`in ${filesScanned} file(s) (${filesSkipped} generated/pinned file(s) skipped).`,
);
console.log(
" Denylist only: it does not verify that the APIs the docs DO name still exist.",
);