@pattern-stack/codegen 0.6.5 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/dist/runtime/subsystems/auth/auth.module.js +1 -1
- package/dist/runtime/subsystems/auth/auth.module.js.map +1 -1
- package/dist/runtime/subsystems/auth/auth.tokens.d.ts +1 -1
- package/dist/runtime/subsystems/auth/auth.tokens.js.map +1 -1
- package/dist/runtime/subsystems/auth/backends/encryption-key/env.d.ts +1 -1
- package/dist/runtime/subsystems/auth/backends/encryption-key/env.js +1 -1
- package/dist/runtime/subsystems/auth/backends/encryption-key/env.js.map +1 -1
- package/dist/runtime/subsystems/auth/controllers/auth.controller.js.map +1 -1
- package/dist/runtime/subsystems/auth/index.d.ts +1 -1
- package/dist/runtime/subsystems/auth/index.js +1 -1
- package/dist/runtime/subsystems/auth/index.js.map +1 -1
- package/dist/runtime/subsystems/auth/protocols/auth-strategy.d.ts +1 -1
- package/dist/runtime/subsystems/auth/protocols/integration-store.d.ts +2 -2
- package/dist/runtime/subsystems/auth/protocols/provider-strategy.d.ts +13 -6
- package/dist/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.d.ts +2 -2
- package/dist/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.js.map +1 -1
- package/dist/runtime/subsystems/auth/runtime/session-expired.error.d.ts +2 -2
- package/dist/runtime/subsystems/auth/runtime/session-expired.error.js.map +1 -1
- package/dist/runtime/subsystems/auth/runtime/with-auth-retry.d.ts +1 -1
- package/dist/runtime/subsystems/auth/runtime/with-auth-retry.js.map +1 -1
- package/dist/runtime/subsystems/index.d.ts +1 -1
- package/dist/runtime/subsystems/index.js +1 -1
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/sync/deep-equal.differ.js.map +1 -1
- package/dist/runtime/subsystems/sync/execute-sync.use-case.js.map +1 -1
- package/dist/runtime/subsystems/sync/index.js.map +1 -1
- package/dist/runtime/subsystems/sync/sync-change-source.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/sync/sync-cursor-store.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/sync/sync-loopback.protocol.d.ts +3 -4
- package/dist/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/sync/sync.module.js.map +1 -1
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/subsystems/auth/auth.tokens.ts +1 -1
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +3 -3
- package/runtime/subsystems/auth/controllers/auth.controller.ts +3 -3
- package/runtime/subsystems/auth/index.ts +2 -2
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +1 -1
- package/runtime/subsystems/auth/protocols/integration-store.ts +2 -2
- package/runtime/subsystems/auth/protocols/provider-strategy.ts +12 -5
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +2 -2
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +2 -2
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +1 -1
- package/runtime/subsystems/index.ts +1 -1
- package/runtime/subsystems/sync/deep-equal.differ.ts +1 -1
- package/runtime/subsystems/sync/execute-sync.use-case.ts +1 -1
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +1 -1
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +1 -1
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +3 -4
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +1 -1
- package/templates/subsystem/auth/app-module-hook.ejs.t +1 -1
- package/templates/subsystem/auth/env-config.ejs.t +5 -5
- package/templates/subsystem/auth/prompt.js +3 -3
- package/templates/subsystem/auth-config/codegen-config-auth-block.ejs.t +1 -1
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* `HUBSPOT_AUTH_STRATEGY`) by each integration module — this subsystem does
|
|
24
24
|
* not mandate a single `AUTH_STRATEGY` token because an app typically has
|
|
25
25
|
* many concurrent strategies, one per provider. They are dispatched through
|
|
26
|
-
* `STRATEGY_REGISTRY` (a `ReadonlyMap<slug,
|
|
26
|
+
* `STRATEGY_REGISTRY` (a `ReadonlyMap<slug, IProviderStrategy>`), populated
|
|
27
27
|
* by per-provider modules via a `useFactory` provider.
|
|
28
28
|
*/
|
|
29
29
|
export const ENCRYPTION_KEY = Symbol('ENCRYPTION_KEY');
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* ciphertexts — prevents replay-style inference. Auth tag enforces integrity;
|
|
7
7
|
* any tampering throws on decrypt.
|
|
8
8
|
*
|
|
9
|
-
* Key source: `
|
|
9
|
+
* Key source: `INTEGRATION_TOKEN_ENCRYPTION_KEY` env var, 32 bytes base64-encoded.
|
|
10
10
|
* Generate via `openssl rand -base64 32`.
|
|
11
11
|
*
|
|
12
12
|
* Future backend: `kms.ts` (AWS/GCP KMS) for production deployments that
|
|
@@ -18,7 +18,7 @@ import type { IEncryptionKey } from '../../protocols/encryption-key';
|
|
|
18
18
|
export interface EnvEncryptionKeyOptions {
|
|
19
19
|
/** Defaults to `process.env`. Tests inject a fixture. */
|
|
20
20
|
env?: NodeJS.ProcessEnv;
|
|
21
|
-
/** Defaults to `'
|
|
21
|
+
/** Defaults to `'INTEGRATION_TOKEN_ENCRYPTION_KEY'`. */
|
|
22
22
|
envVar?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -32,7 +32,7 @@ export class EnvEncryptionKey implements IEncryptionKey {
|
|
|
32
32
|
|
|
33
33
|
constructor(opts: EnvEncryptionKeyOptions = {}) {
|
|
34
34
|
const env = opts.env ?? process.env;
|
|
35
|
-
const envVar = opts.envVar ?? '
|
|
35
|
+
const envVar = opts.envVar ?? 'INTEGRATION_TOKEN_ENCRYPTION_KEY';
|
|
36
36
|
const raw = env[envVar];
|
|
37
37
|
if (!raw) {
|
|
38
38
|
throw new Error(
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 302-redirects to the post-connect path.
|
|
10
10
|
*
|
|
11
11
|
* Hexagonal seams:
|
|
12
|
-
* - `STRATEGY_REGISTRY` (ReadonlyMap<slug,
|
|
12
|
+
* - `STRATEGY_REGISTRY` (ReadonlyMap<slug, IProviderStrategy>) — dispatch.
|
|
13
13
|
* Concrete per-provider strategies live consumer-side and contribute
|
|
14
14
|
* entries via a `useFactory` in the consumer's app module.
|
|
15
15
|
* - `AUTH_USER_CONTEXT` (IUserContext) — resolves "who is this request"
|
|
@@ -44,7 +44,7 @@ import type { AuthModuleOptions } from '../auth.module';
|
|
|
44
44
|
import type { IOAuthStateStore } from '../protocols/oauth-state-store';
|
|
45
45
|
import type { IUserContext } from '../protocols/user-context';
|
|
46
46
|
import type {
|
|
47
|
-
|
|
47
|
+
IProviderStrategy,
|
|
48
48
|
ProviderStrategyRegistry,
|
|
49
49
|
} from '../protocols/provider-strategy';
|
|
50
50
|
import type { IIntegrationGrantSink } from '../protocols/integration-store';
|
|
@@ -131,7 +131,7 @@ export class AuthController {
|
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
private requireStrategy(slug: string):
|
|
134
|
+
private requireStrategy(slug: string): IProviderStrategy {
|
|
135
135
|
const strategy = this.registry.get(slug);
|
|
136
136
|
if (!strategy) {
|
|
137
137
|
throw new HttpException(
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* type IIntegrationGrantSink,
|
|
30
30
|
* type IntegrationGrantInput,
|
|
31
31
|
* type IUserContext,
|
|
32
|
-
* type
|
|
32
|
+
* type IProviderStrategy,
|
|
33
33
|
* type ProviderStrategyRegistry,
|
|
34
34
|
* type ExchangedTokens,
|
|
35
35
|
* } from '@pattern-stack/codegen/runtime/subsystems/auth';
|
|
@@ -58,7 +58,7 @@ export type {
|
|
|
58
58
|
} from './protocols/integration-store';
|
|
59
59
|
export type { IUserContext } from './protocols/user-context';
|
|
60
60
|
export type {
|
|
61
|
-
|
|
61
|
+
IProviderStrategy,
|
|
62
62
|
ProviderStrategyRegistry,
|
|
63
63
|
ExchangedTokens,
|
|
64
64
|
} from './protocols/provider-strategy';
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `OAuth2RefreshStrategy` template-method base in `../runtime/`.
|
|
8
8
|
*
|
|
9
9
|
* See `docs/adrs/ADR-031-auth-subsystem.md` and
|
|
10
|
-
* `docs/gate-1-auth-extraction-findings.md` (
|
|
10
|
+
* `docs/gate-1-auth-extraction-findings.md` (extraction-source findings) for
|
|
11
11
|
* the rationale.
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* those rows — consumers implement these narrow ports against whatever
|
|
7
7
|
* `integrations` table their app uses.
|
|
8
8
|
*
|
|
9
|
-
* In
|
|
9
|
+
* In the extraction-source app both ports are satisfied by a
|
|
10
10
|
* pair of thin adapters over `IntegrationService` + `RefreshIntegrationUseCase`.
|
|
11
11
|
* The codegen-patterns `examples/auth-integrations/` starter (separate PR)
|
|
12
12
|
* ships a canonical `integration.yaml` whose generated service + use case
|
|
@@ -70,7 +70,7 @@ export interface IIntegrationTokenWriter {
|
|
|
70
70
|
* authorize-code callback (i.e. the user just connected a new provider, or
|
|
71
71
|
* re-connected an existing one).
|
|
72
72
|
*
|
|
73
|
-
* `AuthController.callback` invokes this after `
|
|
73
|
+
* `AuthController.callback` invokes this after `IProviderStrategy.exchangeCodeForTokens`.
|
|
74
74
|
* The subsystem itself never imports a concrete `IntegrationsService` — the
|
|
75
75
|
* consumer's `auth-integrations` starter (or any equivalent) adapts this
|
|
76
76
|
* port. Keeps the auth subsystem standalone: a non-codegen consumer can
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth subsystem — `
|
|
2
|
+
* Auth subsystem — `IProviderStrategy` contract.
|
|
3
3
|
*
|
|
4
4
|
* Extension of `OAuth2RefreshStrategy` (which already covers the refresh
|
|
5
5
|
* path) that adds the two methods needed by the connect/callback dance:
|
|
@@ -11,11 +11,18 @@
|
|
|
11
11
|
* stay consumer-side per ADR-031 ("every app has different combinations").
|
|
12
12
|
* They typically subclass `OAuth2RefreshStrategy` for the refresh path and
|
|
13
13
|
* implement these two methods structurally — that satisfies
|
|
14
|
-
* `
|
|
14
|
+
* `IProviderStrategy` because TS lets interfaces extend classes by type.
|
|
15
15
|
*
|
|
16
16
|
* AuthController never imports a concrete strategy — it injects the
|
|
17
|
-
* `STRATEGY_REGISTRY` (a `ReadonlyMap<provider-slug,
|
|
17
|
+
* `STRATEGY_REGISTRY` (a `ReadonlyMap<provider-slug, IProviderStrategy>`)
|
|
18
18
|
* and dispatches by slug.
|
|
19
|
+
*
|
|
20
|
+
* **Naming convention:** interfaces that describe behavioral ports use the
|
|
21
|
+
* `I` prefix (`IProviderStrategy`, `IIntegrationReader`, `IUserContext`,
|
|
22
|
+
* `IOAuthStateStore`, `IEncryptionKey`). Plain data types / DTOs (e.g.
|
|
23
|
+
* `ExchangedTokens`, `DecryptedIntegration`, `IntegrationGrantInput`) do
|
|
24
|
+
* not. Abstract template-method classes (e.g. `OAuth2RefreshStrategy`) also
|
|
25
|
+
* do not — the `I` is for interfaces only.
|
|
19
26
|
*/
|
|
20
27
|
import type { OAuth2RefreshStrategy } from '../runtime/oauth2-refresh.strategy';
|
|
21
28
|
|
|
@@ -29,7 +36,7 @@ export interface ExchangedTokens {
|
|
|
29
36
|
providerMetadata?: Record<string, unknown>;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
export interface
|
|
39
|
+
export interface IProviderStrategy extends OAuth2RefreshStrategy {
|
|
33
40
|
buildAuthorizeUrl(args: { state: string; redirectUri: string }): string;
|
|
34
41
|
exchangeCodeForTokens(args: {
|
|
35
42
|
code: string;
|
|
@@ -38,4 +45,4 @@ export interface ProviderStrategy extends OAuth2RefreshStrategy {
|
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/** The DI value type behind the `STRATEGY_REGISTRY` token. */
|
|
41
|
-
export type ProviderStrategyRegistry = ReadonlyMap<string,
|
|
48
|
+
export type ProviderStrategyRegistry = ReadonlyMap<string, IProviderStrategy>;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Abstract base class for OAuth2 refresh-token strategies.
|
|
3
3
|
*
|
|
4
4
|
* Template-method pattern: `resolve()` is concrete; four small hooks inject
|
|
5
|
-
* provider specifics. Validated across two providers
|
|
6
|
-
*
|
|
5
|
+
* provider specifics. Validated across two providers (Salesforce, HubSpot)
|
|
6
|
+
* in the extraction-source app before being extracted here — see
|
|
7
7
|
* `docs/gate-1-auth-extraction-findings.md` for the "build first, extract
|
|
8
8
|
* later" evidence.
|
|
9
9
|
*
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* the `isSessionExpiredError` predicate to decide whether to force-refresh
|
|
9
9
|
* and retry once.
|
|
10
10
|
*
|
|
11
|
-
* This discriminator replaces the SFDC-only `instanceof` check from
|
|
12
|
-
*
|
|
11
|
+
* This discriminator replaces the SFDC-only `instanceof` check from the
|
|
12
|
+
* extraction-source app's original `withAuthRetry`. See
|
|
13
13
|
* `docs/gate-1-auth-extraction-findings.md` (recommendation 4).
|
|
14
14
|
*/
|
|
15
15
|
export class SessionExpiredError extends Error {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* on the refreshed token propagates rather than looping, so transient
|
|
7
7
|
* adapter bugs can't hang the caller.
|
|
8
8
|
*
|
|
9
|
-
* Generalisation over
|
|
9
|
+
* Generalisation over the extraction source's SFDC-specific original: the
|
|
10
10
|
* session-expired classifier is injected. Providers mark their session-
|
|
11
11
|
* expired errors (via `instanceof` of a marker class, or by setting a known
|
|
12
12
|
* property) and pass a classifier matching that shape.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* per-field diff (`{ from, to }`) for every field whose value changed.
|
|
6
6
|
* Returns `'noop'` when the record is unchanged.
|
|
7
7
|
*
|
|
8
|
-
* Design decisions (extracted from
|
|
8
|
+
* Design decisions (extracted from the upstream consumer + HS-9 findings):
|
|
9
9
|
*
|
|
10
10
|
* 1. **Ignore list** — row metadata that sinks/services stamp unconditionally
|
|
11
11
|
* so upstream cannot reasonably disagree:
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* is strictly provider-agnostic:
|
|
42
42
|
* - `entityType` is `string` throughout; no `'opportunity' | 'account' | ...`
|
|
43
43
|
* narrowing leaks into the use case
|
|
44
|
-
* -
|
|
44
|
+
* - the upstream consumer's `SyncRunRecorderService` class injection replaced with the
|
|
45
45
|
* `ISyncRunRecorder` protocol (backend lands in SYNC-4)
|
|
46
46
|
*/
|
|
47
47
|
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* depend on a specific backend implementation.
|
|
7
7
|
*
|
|
8
8
|
* Three detection modes (poll / cdc / webhook) converge on this single port
|
|
9
|
-
* per ADR-0002 (
|
|
9
|
+
* per ADR-0002 (the upstream consumer). Per-mode differences live in the
|
|
10
10
|
* `Change.source` / `dedupKey` / `providerChangedFields` metadata fields,
|
|
11
11
|
* not in separate ports.
|
|
12
12
|
*
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* could occur. Tests that want to assert per-tenant isolation should
|
|
22
22
|
* target the Drizzle backend.
|
|
23
23
|
*
|
|
24
|
-
* Not shipped in
|
|
24
|
+
* Not shipped in the upstream consumer; this is a subsystem-first addition for the
|
|
25
25
|
* test surface. Consumed by:
|
|
26
26
|
* - SYNC-5 unit tests (`ExecuteSyncUseCase` against synthetic sources)
|
|
27
27
|
* - SYNC-6 module tests (`SyncModule.forRoot({ backend: 'memory' })`)
|
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
* a backend in Phase 1; consumers that need loopback suppression provide
|
|
15
15
|
* their own (redis-hashed, memory TTL, etc.).
|
|
16
16
|
*
|
|
17
|
-
* `entityType` is `string` (not a union) — per
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* want.
|
|
17
|
+
* `entityType` is `string` (not a union) — per HS-9 findings the
|
|
18
|
+
* CRM-specific narrowing `'opportunity' | 'account' | 'contact'` bled into
|
|
19
|
+
* the port and had to be removed. Consumers narrow internally if they want.
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
export interface ILoopbackFingerprintStore<T = unknown> {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DrizzleSyncRunRecorder — Drizzle-backed `ISyncRunRecorder` (SYNC-4).
|
|
3
3
|
*
|
|
4
|
-
* Generic write path only — extracted from
|
|
4
|
+
* Generic write path only — extracted from the source app's
|
|
5
5
|
* `SyncRunRecorderService`, minus CRM-specific convenience methods. Those
|
|
6
6
|
* stay consumer-owned; the subsystem ships the substrate.
|
|
7
7
|
*
|
|
@@ -17,5 +17,5 @@ skip_if: "AuthModule"
|
|
|
17
17
|
// redirectUriBase: process.env.AUTH_REDIRECT_URI_BASE ?? '<%= redirectUriBase %>',
|
|
18
18
|
// }),
|
|
19
19
|
//
|
|
20
|
-
// Requires
|
|
20
|
+
// Requires INTEGRATION_TOKEN_ENCRYPTION_KEY in your environment (see .env.config).
|
|
21
21
|
// Provide an IUserContext adapter (your app's session/JWT scheme).
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
to: "<%= envConfigPath %>"
|
|
3
3
|
inject: true
|
|
4
4
|
append: true
|
|
5
|
-
skip_if: "
|
|
5
|
+
skip_if: "INTEGRATION_TOKEN_ENCRYPTION_KEY"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# OAuth integration token encryption key — 32 bytes base64 (AES-256-GCM).
|
|
9
9
|
# DO NOT REUSE this dev value in prod. To regenerate locally:
|
|
10
10
|
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
11
11
|
# Re-running `codegen subsystem install auth` does NOT rotate this key —
|
|
12
|
-
# `skip_if: "
|
|
13
|
-
# silently rotating would invalidate every encrypted token already in
|
|
14
|
-
# consumer's `integrations` table. Rotation is a separate, auditable op.
|
|
12
|
+
# `skip_if: "INTEGRATION_TOKEN_ENCRYPTION_KEY"` blocks the inject intentionally,
|
|
13
|
+
# because silently rotating would invalidate every encrypted token already in
|
|
14
|
+
# the consumer's `integrations` table. Rotation is a separate, auditable op.
|
|
15
15
|
# In production: store the key in `secrets/secrets.yaml` (or your secret
|
|
16
16
|
# manager) and have your deploy pipeline materialise it into the env at
|
|
17
17
|
# boot — this `.env.config` line should be a placeholder, not the source.
|
|
18
|
-
|
|
18
|
+
INTEGRATION_TOKEN_ENCRYPTION_KEY=<%= tokenEncryptionKey %>
|
|
19
19
|
# Public base URL where OAuth providers redirect back. Override in staging/prod.
|
|
20
20
|
AUTH_REDIRECT_URI_BASE=<%= redirectUriBase %>
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
* - `app-module-hook.ejs.t` — appends a TODO comment block to
|
|
22
22
|
* app.module.ts directing the human to register
|
|
23
23
|
* `AuthModule.forRoot({ ... })`. Same convention as observability.
|
|
24
|
-
* - `env-config.ejs.t` — appends `
|
|
25
|
-
* `AUTH_REDIRECT_URI_BASE=<url>` to `.env.config`. Idempotent via
|
|
26
|
-
* `skip_if: "
|
|
24
|
+
* - `env-config.ejs.t` — appends `INTEGRATION_TOKEN_ENCRYPTION_KEY=<b64>`
|
|
25
|
+
* and `AUTH_REDIRECT_URI_BASE=<url>` to `.env.config`. Idempotent via
|
|
26
|
+
* `skip_if: "INTEGRATION_TOKEN_ENCRYPTION_KEY"` — re-running install does NOT
|
|
27
27
|
* regenerate the key (rotation is a separate operation).
|
|
28
28
|
*
|
|
29
29
|
* Auth has NO `multi_tenant` knob (see auth-scaffold-locals.ts docstring).
|
|
@@ -6,7 +6,7 @@ skip_if: "auth:"
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
auth:
|
|
9
|
-
# ── Encryption key (
|
|
9
|
+
# ── Encryption key (INTEGRATION_TOKEN_ENCRYPTION_KEY from .env.config) ──
|
|
10
10
|
encryption_key: env
|
|
11
11
|
|
|
12
12
|
# ── OAuth state store (drizzle for prod, memory for tests) ──
|