Skip to content

fix(stack): escape encrypted in-list operands; widen is/contains#609

Merged
tobyhede merged 4 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-supabase-adapter-follow-up
Jul 9, 2026
Merged

fix(stack): escape encrypted in-list operands; widen is/contains#609
tobyhede merged 4 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-supabase-adapter-follow-up

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #588, addressing the review feedback that landed after it merged.

Two of these are broken features rather than polish. Both were invisible to the existing tests for the same structural reason: createMockSupabase records the arguments handed to each builder method and never builds a URL, so it can pin per-element encryption but never encoding.

.in() on an encrypted column was completely broken

Every encrypted operand is a serialized envelope, dense with " and ,. postgrest-js wraps a comma-bearing element as `"${s}"` (PostgrestFilterBuilder.ts:811-819) and never escapes the quotes already inside it, so we emitted:

in.("{"v":1,"c":"…"}",…)
       ^ PostgREST ends the value here → PGRST100

Encrypted lists now go through filter(col, 'in', …) with each element quoted and escaped, matching what the .or() path already did.

This affects v2 as well as v3. v2's ("a@b.com") composite literal is itself quote-bearing and was equally broken. Three existing tests asserted the old, broken encoding and are updated.

.not(col, 'in', […]) encrypted the whole list as one ciphertext

The not-collector had no element-wise in branch — compare the regular filter collector, which pushes one term per element. isEncryptableTerm only checks op !== 'is' && value != null, so an array sailed through and was encrypted whole. The filter then silently matched nothing, and postgrest emitted an unparenthesized not.in.a,b.

This is the same defect that was fixed for .or() in 81d2eb28 and left unfixed on the direct .not() path. There was no test for it. It now encrypts element-wise and emits not.in.(…); a PostgREST list literal on an encrypted column throws rather than failing silently.

is(col, null) on storage-only columns

is is never encrypted (isEncryptableTerm rejects it outright, so no term is collected and no capability guard runs) and a NULL plaintext is stored as a SQL NULL. On a storage-only column (types.Boolean, types.Integer, …) IS NULL is therefore not merely legal but the only predicate available — yet the type surface rejected it. A null-only overload widens it to every row key. is(col, true) remains a compile error on encrypted columns, since you cannot IS TRUE-compare a jsonb ciphertext to a plaintext boolean.

contains() operand, and plaintext containment in .or()

A plaintext jsonb/array column falls through to PostgREST's native containment, so contains('tags', ['vip']) and contains('meta', { plan: 'pro' }) now typecheck; encrypted match columns still take a string token.

Relatedly, .or([{ op: 'contains' }]) only translated containscs for encrypted conditions, so a plaintext containment reached the wire as .contains. and failed to parse. The translation is now unconditional and lives in rebuildOrString, and splitOrString tracks {} so an array/jsonb literal's commas no longer split the condition mid-value.

DOMAIN_REGISTRY derived from types

The comment justifying the hand-written map ("TextSearch has a different arity") went stale when 8123839c removed the freeTextSearch tuner; all 39 factories are now uniformly (name: string) => Column. Deriving them makes drift impossible.

Deriving alone would be unsafe: once the registry is built from stripDomainSchema(factory().getEqlType()), any test that also derives its expectation measures the source against itself. Corrupt a domain constant in columns.ts and the registry silently re-keys, introspection ships a wrong SQL parameter, real columns are misclassified as unmodelled — with every test green. So the derivation lands together with a hand-written literal list of the 39 domain names, which is the external-contract anchor. Verified keys-preserving: 39 keys, unchanged, null prototype intact.

Tests

A new __tests__/helpers/postgrest-wire.ts runs the real PostgrestClient against a capturing fetch, so the emitted query string is asserted without a database. @supabase/postgrest-js is already a devDependency. Both P1 bugs above were invisible to the old harness and are caught trivially by this one.

Also adds biting coverage for three null-prototype maps that were correct but unpinned (mergeDeclaredTables, the v3Columns pattern-filter lookup, the mutation-model rebuild) and for the unreachable scalar-queryType backstop.

Every fix was written test-first, and each test was verified to bite — the fix reverted, the test confirmed failing, then restored. That step is not ceremony here: the three null-prototype items exist precisely because correct fixes shipped alongside tests that never bit.

Verification

  • 559 tests pass across the 11 supabase / EQL-v3 suites.
  • 88 type tests pass, no type errors.
  • Biome error count unchanged from the base (1, pre-existing).
  • The 18 remaining package failures are credential-gated suites that fail identically on a clean tree.

.in() and .not(…, 'in', …) are wire-format changes, so the live PostgREST suite (supabase-v3-pgrest-live.test.ts, requires DATABASE_URL) is the end-to-end proof and should be run before merge.

Follow-up to #588. Two of these are broken features, not polish; both were
invisible to the existing tests because `createMockSupabase` records the
arguments handed to each builder method and never builds a URL.

`.in()` on an encrypted column produced a request PostgREST rejects. Every
encrypted operand is a serialized envelope, dense with `"` and `,`, and
postgrest-js wraps a comma-bearing element as `"…"` without escaping the quotes
already inside it:

    in.("{"v":1,"c":"…"}",…)
           ^ PostgREST ends the value here -> PGRST100

Encrypted lists now go through `filter(col, 'in', …)` with each element quoted
and escaped, as the `.or()` path already did. This affects v2 as well as v3 —
v2's `("a@b.com")` composite literal is itself quote-bearing and was equally
broken — so three existing tests that asserted the old encoding are updated.

`.not(col, 'in', […])` encrypted the whole list as a single ciphertext, so the
filter silently matched nothing, and emitted an unparenthesized `not.in.a,b`.
The not-collector now encrypts element-wise, mirroring the regular `in` and
`or(… .in. …)` paths. A PostgREST list literal on an encrypted column throws
rather than silently matching nothing.

`is(col, null)` is widened to every row key. `is` is never encrypted and a NULL
plaintext is stored as a SQL NULL, so on a storage-only column `IS NULL` is not
merely legal but the only predicate available. `is(col, true)` stays a compile
error on encrypted columns.

`contains()` accepts native operands on plaintext columns, which fall through to
PostgREST's native containment. Relatedly, `.or([{ op: 'contains' }])` now emits
`cs` for plaintext conditions too — previously only encrypted ones were
translated, so plaintext containment reached the wire as `.contains.` and failed
to parse — and `splitOrString` tracks `{}` so an array/jsonb literal's commas no
longer split the condition mid-value.

`DOMAIN_REGISTRY` is derived from `types` rather than hand-listed, so the two
cannot drift. The keys are an external contract (the information_schema query
parameter), and because they are now derived from `getEqlType()`, no test that
also derives them can detect a corrupted domain constant — the test file pins
them against a hand-written literal list for exactly that reason.

Tests: a `postgrest-wire` harness runs the real PostgrestClient against a
capturing fetch, so operand serialization is asserted without a database. Adds
biting coverage for the three null-prototype maps (`mergeDeclaredTables`,
`v3Columns` pattern-filter lookup, mutation-model rebuild) and for the
unreachable scalar-queryType backstop.
@tobyhede tobyhede requested a review from a team as a code owner July 9, 2026 12:11
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b27b7a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 805dda22-b045-4bda-bd3b-263d71a4a092

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eql-v3-supabase-adapter-follow-up

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the Supabase adapter’s PostgREST wire-format handling for encrypted filters (notably in/not(..., 'in', ...)), broadens the v3 type surface for contains() and is(col, null), and reduces drift risk in the EQL v3 domain registry by deriving it from types while still pinning the external contract via tests.

Changes:

  • Fix encrypted in() / not(..., 'in', ...) to emit parseable PostgREST operands (escape quote-bearing ciphertexts; encrypt not.in element-wise).
  • Make contains() accept native jsonb/array operands on plaintext columns (and ensure .or() always translates containscs, preserving containment literals during parse/rebuild).
  • Derive DOMAIN_REGISTRY from types and add wire-format tests via a real PostgrestClient + capturing fetch.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/stack/src/supabase/types.ts Adds conditional operand typing for contains(), and widens is(col, null) beyond filterable keys.
packages/stack/src/supabase/query-builder.ts Applies encrypted in via filter('in', ...) with explicit operand formatting; fixes not(..., 'in', ...) element-wise handling; simplifies or() rebuild flow.
packages/stack/src/supabase/query-builder-v3.ts Removes v3-only or() operator shaping hook now that containscs is handled centrally.
packages/stack/src/supabase/helpers.ts Centralizes containscs, adds containment-literal formatting, tracks {} during .or() string splitting, and introduces formatInListOperand.
packages/stack/src/eql/v3/domain-registry.ts Derives the domain registry from types, preserving null-prototype safety and adding collision protection.
packages/stack/tests/supabase-v3.test-d.ts Expands type-level coverage for contains() operand typing and is(col, null) on storage-only columns.
packages/stack/tests/supabase-v3-wire.test.ts New wire-format tests asserting the real serialized query string for encrypted in / not.in.
packages/stack/tests/supabase-v3-pgrest-live.test.ts Extends live PostgREST suite with plaintext tags/meta containment coverage.
packages/stack/tests/supabase-v3-builder.test.ts Updates mock-based assertions to reflect filter('in', ...) usage and adds additional edge-case coverage.
packages/stack/tests/supabase-schema-builder.test.ts Pins null-prototype / __proto__-safety in schema merging.
packages/stack/tests/supabase-helpers.test.ts Adds focused unit coverage for containment formatting and parsing stability.
packages/stack/tests/helpers/postgrest-wire.ts New helper to capture actual PostgREST request URLs without a DB.
packages/stack/tests/eql-v3-domain-registry.test.ts Pins the external domain-key contract via a literal list while validating derivation correctness.
.changeset/supabase-in-list-operands.md Changeset documenting the wire-format fixes and type-surface widening.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/stack/src/supabase/types.ts Outdated
Comment on lines +630 to +631
is<K extends FK>(column: K, value: null | boolean): Self
is<K extends StringKeyOf<T>>(column: K, value: null): Self
Comment on lines +80 to +84
const builder = supabase.from('users')
builder.is('active', null)
builder.is('email', null)
builder.is('email', true)
})

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with a multi-angle pass (line-by-line, removed-behavior, cross-file tracing, reuse/simplification/efficiency/altitude), each candidate independently verified against the head commit, the vendored EQL v3 types, and the installed @supabase/postgrest-js. This is a solid fix — the two headline bugs (.in() on encrypted columns, whole-list .not(…,'in',…) encryption) are real and correctly addressed, and the new postgrest-wire.ts harness that asserts the emitted query string is exactly the right tool for this class of bug. Verification also cleared several plausible-looking concerns (the empty-list not.in.() is byte-identical to what postgrest-js itself emits; the {x} containment literal matches postgrest-js and the SDK's array-element quoting is actually more correct than postgrest's). Findings below, most severe first.

Correctness

1. splitOrString mis-parses a containment literal whose string value contains } — silently drops the rest of the .or() (helpers.ts:331)

The quote gate only tracks quotes at the top level: else if (char === '"' && depth === 0). Inside a {…} literal (depth > 0) quotes are not tracked, so a } that appears inside a JSON string value is miscounted as structural. Trace .or('meta.cs.{"note":"a}b"},email.eq.x') where meta is an encrypted column (which forces the parse→rebuild path — parseOrString runs unconditionally in toDbSpace): the } inside "a}b" drops depth to 0 prematurely, the following " then flips inQuotes true at depth 0, and the top-level comma before email.eq.x is no longer split. email.eq.x is swallowed into meta's operand and dropped from the query — PostgREST gets a syntactically valid but wrong filter tree and returns the wrong rows, with no error. This is the same silent-mismatch class the {}-tracking change set out to fix; the fix is incomplete because quotes must be tracked at every depth (and escapeOrValue can itself emit such a }-bearing literal, so emit and parse are mutually inconsistent). Related lower-severity gap: splitOrString tracks {} and () but not [], so a hand-written .or('meta.cs.[1,2,3],…') on an encrypted column splits mid-array — narrower because the SDK's own emit path never produces a top-level [...].

Deeper point: the SDK is re-encoding and re-parsing PostgREST's grammar by hand (splitOrString, formatOrValue, two separate reserved-char regexes POSTGREST_RESERVED and ARRAY_ELEMENT_RESERVED that already disagree). Every grammar edge (braces-in-strings, [], backslashes, unicode) is a latent bug the mock-based tests can't see. Worth considering whether the structured conditions can be carried through and serialized once by postgrest-js rather than round-tripped through a flat string the SDK must re-parse.

2. is(col, true) compiles on every non-storage-only encrypted column, contradicting the stated invariant — and a test bakes it in (types.ts:630, supabase-v3.test-d.ts)

The PR body and the doc comment both claim "is(col, true) remains a compile error on encrypted columns." It isn't. is<K extends FK>(column, value: null | boolean) is gated on FK = V3FilterableKeys, which excludes only NonQueryableV3Keys (storage-only columns). A text_search/text_eq/text_match/*_ord column carries a query capability, so it is in FK and matches the boolean overload. is('email', true) (where email is types.TextSearch) therefore typechecks and at runtime forwards q.is('email', true)email=is.true against a jsonb ciphertext → Postgres error. The new test-d asserts builder.is('email', true) with no @ts-expect-error (the @ts-expect-error sits only on active, a storage-only types.Boolean, which is why the author's spot-check passed) — so the suite actively locks in the wrong behavior. The boolean is overload needs to be gated on storage-only-or-plaintext keys, not FK, and the test corrected to expect a compile error on email.

3. contains() admits plaintext scalar columns with an array/object operand → runtime 42883 (types.ts:118 / 151)

V3FreeTextSearchableKeys subtracts only encrypted-without-match-index keys, so every plaintext key — including a scalar text/varchar column — stays in the accepted set, and V3ContainsValue gives it NativeContainsValue (string | readonly unknown[] | Record<…>). So contains('note', ['vip']) where note is a plaintext scalar text column typechecks, then emits note.cs.{vip}@> on text → Postgres 42883. The widening off the old blanket value: string is deliberate and the operand type intentionally mirrors postgrest-js's own untyped contains (which can't reject this either without column-type metadata), so it's bounded — but it's a real footgun that the old signature didn't have, worth a note in the doc comment at least.

Efficiency

4. v3 in-lists encrypt one FFI round-trip per element instead of one bulk call (query-builder-v3.ts:333)

The element-wise in/not(…,'in',…) change (correct for the bug) feeds N same-column terms into the v3 encryptCollectedTerms override, which does Promise.all(terms.map(t => client.encrypt(t.value, {column, table}))) — N concurrent ZeroKMS/FFI round-trips for in(col, [N values]). The v3 client already exposes bulkEncrypt(plaintexts, {column, table}) (encryption/v3.ts:112) whose shape is exactly a single-column in-list. Concurrent so not catastrophic, but an N=100 inArray is 100 invocations where one would do. (Same observation applies to the Drizzle v3 path in #565 — a shared bulkEncrypt routing for same-column term batches would fix both.)

Altitude / maintainability

5. Deriving DOMAIN_REGISTRY turns a shape change in a neighbouring export into a package-wide import crash (domain-registry.ts:31)

The derivation for (const factory of Object.values(types)) { … factory('_probe').getEqlType() … } runs at module load and assumes every value in types is a side-effect-free (name) => Column factory. If types ever gains a non-factory export (a constant, a helper), factory('_probe') throws during module evaluation — and domain-registry.ts is imported by introspect.ts, the schema builder, and the public @/eql/v3 barrel, so the throw takes down all v3 functionality at import, not one test. The hand-written map degraded gracefully (unknown key → undefined). The derived-plus-anchor-list pattern is otherwise good; consider constraining types's value type to V3ColumnFactory at the type level, or iterating a typed factory list rather than Object.values.

Cleanup

6. In-list term collection is copy-pasted across three sites (query-builder.ts:600)

The new not(…,'in') per-element block is near-identical to the regular-in block (~543) and the or-condition path (~661): same loop, isEncryptableTerm guard, terms.push({… queryType, returnType:'composite-literal'}), differing only in the termMap source tag. A shared collectInListTerms(op, values, column, pushMapping) would keep the three in lockstep — exactly the drift that left the not path unfixed until now. (Minor siblings: formatInListOperand is a one-line formatOrValue([...values]) wrapper, and the containscs mapping is encoded twice in CONTAINMENT_OPS and orOperatorToken.)

@freshtonic freshtonic self-requested a review July 9, 2026 12:26

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved with non-blocking feedback, but some items should be followed up.

tobyhede added 3 commits July 9, 2026 22:51
An `.or()` string is only rebuilt from its parse when it references an
encrypted column — otherwise the caller's string is forwarded verbatim —
so each of these corrupts precisely the mixed encrypted/plaintext case.

Quotes were tracked only at brace depth 0, so a `}` inside a quoted array
element or jsonb string value closed the literal early and the next `"`
re-opened quoting: the following top-level comma never split and its
condition was absorbed into the operand. Quotes are now opaque at every
depth.

A stray `}` or `)` drove the depth counter negative, after which no comma
split again. Neither is a PostgREST reserved character, so `a}b` is a
valid unquoted operand. Depth now floors at zero.

`in`-list elements were split on every comma, ignoring quotes, and the
quotes were left embedded in the fragments. On an encrypted column each
fragment became its own term, so `in.("Doe, John",Smith)` never matched.
Elements are now split on top-level commas and unquoted — the inverse of
what `rebuildOrString` emits.

A parenthesized operand was read as a list for every operator, so
`eq.(foo)` encrypted a JS array rather than the string. Only `in` and the
range operators take a paren-delimited operand.

A string operand spelling `null`/`true`/`false` is now quoted: PostgREST
reads a bare `null` as SQL NULL.

Finally, `contains(col, …)` on a union key spanning an encrypted and a
plaintext column accepted an array or object. A union is only as
permissive as its strictest member; any declared column in it pins the
operand to `string`. A literal column argument was never affected.
Fixes every finding from the PR #609 review that survived verification
against HEAD. Each was reproduced with a failing test before the fix.

`.or()` dropped a condition after an unbalanced opening brace or paren.
The depth floor added in 8cd485d recovers from a stray CLOSING brace,
but a stray OPENING one strands the counter above zero and no later
comma ever splits, swallowing every condition behind it. With a
plaintext column first, `referencesEncrypted` then reads false over the
surviving conditions and the group is forwarded verbatim — running the
swallowed condition against a ciphertext column with a plaintext
operand. Braces are now quoted on emit (PostgREST treats them as
structure inside `or=(…)`, so an unquoted scalar brace was malformed on
the wire regardless), and `splitTopLevel` re-splits honouring quotes
alone when its depth tracking does not balance. Containment literals
keep the narrow reserved set, so `tags.cs.{vip}` stays unquoted.

Direct `contains()` / `not(col, 'contains', …)` did not serialize their
operand. postgrest-js joins array elements on `,` without quoting, so
`contains('tags', ['with,comma'])` reached Postgres as two elements; its
`not()` interpolates with `String(value)`, emitting
`not.contains.with,comma` — no braces, wrong operator token — and
`[object Object]` for a jsonb operand. Both now build the same
containment literal the `.or()` path builds, and emit `cs`.

`is(col, true)` compiled on queryable encrypted columns. The boolean
overload was gated on the filterable keys, which exclude only the
storage-only columns, so a `text_search`/`text_eq`/`*_ord` column
matched it and emitted `IS TRUE` against a jsonb ciphertext. It now
takes a dedicated `BK` parameter, kept distinct from the orderable `OK`
so the two capability axes can diverge. The type test asserted the bug;
it now asserts the compile error.

`contains()` admitted arrays on plaintext SCALAR columns, emitting `@>`
on `text` (42883). The plaintext operand now follows the column's own
shape, mapping scalars to `never`. It distributes over `Row[K]`, which
is sound only because the tuple guard already excludes every encrypted
member; the residual case is documented.

In-list operands spent one ZeroKMS crossing per element. Terms are now
grouped by column — mandatory, since `bulkEncrypt` carries one
`{table, column}` for the whole payload — and each group takes a single
call, scattered back onto its original indices. Mirrors the Drizzle v3
path, keeps a per-term fallback, and rejects a length mismatch rather
than silently truncating the predicate.

`types` is now `satisfies Record<string, V3ColumnFactory>`. The derived
`DOMAIN_REGISTRY` calls every value at module load, so a non-factory
export would throw and take the supabase introspect/schema-build/verify
path with it; that is now a compile error at the offending line. The
blind `Object.values(types) as V3ColumnFactory[]` cast that would have
silenced it is gone.

In-list term collection is unified behind `collectInListTerms` — the
three copies had already drifted once, leaving the `not` path unfixed.
`splitTopLevel` treated every unquoted brace and paren as structure. Neither
`{`/`(` mid-operand nor `}`/`)` is a PostgREST reserved character, so `a{b` and
`a}b` are valid unquoted scalars — and counting them desynchronised the split.
A stray closer drove the depth negative, a stray opener stranded it above zero,
and either way no later comma split: every remaining condition was absorbed
into the preceding operand.

Flooring the depth at zero fixed the closer. The opener was handled by
discarding the whole depth pass whenever the count ended unbalanced and
re-splitting on quotes alone — which trades the bug for its mirror image. Given
both a stray opener and a real containment literal, the re-split lands inside
the literal:

    note.eq.a{b,tags.cs.{vip,admin}
      -> ["note.eq.a{b", "tags.cs.{vip", "admin}"]

`admin}` carries no dot, so `parseOrString` drops it.

A brace or paren is structure only where the grammar can put one: opening a
logic group (`and(`, `or(`, and their `not.` forms), opening an operand right
after the operator dot, or nested inside a literal already open. Count those and
nothing else. The unbalanced-depth re-split stays as a backstop for the one case
the rule still misreads — a scalar whose brace follows an in-value dot,
`x.eq.a.{b` — where it is a recovery rather than the primary mechanism.

Both paths are reachable only through a raw-string `.or()`: `rebuildOrString`
quotes braces in scalars, so the adapter never emits the trigger itself.

Also pins the two containment operands `arrayLiteralElement` special-cases but
no wire test drove: an empty element must emit `{""}`, not the empty array `{}`,
and an element spelling `null` must emit `{"null"}`, not a SQL NULL.
@tobyhede tobyhede merged commit bf3e1ea into feat/eql-v3-text-search-schema Jul 9, 2026
7 checks passed
@tobyhede tobyhede deleted the feat/eql-v3-supabase-adapter-follow-up branch July 9, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants