okengine 0.17.2 → 0.18.4
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/AGENTS.md +5 -3
- package/README.md +14 -10
- package/manifest.v1.schema.json +61 -2
- package/package.json +19 -5
- package/site/content/docs/ai/index.mdx +1 -1
- package/site/content/docs/ai/mcp.mdx +10 -1
- package/site/content/docs/ai/skills.mdx +9 -9
- package/site/content/docs/elements/ai.mdx +1 -1
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/gate.mdx +3 -2
- package/site/content/docs/elements/store.mdx +289 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/basic-usage.mdx +3 -10
- package/site/content/docs/get-started/index.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -3
- package/site/content/docs/get-started/introduction.mdx +58 -121
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/project-structure.mdx +4 -11
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +93 -71
- package/site/content/docs/index.mdx +44 -11
- package/site/content/docs/meta.json +8 -5
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/providers/index.mdx +2 -0
- package/site/content/docs/recipes/index.mdx +2 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +5 -2
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol-named OAuth client driver contracts.
|
|
3
|
+
*
|
|
4
|
+
* Two provider shapes (locked design):
|
|
5
|
+
* - `oidc` — discovery + JWKS-verified ID tokens (Google, Apple, Microsoft)
|
|
6
|
+
* - `oauth2` — code → access token → userinfo-equivalent, normalized into the
|
|
7
|
+
* same {@link OAuthAssertion} ("OIDC wrapper" pattern; GitHub, Discord, X,
|
|
8
|
+
* Facebook, Figma)
|
|
9
|
+
*
|
|
10
|
+
* Every driver enforces Authorization Code + PKCE (S256). Implicit and ROPC
|
|
11
|
+
* grants are never implemented.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Supported social providers. */
|
|
15
|
+
export type OAuthDriverId =
|
|
16
|
+
| "apple"
|
|
17
|
+
| "discord"
|
|
18
|
+
| "facebook"
|
|
19
|
+
| "figma"
|
|
20
|
+
| "github"
|
|
21
|
+
| "google"
|
|
22
|
+
| "microsoft"
|
|
23
|
+
| "x";
|
|
24
|
+
|
|
25
|
+
/** Token payload returned by a provider's token endpoint. */
|
|
26
|
+
export interface OAuthTokenSet {
|
|
27
|
+
readonly accessToken: string;
|
|
28
|
+
readonly refreshToken?: string;
|
|
29
|
+
/** Epoch-ms expiry of the access token when the provider reports one. */
|
|
30
|
+
readonly expiresAt?: number;
|
|
31
|
+
readonly scopes?: readonly string[];
|
|
32
|
+
/** Raw OIDC ID token (base64url JWT) — `oidc` drivers only. */
|
|
33
|
+
readonly idToken?: string;
|
|
34
|
+
/** Full provider response (secrets included) — never logged. */
|
|
35
|
+
readonly raw: Readonly<Record<string, unknown>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Input to {@link OAuthDriver.buildAuthorizeUrl}. */
|
|
39
|
+
export interface OAuthAuthorizeInput {
|
|
40
|
+
readonly clientId: string;
|
|
41
|
+
/**
|
|
42
|
+
* EXACT redirect URI string registered at the provider. Never derived from
|
|
43
|
+
* the incoming request — RFC 9700 requires byte-exact comparison.
|
|
44
|
+
*/
|
|
45
|
+
readonly redirectUri: string;
|
|
46
|
+
readonly scopes: readonly string[];
|
|
47
|
+
readonly state: string;
|
|
48
|
+
/** Base64url(SHA-256(code_verifier)) — PKCE S256, mandatory. */
|
|
49
|
+
readonly codeChallenge: string;
|
|
50
|
+
/** OIDC nonce echoed in the ID token. */
|
|
51
|
+
readonly nonce?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Result of {@link OAuthDriver.buildAuthorizeUrl}. */
|
|
55
|
+
export interface OAuthAuthorizeResult {
|
|
56
|
+
/** Provider authorization endpoint URL incl. all query parameters. */
|
|
57
|
+
readonly url: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Input to {@link OAuthDriver.exchangeCode}. */
|
|
61
|
+
export interface OAuthExchangeInput {
|
|
62
|
+
readonly clientId: string;
|
|
63
|
+
/** Confidential-client secret (revealed from Vault at the boundary). */
|
|
64
|
+
readonly clientSecret?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Apple only — signs a fresh ES256 client-secret JWT per exchange.
|
|
67
|
+
* Wins over {@link clientSecret} when set.
|
|
68
|
+
*/
|
|
69
|
+
readonly clientSecretJwt?: () => Promise<string>;
|
|
70
|
+
/** Exact redirect URI stored on the flow record at start. */
|
|
71
|
+
readonly redirectUri: string;
|
|
72
|
+
readonly code: string;
|
|
73
|
+
readonly codeVerifier: string;
|
|
74
|
+
/** Injectable fetch (tests). Defaults to `globalThis.fetch`. */
|
|
75
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
76
|
+
/** Injectable clock. */
|
|
77
|
+
readonly now?: () => number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Result of {@link OAuthDriver.exchangeCode}. */
|
|
81
|
+
export interface OAuthExchangeResult {
|
|
82
|
+
readonly tokens: OAuthTokenSet;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Input to {@link OAuthDriver.resolveAssertion}. */
|
|
86
|
+
export interface OAuthAssertionInput {
|
|
87
|
+
readonly tokens: OAuthTokenSet;
|
|
88
|
+
/**
|
|
89
|
+
* Issuer the flow was initiated against (stored on the flow record at
|
|
90
|
+
* start). Assertions whose issuer differs are mix-up attacks — rejected.
|
|
91
|
+
*/
|
|
92
|
+
readonly expectedIssuer: string;
|
|
93
|
+
/** Expected ID token / client audience (= clientId). */
|
|
94
|
+
readonly expectedAudience: string;
|
|
95
|
+
/** Expected OIDC nonce from the flow record. */
|
|
96
|
+
readonly expectedNonce?: string;
|
|
97
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
98
|
+
readonly now?: () => number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Uniform internal identity assertion — the "OIDC wrapper" every driver
|
|
103
|
+
* produces regardless of provider shape.
|
|
104
|
+
*
|
|
105
|
+
* `emailVerified` follows the per-provider trust matrix and defaults to
|
|
106
|
+
* `false`; it is `true` only on an explicit provider attestation.
|
|
107
|
+
*/
|
|
108
|
+
export interface OAuthAssertion {
|
|
109
|
+
/** Stable provider-scoped subject (`sub` / provider user id). */
|
|
110
|
+
readonly subject: string;
|
|
111
|
+
/** Verified issuer — equals {@link OAuthAssertionInput.expectedIssuer}. */
|
|
112
|
+
readonly issuer: string;
|
|
113
|
+
readonly email?: string;
|
|
114
|
+
readonly emailVerified: boolean;
|
|
115
|
+
readonly name?: string;
|
|
116
|
+
readonly raw: Readonly<Record<string, unknown>>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One social-login driver. Small, focused modules behind this shared
|
|
121
|
+
* interface — never one generalized function branching on provider name.
|
|
122
|
+
*/
|
|
123
|
+
export interface OAuthDriver {
|
|
124
|
+
readonly id: OAuthDriverId;
|
|
125
|
+
/** `oidc` (discovery + ID token) or `oauth2` (userinfo equivalent). */
|
|
126
|
+
readonly kind: "oidc" | "oauth2";
|
|
127
|
+
/**
|
|
128
|
+
* Stable issuer identifier bound to the flow record at start and checked
|
|
129
|
+
* against the assertion on callback — the mix-up defense anchor
|
|
130
|
+
* (RFC 9700 §4.4 / RFC 9207).
|
|
131
|
+
*/
|
|
132
|
+
readonly authorizationServerId: string;
|
|
133
|
+
/** Build the provider authorize URL (Authorization Code + PKCE S256). */
|
|
134
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult;
|
|
135
|
+
/** Exchange `code` for tokens using the exact stored redirect URI. */
|
|
136
|
+
exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult>;
|
|
137
|
+
/**
|
|
138
|
+
* Normalize tokens into an {@link OAuthAssertion}: OIDC drivers verify the
|
|
139
|
+
* ID token (sig, iss, aud, nonce, exp); `oauth2` drivers call their
|
|
140
|
+
* userinfo-equivalent endpoint.
|
|
141
|
+
*/
|
|
142
|
+
resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Thrown on any OAuth protocol failure (exchange, assertion, mix-up). */
|
|
146
|
+
export class OAuthProtocolError extends Error {
|
|
147
|
+
/** Machine-readable failure reason (safe to surface as `AuthFailed.reason`). */
|
|
148
|
+
readonly reason: string;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param reason - Stable failure slug (e.g. `issuer_mismatch`)
|
|
152
|
+
* @param message - Human context (logged, never returned to clients)
|
|
153
|
+
*/
|
|
154
|
+
constructor(reason: string, message: string) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = "OAuthProtocolError";
|
|
157
|
+
this.reason = reason;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x` OAuth2 driver (Authorization Code with PKCE — X has no OIDC).
|
|
3
|
+
* Profile: `GET /2/users/me?user.fields=confirmed_email`.
|
|
4
|
+
*
|
|
5
|
+
* Trust: `emailVerified` is **always false** — `confirmed_email` is an app
|
|
6
|
+
* permission gate, not a verification attestation (locked decision 5). Email
|
|
7
|
+
* may be absent without the `users.email` scope + elevated app access.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type OAuthAssertion,
|
|
12
|
+
type OAuthAssertionInput,
|
|
13
|
+
type OAuthAuthorizeInput,
|
|
14
|
+
type OAuthAuthorizeResult,
|
|
15
|
+
type OAuthDriver,
|
|
16
|
+
type OAuthExchangeInput,
|
|
17
|
+
type OAuthExchangeResult,
|
|
18
|
+
} from "./oauth-types.ts";
|
|
19
|
+
import { buildAuthorizeQuery } from "./oauth-shared.ts";
|
|
20
|
+
import { fetchProviderJson, resolveOauth2Assertion } from "./oauth2-common.ts";
|
|
21
|
+
import { postTokenRequest } from "./oauth2-token.ts";
|
|
22
|
+
|
|
23
|
+
const AUTHORIZATION_SERVER_ID = "https://x.com";
|
|
24
|
+
|
|
25
|
+
/** Default X scopes. */
|
|
26
|
+
export const X_DEFAULT_SCOPES = ["users.read", "tweet.read"] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open the X driver.
|
|
30
|
+
*
|
|
31
|
+
* @param options - Optional scope override / injectable fetch
|
|
32
|
+
*/
|
|
33
|
+
export function openOAuthX(
|
|
34
|
+
options: { readonly scopes?: readonly string[]; readonly fetch?: typeof globalThis.fetch } = {},
|
|
35
|
+
): OAuthDriver {
|
|
36
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
37
|
+
return {
|
|
38
|
+
id: "x",
|
|
39
|
+
kind: "oauth2",
|
|
40
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
41
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
42
|
+
return { url: buildAuthorizeQuery("https://x.com/i/oauth2/authorize", input) };
|
|
43
|
+
},
|
|
44
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
45
|
+
// X is a public client — no client_secret on the token call.
|
|
46
|
+
return {
|
|
47
|
+
tokens: await postTokenRequest({
|
|
48
|
+
tokenEndpoint: "https://api.x.com/2/oauth2/token",
|
|
49
|
+
input,
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
54
|
+
return resolveOauth2Assertion(input, async (accessToken) => {
|
|
55
|
+
const res = await fetchProviderJson(
|
|
56
|
+
"https://api.x.com/2/users/me?user.fields=id,name,username,confirmed_email",
|
|
57
|
+
accessToken,
|
|
58
|
+
fetchFn,
|
|
59
|
+
"x",
|
|
60
|
+
);
|
|
61
|
+
const data = res["data"];
|
|
62
|
+
if (data === null || typeof data !== "object") throw new Error("x: profile missing data");
|
|
63
|
+
const user = data as Record<string, unknown>;
|
|
64
|
+
const subject = typeof user["id"] === "string" ? user["id"] : undefined;
|
|
65
|
+
if (subject === undefined) throw new Error("x: profile missing id");
|
|
66
|
+
return {
|
|
67
|
+
providerId: "x",
|
|
68
|
+
subject,
|
|
69
|
+
...(user["confirmed_email"] !== undefined ? { email: user["confirmed_email"] } : {}),
|
|
70
|
+
// Locked trust decision: never verified via X.
|
|
71
|
+
name: typeof user["name"] === "string" ? user["name"] : undefined,
|
|
72
|
+
raw: user,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OAuth 2.0-only driver machinery: the "OIDC wrapper" — userinfo-
|
|
3
|
+
* equivalent fetches normalized into an {@link OAuthAssertion}.
|
|
4
|
+
*
|
|
5
|
+
* These providers have no `iss` on their identity surface; the mix-up anchor
|
|
6
|
+
* is the flow record's `authorizationServerId`, checked by the plugin before
|
|
7
|
+
* any driver call (RFC 9700 §4.4 alternate: distinct callback paths).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { OAuthAssertion, OAuthAssertionInput } from "./oauth-types.ts";
|
|
11
|
+
import { normalizeEmail } from "./oauth-shared.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Provider-agnostic profile fields a driver resolves before normalization.
|
|
15
|
+
* `verified` carries only explicit provider attestations (GitHub primary,
|
|
16
|
+
* Discord); Facebook / X / Figma omit it entirely → always unverified.
|
|
17
|
+
*/
|
|
18
|
+
export interface OAuth2Profile {
|
|
19
|
+
readonly providerId: string;
|
|
20
|
+
readonly subject: string;
|
|
21
|
+
readonly email?: unknown;
|
|
22
|
+
readonly verified?: unknown;
|
|
23
|
+
readonly name?: unknown;
|
|
24
|
+
readonly raw: Readonly<Record<string, unknown>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a provider JSON endpoint with bearer auth.
|
|
29
|
+
*
|
|
30
|
+
* @param url - Endpoint
|
|
31
|
+
* @param accessToken - Bearer token
|
|
32
|
+
* @param fetchFn - Injectable fetch
|
|
33
|
+
* @param provider - Driver id for error context
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchProviderJson(
|
|
36
|
+
url: string,
|
|
37
|
+
accessToken: string,
|
|
38
|
+
fetchFn: typeof globalThis.fetch,
|
|
39
|
+
provider: string,
|
|
40
|
+
): Promise<Record<string, unknown>> {
|
|
41
|
+
const res = await fetchFn(url, {
|
|
42
|
+
headers: {
|
|
43
|
+
authorization: `Bearer ${accessToken}`,
|
|
44
|
+
accept: "application/json",
|
|
45
|
+
// GitHub v3 convention — avoids XML fallbacks.
|
|
46
|
+
"user-agent": "okengine-oauth",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error(`${provider}: profile request failed with HTTP ${res.status}`);
|
|
51
|
+
}
|
|
52
|
+
return (await res.json()) as Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Trust matrix: verified only on an explicit boolean/string `true`. */
|
|
56
|
+
function trustVerified(verified: unknown): boolean {
|
|
57
|
+
if (typeof verified === "boolean") return verified;
|
|
58
|
+
if (typeof verified === "string") return verified === "true";
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build an {@link OAuthAssertion} from resolved provider fields.
|
|
64
|
+
*
|
|
65
|
+
* @param options - Profile fields plus the pinned issuer
|
|
66
|
+
*/
|
|
67
|
+
export function oauth2Assertion(
|
|
68
|
+
options: OAuth2Profile & { readonly issuer: string },
|
|
69
|
+
): OAuthAssertion {
|
|
70
|
+
const email = normalizeEmail(options.email);
|
|
71
|
+
const name =
|
|
72
|
+
typeof options.name === "string" && options.name.length > 0 ? options.name : undefined;
|
|
73
|
+
return {
|
|
74
|
+
subject: options.subject,
|
|
75
|
+
issuer: options.issuer,
|
|
76
|
+
...(email !== undefined ? { email } : {}),
|
|
77
|
+
emailVerified: trustVerified(options.verified),
|
|
78
|
+
...(name !== undefined ? { name } : {}),
|
|
79
|
+
raw: { ...options.raw, provider: options.providerId },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Standard resolveAssertion shape for OAuth2 drivers.
|
|
85
|
+
*
|
|
86
|
+
* @param input - Assertion inputs
|
|
87
|
+
* @param resolve - Profile resolver receiving the access token
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveOauth2Assertion(
|
|
90
|
+
input: OAuthAssertionInput,
|
|
91
|
+
resolve: (accessToken: string) => Promise<OAuth2Profile>,
|
|
92
|
+
): Promise<OAuthAssertion> {
|
|
93
|
+
const profile = await resolve(input.tokens.accessToken);
|
|
94
|
+
return oauth2Assertion({ ...profile, issuer: input.expectedIssuer });
|
|
95
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared token-exchange helper for OAuth 2.0-only drivers.
|
|
3
|
+
* (Split from oauth2-common to keep profile logic separate from transport.)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { OAuthProtocolError, type OAuthTokenSet } from "./oauth-types.ts";
|
|
7
|
+
import type { OAuthExchangeInput } from "./oauth-types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST `code` (+ PKCE verifier) to a provider token endpoint and normalize
|
|
11
|
+
* the response. Providers without client auth (PKCE public clients) omit the
|
|
12
|
+
* secret automatically when undefined.
|
|
13
|
+
*
|
|
14
|
+
* @param options - Endpoint + exchange inputs
|
|
15
|
+
*/
|
|
16
|
+
export async function postTokenRequest(options: {
|
|
17
|
+
readonly tokenEndpoint: string;
|
|
18
|
+
readonly input: OAuthExchangeInput;
|
|
19
|
+
}): Promise<OAuthTokenSet> {
|
|
20
|
+
const { input } = options;
|
|
21
|
+
const params = new URLSearchParams({
|
|
22
|
+
grant_type: "authorization_code",
|
|
23
|
+
code: input.code,
|
|
24
|
+
redirect_uri: input.redirectUri,
|
|
25
|
+
client_id: input.clientId,
|
|
26
|
+
code_verifier: input.codeVerifier,
|
|
27
|
+
});
|
|
28
|
+
if (input.clientSecret !== undefined) params.set("client_secret", input.clientSecret);
|
|
29
|
+
const fetchFn = input.fetch ?? globalThis.fetch;
|
|
30
|
+
const res = await fetchFn(options.tokenEndpoint, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
34
|
+
accept: "application/json",
|
|
35
|
+
// X rejects JSON bodies; GitHub accepts both but form is universal.
|
|
36
|
+
"user-agent": "okengine-oauth",
|
|
37
|
+
},
|
|
38
|
+
body: params.toString(),
|
|
39
|
+
});
|
|
40
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
41
|
+
if (!res.ok || typeof body["access_token"] !== "string") {
|
|
42
|
+
throw new OAuthProtocolError(
|
|
43
|
+
"exchange_failed",
|
|
44
|
+
`${options.tokenEndpoint} returned HTTP ${res.status}: ${String(body["error"] ?? "unknown")}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const expiresIn = body["expires_in"];
|
|
48
|
+
return {
|
|
49
|
+
accessToken: body["access_token"],
|
|
50
|
+
...(typeof body["refresh_token"] === "string"
|
|
51
|
+
? { refreshToken: body["refresh_token"] as string }
|
|
52
|
+
: {}),
|
|
53
|
+
...(typeof expiresIn === "number"
|
|
54
|
+
? { expiresAt: Math.floor(Date.now() / 1000 + expiresIn * 1000) }
|
|
55
|
+
: {}),
|
|
56
|
+
...(typeof body["scope"] === "string"
|
|
57
|
+
? { scopes: (body["scope"] as string).split(/\s+/).filter((s) => s.length > 0) }
|
|
58
|
+
: {}),
|
|
59
|
+
raw: body,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke.row_passes_policies` — synthetic-row RLS replay for CDC live queries.
|
|
3
|
+
*
|
|
4
|
+
* When a table row changes (CDC event), live-query fan-out must decide per
|
|
5
|
+
* subscriber whether the OLD version of the row was visible (`beforeVisible`)
|
|
6
|
+
* without heap access — after an UPDATE the heap holds only the after-image,
|
|
7
|
+
* after a DELETE nothing remains. The subscriber's stamped connection
|
|
8
|
+
* (`SET LOCAL ROLE oke_app` + `oke.*` GUCs) evaluates policies natively for
|
|
9
|
+
* heap rows; this helper gives the SAME verdict for a JSONB payload by
|
|
10
|
+
* replaying expressions straight from `pg_policies`.
|
|
11
|
+
*
|
|
12
|
+
* Parity contract (src/elements/store/rls-row-passes-policies.parity.test.ts):
|
|
13
|
+
* replay(table, rowJson, "SELECT") === native stamped `WHERE pk = …` verdict
|
|
14
|
+
* across every shipped policy family (owner / tenant / gate / scope) and their
|
|
15
|
+
* real compositions, plus WITH CHECK probes via native INSERT attempts.
|
|
16
|
+
*
|
|
17
|
+
* Expression rewriting (inside the SQL function, per policy, per call):
|
|
18
|
+
* 1. Bare column tokens (resolved from `pg_attribute` of the SAME table) are
|
|
19
|
+
* first wrapped in a placeholder `<<col>>`, then placeholders are
|
|
20
|
+
* substituted with `(row_data->>'col')` once per column. Two-phase keeps
|
|
21
|
+
* a single regexp_replace pass idempotent — a direct wrap re-matches on
|
|
22
|
+
* every pass ((row_data->>'col')'s tail still sits between valid word
|
|
23
|
+
* boundaries) and the loop never stabilizes.
|
|
24
|
+
* 2. The replacement string is DOLLAR-QUOTED (`$re$\1<<\2>>\3$re$`), never
|
|
25
|
+
* E''-quoted: plpgsql folds `E'\1'` at body-compile time into chr(1),
|
|
26
|
+
* silently corrupting backreferences.
|
|
27
|
+
* 3. `<`/`>` are excluded from both boundary classes so `<<col>>` cannot
|
|
28
|
+
* re-match; single-quoted literals are protected because `'` is excluded
|
|
29
|
+
* from boundaries too — `'owner'` never trips a column named `owner`.
|
|
30
|
+
* 4. `oke.*` helpers survive: their segments follow a `.` (excluded left
|
|
31
|
+
* boundary). pg_policies stores them qualification-stripped
|
|
32
|
+
* (`"user"()`); `"user"` is not a column token so it passes through and
|
|
33
|
+
* resolves via search_path to oke.user() exactly as it does natively.
|
|
34
|
+
* 5. Keyword-shaped tokens are skipped; placeholder substitution uses plain
|
|
35
|
+
* replace() so `id` never corrupts an already-substituted accessor.
|
|
36
|
+
*
|
|
37
|
+
* Semantics mirror native RLS:
|
|
38
|
+
* - `TO <role>` applicability: a policy applies only when the current user is
|
|
39
|
+
* a member of one of its roles (`public`/`current_user` always; membership
|
|
40
|
+
* via `pg_has_role`). The stamp runs as `oke_app`, so internal policies
|
|
41
|
+
* granted to non-member roles are invisible to it natively — the replay
|
|
42
|
+
* skips them identically (TO-role regression gate: F4-style cases).
|
|
43
|
+
* - `ALL` policies apply to every command; others match their own.
|
|
44
|
+
* - Clause selection per command mirrors the native backend:
|
|
45
|
+
* SELECT/DELETE evaluate USING (qual); INSERT evaluates WITH CHECK only;
|
|
46
|
+
* UPDATE must satisfy both (missing clause falls back to its sibling).
|
|
47
|
+
* - PERMISSIVE OR together, RESTRICTIVE AND; zero applicable policies on an
|
|
48
|
+
* RLS-enabled table denies everything.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** One statement per exec — drivers reject multi-command batches on `query`. */
|
|
52
|
+
export const ROW_PASSES_POLICIES_STATEMENTS: readonly string[] = [
|
|
53
|
+
String.raw`CREATE OR REPLACE FUNCTION oke.pol_applicable_to_role(
|
|
54
|
+
p_roles text[]
|
|
55
|
+
) RETURNS boolean
|
|
56
|
+
LANGUAGE sql STABLE AS $fn$
|
|
57
|
+
SELECT EXISTS (
|
|
58
|
+
SELECT 1
|
|
59
|
+
FROM unnest(p_roles) AS r(role)
|
|
60
|
+
WHERE r.role = 'public'
|
|
61
|
+
OR r.role = 'current_user'
|
|
62
|
+
OR r.role = 'session_user'
|
|
63
|
+
OR pg_has_role(current_user, r.role, 'MEMBER')
|
|
64
|
+
)
|
|
65
|
+
$fn$`,
|
|
66
|
+
String.raw`CREATE OR REPLACE FUNCTION oke.row_passes_policies(
|
|
67
|
+
p_table text,
|
|
68
|
+
p_row jsonb,
|
|
69
|
+
p_command text
|
|
70
|
+
) RETURNS boolean
|
|
71
|
+
LANGUAGE plpgsql STABLE AS $fn$
|
|
72
|
+
DECLARE
|
|
73
|
+
v_oid oid;
|
|
74
|
+
v_rls boolean := false;
|
|
75
|
+
pol record;
|
|
76
|
+
col record;
|
|
77
|
+
expr text;
|
|
78
|
+
next_text text;
|
|
79
|
+
n int;
|
|
80
|
+
acc text;
|
|
81
|
+
verdict boolean;
|
|
82
|
+
permissive_pass boolean := false;
|
|
83
|
+
has_permissive boolean := false;
|
|
84
|
+
restrictive_pass boolean := true;
|
|
85
|
+
has_restrictive boolean := false;
|
|
86
|
+
BEGIN
|
|
87
|
+
SELECT c.oid, c.relrowsecurity INTO v_oid, v_rls
|
|
88
|
+
FROM pg_class c
|
|
89
|
+
JOIN pg_namespace nsp ON nsp.oid = c.relnamespace
|
|
90
|
+
WHERE c.relname = split_part(p_table, '.', 2)
|
|
91
|
+
OR c.relname = p_table
|
|
92
|
+
ORDER BY (nsp.nspname = 'public') DESC
|
|
93
|
+
LIMIT 1;
|
|
94
|
+
|
|
95
|
+
IF v_oid IS NULL OR NOT COALESCE(v_rls, false) THEN
|
|
96
|
+
RETURN true; -- unknown table or RLS off: everything visible (native parity)
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
FOR pol IN
|
|
100
|
+
SELECT policyname, permissive, cmd, roles, qual, with_check
|
|
101
|
+
FROM pg_policies
|
|
102
|
+
WHERE tablename = split_part(p_table, '.', 2)
|
|
103
|
+
OR tablename = p_table
|
|
104
|
+
LOOP
|
|
105
|
+
-- Native TO-role semantics: a policy only applies when the current user is
|
|
106
|
+
-- a member of (one of) its roles. oke_app (the stamp role) is not normally
|
|
107
|
+
-- a member of custom policy roles, so such policies must be skipped —
|
|
108
|
+
-- otherwise an internal TO-restricted PERMISSIVE policy would wrongly widen
|
|
109
|
+
-- per-user visibility through the replay path.
|
|
110
|
+
IF NOT pol_applicable_to_role(pol.roles) THEN
|
|
111
|
+
CONTINUE;
|
|
112
|
+
END IF;
|
|
113
|
+
|
|
114
|
+
IF pol.cmd <> 'ALL' AND upper(p_command) <> pol.cmd THEN
|
|
115
|
+
CONTINUE;
|
|
116
|
+
END IF;
|
|
117
|
+
|
|
118
|
+
-- Command-clause selection mirrors each native command's evaluation:
|
|
119
|
+
-- SELECT/DELETE -> USING (qual) only
|
|
120
|
+
-- UPDATE -> USING + WITH CHECK must both pass (native treats a
|
|
121
|
+
-- missing clause as "same as the other")
|
|
122
|
+
-- INSERT -> WITH CHECK only (qual does not gate new rows)
|
|
123
|
+
-- Zero applicable policies on an RLS-enabled table still denies all.
|
|
124
|
+
expr := CASE
|
|
125
|
+
WHEN upper(p_command) = 'INSERT' THEN COALESCE(pol.with_check, pol.qual)
|
|
126
|
+
ELSE COALESCE(pol.qual, pol.with_check)
|
|
127
|
+
END;
|
|
128
|
+
|
|
129
|
+
CONTINUE WHEN expr IS NULL;
|
|
130
|
+
|
|
131
|
+
FOR col IN
|
|
132
|
+
SELECT a.attname AS name
|
|
133
|
+
FROM pg_attribute a
|
|
134
|
+
WHERE a.attrelid = v_oid
|
|
135
|
+
AND a.attnum > 0
|
|
136
|
+
AND NOT a.attisdropped
|
|
137
|
+
ORDER BY a.attnum
|
|
138
|
+
LOOP
|
|
139
|
+
IF lower(col.name) IN (
|
|
140
|
+
'and','or','not','is','in','like','ilike','between','exists',
|
|
141
|
+
'case','when','then','else','end','true','false','null'
|
|
142
|
+
) THEN
|
|
143
|
+
CONTINUE;
|
|
144
|
+
END IF;
|
|
145
|
+
|
|
146
|
+
-- Phase 1: wrap bare tokens as <<col>>. '<'/'>' sit outside the word
|
|
147
|
+
-- boundary classes, so the substituted form can never re-match and the
|
|
148
|
+
-- stabilization pass count is bounded by pathological overlaps only.
|
|
149
|
+
n := 0;
|
|
150
|
+
LOOP
|
|
151
|
+
n := n + 1;
|
|
152
|
+
IF n > 100 THEN
|
|
153
|
+
RAISE EXCEPTION 'row_passes_policies rewrite did not stabilize (%)', pol.policyname;
|
|
154
|
+
END IF;
|
|
155
|
+
next_text := replace(
|
|
156
|
+
regexp_replace(
|
|
157
|
+
expr,
|
|
158
|
+
'(^|[^A-Za-z0-9_''.<>])(' || col.name || ')($|[^A-Za-z0-9''<>])',
|
|
159
|
+
$re$\1<<\2>>\3$re$,
|
|
160
|
+
'g'
|
|
161
|
+
),
|
|
162
|
+
chr(1) || '<<' || col.name || '>>' || chr(1),
|
|
163
|
+
'<<' || col.name || '>>'
|
|
164
|
+
);
|
|
165
|
+
EXIT WHEN next_text = expr;
|
|
166
|
+
expr := next_text;
|
|
167
|
+
END LOOP;
|
|
168
|
+
|
|
169
|
+
-- Phase 2: placeholders -> jsonb accessor (plain replace, order-safe).
|
|
170
|
+
acc := '(row_data->>''' || col.name || ''')';
|
|
171
|
+
expr := replace(expr, '<<' || col.name || '>>', acc);
|
|
172
|
+
END LOOP;
|
|
173
|
+
|
|
174
|
+
BEGIN
|
|
175
|
+
EXECUTE format('SELECT (%s) FROM (SELECT %L::jsonb AS row_data) AS t',
|
|
176
|
+
expr, p_row::text) INTO verdict;
|
|
177
|
+
EXCEPTION WHEN OTHERS THEN
|
|
178
|
+
RAISE EXCEPTION 'row_passes_policies replay failed (%): %',
|
|
179
|
+
pol.policyname, SQLERRM;
|
|
180
|
+
END;
|
|
181
|
+
|
|
182
|
+
IF pol.permissive = 'PERMISSIVE' THEN
|
|
183
|
+
has_permissive := true;
|
|
184
|
+
IF verdict THEN permissive_pass := true; END IF;
|
|
185
|
+
ELSE
|
|
186
|
+
has_restrictive := true;
|
|
187
|
+
IF NOT verdict THEN restrictive_pass := false; END IF;
|
|
188
|
+
END IF;
|
|
189
|
+
END LOOP;
|
|
190
|
+
|
|
191
|
+
IF NOT has_permissive AND NOT has_restrictive THEN
|
|
192
|
+
RETURN false; -- RLS enabled, zero applicable policies: Postgres denies all
|
|
193
|
+
END IF;
|
|
194
|
+
RETURN (NOT has_restrictive OR restrictive_pass)
|
|
195
|
+
AND (NOT has_permissive OR permissive_pass);
|
|
196
|
+
END
|
|
197
|
+
$fn$`,
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Probe for one replay evaluation.
|
|
202
|
+
*
|
|
203
|
+
* Prefer `sql`/`params` with drivers that bind JSON as an explicit type;
|
|
204
|
+
* Bun.SQL binds JS strings as unknown, so `$2::jsonb` yields a jsonb
|
|
205
|
+
* string scalar and `->>` lookups return NULL (live-PG parity failure).
|
|
206
|
+
* {@link buildInlineRowPassesSql} inlines the image safely instead.
|
|
207
|
+
*
|
|
208
|
+
* @param table - Table (bare name or `schema.table`)
|
|
209
|
+
* @param row - Synthetic row image (JSON-serializable)
|
|
210
|
+
* @param command - Command context (`SELECT` = visibility)
|
|
211
|
+
*/
|
|
212
|
+
export interface RowPassesProbe {
|
|
213
|
+
readonly sql: string;
|
|
214
|
+
readonly params: [string, string, string];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Escape a value for inline SQL literal embedding. */
|
|
218
|
+
function escapeSqlLiteral(value: string): string {
|
|
219
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Inline-literal probe SQL — portable across pg drivers regardless of how
|
|
224
|
+
* each binds JS strings. Row payload and table name are escaped; command
|
|
225
|
+
* comes from a closed union.
|
|
226
|
+
*
|
|
227
|
+
* @param table - Table (bare name or `schema.table`)
|
|
228
|
+
* @param row - Synthetic row image
|
|
229
|
+
* @param command - Command context
|
|
230
|
+
*/
|
|
231
|
+
export function buildInlineRowPassesSql(
|
|
232
|
+
table: string,
|
|
233
|
+
row: Readonly<Record<string, unknown>>,
|
|
234
|
+
command: "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE" = "SELECT",
|
|
235
|
+
): string {
|
|
236
|
+
const tableArg = escapeSqlLiteral(table);
|
|
237
|
+
const rowArg = `${escapeSqlLiteral(JSON.stringify(row))}::jsonb`;
|
|
238
|
+
return `SELECT oke.row_passes_policies(${tableArg}, ${rowArg}, ${escapeSqlLiteral(command)}) AS ok`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Build a single replay probe (runs under the caller's RLS stamp). */
|
|
242
|
+
export function buildRowPassesProbe(
|
|
243
|
+
table: string,
|
|
244
|
+
row: Readonly<Record<string, unknown>>,
|
|
245
|
+
command: "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE" = "SELECT",
|
|
246
|
+
): RowPassesProbe {
|
|
247
|
+
return {
|
|
248
|
+
sql: "SELECT oke.row_passes_policies(?, ?, ?) AS ok",
|
|
249
|
+
params: [table, JSON.stringify(row), command],
|
|
250
|
+
};
|
|
251
|
+
}
|