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
28 changes: 28 additions & 0 deletions .changeset/supabase-v3-json-querying.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@cipherstash/stack-supabase': minor
'@cipherstash/stack': minor
'stash': patch
---

Encrypted-JSON querying on the v3 Supabase surface (#650). A `types.Json`
column now supports exact encrypted containment — `contains(col, subDocument)`
(ste_vec `@>` via PostgREST `cs`, with the sub-document storage-encrypted
against the column) — and JSONPath selector predicates: `selectorEq(col, path,
value)` and `selectorNe(col, path, value)` (dot-notation paths; `ne` includes
rows where the path is absent, mirroring the Drizzle selector's semantics).
Raw `.filter(col, 'cs', subDocument)` and `not(col, 'contains', …)` route
through the same encrypted path. Selector ordering is not expressible over
PostgREST yet (needs an EQL-bundle overload — see
cipherstash/encrypt-query-language#407); the Drizzle integration's
`ops.selector()` covers ordering today.

In core, `QueryTypesForColumn` gains the `searchableJson` arm (a `types.Json`
column no longer resolves to `never`, so typed adapter key sets can include
it), and the JSONPath selector-path helpers the Drizzle adapter introduced in
#651 moved to `@cipherstash/stack/adapter-kit` so both adapters share one
validation surface (`@cipherstash/stack-drizzle` re-exports them unchanged).

The bundled `stash-supabase` and `stash-encryption` skills are updated to
document the new querying surface (including the array-leaf and SQL-NULL
semantics, and the operand-exposure caveat) — skills ship inside the `stash`
tarball, hence the patch.
113 changes: 7 additions & 106 deletions packages/stack-drizzle/src/v3/operators.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { Result } from '@byteslice/result'
import type { AuditConfig } from '@cipherstash/stack/adapter-kit'
import {
jsonPathOf,
matchNeedleError,
parseSelectorSegments,
reconstructSelectorDocument,
stripDomainSchema,
unsupportedLeafReason,
} from '@cipherstash/stack/adapter-kit'
import type {
AnyEncryptedV3Column,
Expand Down Expand Up @@ -76,112 +80,9 @@ type OperandEncryptionClient = {
encrypt(value: never, opts: never): ChainableOperation<unknown>
}

/**
* Object keys that are prototype-pollution vectors — rejected outright (mirrors
* core's `FORBIDDEN_KEYS`), so a selector can never address them.
*/
const FORBIDDEN_SEGMENTS: ReadonlySet<string> = new Set([
'__proto__',
'prototype',
'constructor',
])

/**
* Parse a dot-notation JSONPath into its object-key segments. Rejects, each with
* a clear message: array-index/wildcard syntax (v1 is object-keys-only), the
* empty/root path, malformed paths (`..`, stray/leading/trailing dots, so we
* never silently query a *different* path), and prototype-pollution keys.
* `'$.a.b'` / `' a.b '` → `['a', 'b']`. Mirrors core's JSONPath normalization
* (not exported publicly — see the follow-up to share it via adapter-kit).
*
* Exported for unit testing; NOT re-exported from the package index.
*/
export function parseSelectorSegments(path: string): string[] {
const trimmed = path.trim()
let body = trimmed.startsWith('$') ? trimmed.slice(1) : trimmed
if (body.startsWith('.')) body = body.slice(1)
if (body === '') {
throw new Error(
`JSON selector path "${path}" addresses no field — use e.g. "$.a" or "$.a.b".`,
)
}
if (/[[\]*]/.test(body)) {
throw new Error(
`JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`,
)
}
const segments = body.split('.')
for (const segment of segments) {
if (segment === '') {
throw new Error(
`JSON selector path "${path}" is malformed (empty segment / ".." / stray dot) — use dot-notation object keys (e.g. "$.a.b").`,
)
}
if (FORBIDDEN_SEGMENTS.has(segment)) {
throw new Error(
`JSON selector path "${path}" addresses the forbidden key "${segment}".`,
)
}
}
return segments
}

/** `$`-rooted JSONPath for `encryptQuery`'s selector needle. */
function jsonPathOf(segments: string[]): string {
return `$.${segments.join('.')}`
}

/**
* A selector compares a single scalar LEAF. Returns a reason string when `value`
* is unsupported — a non-scalar (object/array → that's `contains`), or a boolean
* under an ordering operator (no ordering term) — else `null`. The caller raises
* it as an {@link EncryptionOperatorError} with column context, so a bad value is
* an actionable SDK error rather than a deferred, opaque DB failure.
*/
function unsupportedLeafReason(
value: unknown,
ordering: boolean,
): string | null {
const isScalar =
value instanceof Date ||
typeof value === 'number' ||
typeof value === 'bigint' ||
typeof value === 'string' ||
typeof value === 'boolean'
if (!isScalar) {
return `a selector compares a scalar leaf, but got ${Array.isArray(value) ? 'an array' : 'an object'} — use contains() for sub-object matching.`
}
if (ordering && typeof value === 'boolean') {
return 'a boolean leaf has no ordering — use eq/ne (or contains()).'
}
return null
}

/**
* Nest `value` under the segments: `['a','b']` → `{ a: { b: value } }`. The
* storage-needle document whose ste_vec entry at the path supplies the
* ciphertext-bearing comparison entry.
*/
export function reconstructSelectorDocument(
segments: string[],
value: unknown,
): Record<string, unknown> {
// Null-prototype objects: a segment like `__proto__` must become an OWN key,
// not invoke the prototype setter (which would drop it and mis-serialize the
// needle). JSON.stringify ignores the [[Prototype]], so this serializes fine.
const root: Record<string, unknown> = Object.create(null)
let cursor = root
segments.forEach((segment, index) => {
if (index === segments.length - 1) {
cursor[segment] = value
} else {
const next: Record<string, unknown> = Object.create(null)
cursor[segment] = next
cursor = next
}
})
return root
}
// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the
// Supabase adapter, #650); re-exported so existing imports keep working.
export { parseSelectorSegments, reconstructSelectorDocument }

/**
* A dedicated error for v3 operator gating and operand-encryption failures,
Expand Down
9 changes: 7 additions & 2 deletions packages/stack-supabase/__tests__/helpers/supabase-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ export function fakeEnvelope(value: unknown, column: string): FakeEnvelope {
: typeof value === 'bigint'
? value.toString()
: value
// `String(pt)` throws on a null-prototype object (no toString) — which the
// JSON selector needles deliberately are (prototype-pollution safety). Tag
// the fake ciphertext via JSON for objects instead.
const tag =
pt !== null && typeof pt === 'object' ? JSON.stringify(pt) : String(pt)
return {
v: 2,
i: { t: 'tbl', c: column },
c: `ct:${String(pt)}`,
hm: `hm:${String(pt)}`,
c: `ct:${tag}`,
hm: `hm:${tag}`,
pt,
}
}
Expand Down
15 changes: 8 additions & 7 deletions packages/stack-supabase/__tests__/supabase-v3-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1753,18 +1753,19 @@ describe('v3 raw filter() resolves the query type from the operator', () => {
])
})

// `encryptCollectedTerms` rejects any queryType outside the three scalar EQL
// v3 kinds. No public call path can produce a fourth — `mapFilterOpToQueryType`,
// `encryptCollectedTerms` rejects any queryType outside the four supported EQL
// v3 kinds. No public call path can produce a fifth — `mapFilterOpToQueryType`,
// `queryTypeForRawOp` and `queryTypeForOrOp` are exhaustive — so this backstop
// is unreachable without breaking the internal contract, which is exactly what
// the subclass below does. Keep the guard: it is what a future producer
// gaining a fourth QueryTypeName would trip over.
it('rejects a query type outside the scalar EQL v3 kinds', async () => {
// gaining a new QueryTypeName would trip over. (`searchableJson` used to be
// the exemplar here until #650 made it a real, supported kind.)
it('rejects a query type outside the supported EQL v3 kinds', async () => {
const supabase = createMockSupabase()

class BogusQueryType extends EncryptedQueryBuilderV3Impl<typeof users> {
protected override queryTypeForRawOp(_operator: string) {
return 'searchableJson' as never
return 'steVecSelector' as never
}
}

Expand All @@ -1781,8 +1782,8 @@ describe('v3 raw filter() resolves the query type from the operator', () => {
.filter('email', 'eq', 'a@b.com')

expect(status).toBe(500)
expect(error?.message).toContain('query type "searchableJson"')
expect(error?.message).toContain('not supported on scalar EQL v3 columns')
expect(error?.message).toContain('query type "steVecSelector"')
expect(error?.message).toContain('not supported on EQL v3 columns')
})
})

Expand Down
Loading
Loading