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.
Files changed (209) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +14 -10
  3. package/manifest.v1.schema.json +61 -2
  4. package/package.json +19 -5
  5. package/site/content/docs/ai/index.mdx +1 -1
  6. package/site/content/docs/ai/mcp.mdx +10 -1
  7. package/site/content/docs/ai/skills.mdx +9 -9
  8. package/site/content/docs/elements/ai.mdx +1 -1
  9. package/site/content/docs/elements/clock.mdx +1 -1
  10. package/site/content/docs/elements/flow.mdx +25 -1
  11. package/site/content/docs/elements/gate.mdx +3 -2
  12. package/site/content/docs/elements/store.mdx +289 -341
  13. package/site/content/docs/elements/vault.mdx +5 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +3 -10
  15. package/site/content/docs/get-started/index.mdx +1 -1
  16. package/site/content/docs/get-started/installation.mdx +2 -3
  17. package/site/content/docs/get-started/introduction.mdx +58 -121
  18. package/site/content/docs/get-started/meta.json +9 -1
  19. package/site/content/docs/get-started/project-structure.mdx +4 -11
  20. package/site/content/docs/get-started/testing.mdx +328 -0
  21. package/site/content/docs/get-started/why.mdx +93 -71
  22. package/site/content/docs/index.mdx +44 -11
  23. package/site/content/docs/meta.json +8 -5
  24. package/site/content/docs/plugins/apple.mdx +151 -0
  25. package/site/content/docs/plugins/discord.mdx +139 -0
  26. package/site/content/docs/plugins/facebook.mdx +134 -0
  27. package/site/content/docs/plugins/figma.mdx +138 -0
  28. package/site/content/docs/plugins/github.mdx +138 -0
  29. package/site/content/docs/plugins/google.mdx +153 -0
  30. package/site/content/docs/plugins/index.mdx +47 -1
  31. package/site/content/docs/plugins/meta.json +10 -0
  32. package/site/content/docs/plugins/microsoft.mdx +151 -0
  33. package/site/content/docs/plugins/oauth.mdx +188 -0
  34. package/site/content/docs/plugins/x.mdx +125 -0
  35. package/site/content/docs/providers/index.mdx +2 -0
  36. package/site/content/docs/recipes/index.mdx +2 -0
  37. package/site/content/docs/reference/cli.md +3 -2
  38. package/site/content/docs/reference/client.mdx +58 -1
  39. package/site/content/docs/reference/configuration.mdx +2 -4
  40. package/site/content/docs/reference/fx.mdx +3 -1
  41. package/site/content/docs/reference/index.mdx +0 -5
  42. package/site/content/docs/reference/meta.json +2 -2
  43. package/site/content/docs/reference/okid.mdx +137 -0
  44. package/src/auth/bindings.ts +1 -1
  45. package/src/auth/config.ts +9 -0
  46. package/src/auth/identity-sql.ts +314 -0
  47. package/src/auth/identity.ts +140 -2
  48. package/src/auth/index.ts +17 -1
  49. package/src/auth/method-context.ts +3 -0
  50. package/src/auth/oauth-as/cimd.ts +132 -0
  51. package/src/auth/oauth-as/crypto.test.ts +101 -0
  52. package/src/auth/oauth-as/crypto.ts +393 -0
  53. package/src/auth/oauth-as/errors.ts +68 -0
  54. package/src/auth/oauth-as/http.test.ts +419 -0
  55. package/src/auth/oauth-as/http.ts +842 -0
  56. package/src/auth/oauth-as/stores.ts +61 -0
  57. package/src/auth/oauth-as/tables.ts +142 -0
  58. package/src/auth/tables.ts +0 -11
  59. package/src/bench/README.md +83 -0
  60. package/src/bench/REPORT.md +176 -0
  61. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  62. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  63. package/src/bench/g03-signal-once.bench.ts +157 -0
  64. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  65. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  66. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  67. package/src/bench/g05-sustained-full.bench.ts +265 -0
  68. package/src/bench/g06-mixed-load.bench.ts +260 -0
  69. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  70. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  71. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  72. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  73. package/src/bench/g08-store-sql.bench.ts +178 -0
  74. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  75. package/src/bench/g10-observability-contention.bench.ts +246 -0
  76. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  77. package/src/bench/g13-elements.bench.ts +427 -0
  78. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  79. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  80. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  81. package/src/bench/lib/event-loop-lag.ts +26 -0
  82. package/src/bench/lib/infra.ts +60 -0
  83. package/src/bench/lib/report.ts +52 -0
  84. package/src/bench/lib/rss-sampler.ts +61 -0
  85. package/src/bench/lib/signal-pg.ts +88 -0
  86. package/src/bench/load-app.ts +337 -0
  87. package/src/bench/load-child.ts +108 -0
  88. package/src/bench/smoke.bench.ts +43 -0
  89. package/src/cli/competitor-mention-removal.test.ts +28 -0
  90. package/src/cli/doctor-fd.ts +117 -0
  91. package/src/cli/doctor.test.ts +192 -0
  92. package/src/cli/doctor.ts +129 -1
  93. package/src/client/create.ts +95 -1
  94. package/src/client/index.ts +9 -2
  95. package/src/client/transport.ts +11 -4
  96. package/src/client/use-live-query.ts +154 -0
  97. package/src/client-react/index.ts +15 -1
  98. package/src/client-react/live-resource.ts +246 -0
  99. package/src/client-react/use-live-query.test.ts +475 -0
  100. package/src/client-react/use-live-query.ts +530 -0
  101. package/src/compiler/extract.test.ts +518 -0
  102. package/src/compiler/extract.ts +386 -19
  103. package/src/console/server/invoke-user-flow.ts +2 -1
  104. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
  105. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
  107. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
  108. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  109. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
  110. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
  111. package/src/console/ui-next/dist/index.html +1 -1
  112. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  113. package/src/drivers/cdc-outbox.ts +389 -0
  114. package/src/drivers/memory.ts +20 -0
  115. package/src/drivers/oauth-apple.ts +156 -0
  116. package/src/drivers/oauth-discord.ts +79 -0
  117. package/src/drivers/oauth-facebook.ts +80 -0
  118. package/src/drivers/oauth-figma.ts +116 -0
  119. package/src/drivers/oauth-github.ts +92 -0
  120. package/src/drivers/oauth-google.ts +142 -0
  121. package/src/drivers/oauth-microsoft.ts +174 -0
  122. package/src/drivers/oauth-oidc.ts +293 -0
  123. package/src/drivers/oauth-shared.ts +326 -0
  124. package/src/drivers/oauth-types.ts +159 -0
  125. package/src/drivers/oauth-x.ts +77 -0
  126. package/src/drivers/oauth2-common.ts +95 -0
  127. package/src/drivers/oauth2-token.ts +61 -0
  128. package/src/drivers/pg-rls-row-passes.ts +251 -0
  129. package/src/drivers/pg-rls.ts +2 -0
  130. package/src/drivers/postgres.ts +45 -2
  131. package/src/drivers/signal-postgres.ts +2 -1
  132. package/src/elements/channel/runtime.ts +29 -2
  133. package/src/elements/channel.test.ts +52 -0
  134. package/src/elements/gate/boot.ts +29 -2
  135. package/src/elements/store/emit-drizzle.ts +147 -14
  136. package/src/elements/store/field-ddl.test.ts +118 -0
  137. package/src/elements/store/field-types.test.ts +455 -0
  138. package/src/elements/store/list-query.golden.json +777 -0
  139. package/src/elements/store/list-query.parity.test.ts +396 -0
  140. package/src/elements/store/list-query.ts +792 -0
  141. package/src/elements/store/live-default.test.ts +136 -0
  142. package/src/elements/store/live-http.test.ts +160 -0
  143. package/src/elements/store/live-isolation.test.ts +291 -0
  144. package/src/elements/store/live-query-runtime.test.ts +323 -0
  145. package/src/elements/store/live-query-runtime.ts +403 -0
  146. package/src/elements/store/live-query-server.test.ts +377 -0
  147. package/src/elements/store/live-query-server.ts +102 -0
  148. package/src/elements/store/live-query.ts +97 -0
  149. package/src/elements/store/resource.ts +189 -680
  150. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  151. package/src/elements/store/schema-decl.ts +539 -41
  152. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  153. package/src/elements/store/sql-session.ts +297 -35
  154. package/src/elements/store/table.ts +102 -21
  155. package/src/elements/store.test.ts +3 -1
  156. package/src/elements/store.ts +12 -1
  157. package/src/elements/vault/chaos-child.ts +74 -1
  158. package/src/elements/vault/chaos.test.ts +4 -2
  159. package/src/elements/vault/storage.ts +4 -2
  160. package/src/index.ts +5 -2
  161. package/src/kernel/app-auth.ts +1 -0
  162. package/src/kernel/app.ts +116 -2
  163. package/src/kernel/auth-sharing.test.ts +196 -0
  164. package/src/kernel/boot.test.ts +3 -3
  165. package/src/kernel/errors.ts +8 -0
  166. package/src/kernel/fx.test.ts +1 -0
  167. package/src/kernel/fx.ts +14 -2
  168. package/src/kernel/horizontal-child.ts +2 -1
  169. package/src/kernel/http-resource.ts +33 -7
  170. package/src/kernel/identity-host-persist.test.ts +119 -0
  171. package/src/kernel/instance-id.ts +4 -2
  172. package/src/kernel/journal.ts +2 -1
  173. package/src/kernel/mcp-tool.test.ts +95 -0
  174. package/src/kernel/on.ts +9 -0
  175. package/src/kernel/realtime-bind.ts +326 -0
  176. package/src/kernel/resource-live.ts +117 -0
  177. package/src/kernel/triggers.ts +86 -4
  178. package/src/manifest/diff.ts +37 -0
  179. package/src/manifest/types.ts +64 -2
  180. package/src/okid.bench.test.ts +64 -0
  181. package/src/okid.test.ts +338 -0
  182. package/src/okid.ts +245 -0
  183. package/src/plugins/anonymous.ts +19 -1
  184. package/src/plugins/auth/shared.ts +15 -0
  185. package/src/plugins/index.ts +2 -0
  186. package/src/plugins/magic-link.ts +10 -8
  187. package/src/plugins/mcp-oauth.ts +208 -0
  188. package/src/plugins/oauth/flow-store.ts +117 -0
  189. package/src/plugins/oauth/link.ts +69 -0
  190. package/src/plugins/oauth/shared.ts +108 -0
  191. package/src/plugins/oauth/token-vault.ts +100 -0
  192. package/src/plugins/oauth.security.test.ts +535 -0
  193. package/src/plugins/oauth.ts +532 -0
  194. package/src/plugins/otp.ts +48 -6
  195. package/src/plugins/passkey.ts +20 -1
  196. package/src/plugins/two-factor.ts +11 -0
  197. package/src/plugins/username.ts +40 -7
  198. package/src/release/build-lib.ts +7 -1
  199. package/src/release/measure.ts +1 -0
  200. package/src/release/official-plugins.ts +4 -1
  201. package/src/runs/collect.ts +2 -1
  202. package/src/runs/drivers/files.ts +2 -1
  203. package/src/test/create-test-app.ts +114 -5
  204. package/src/test/export-bundle.test.ts +33 -0
  205. package/src/test/live-signals.test.ts +83 -0
  206. package/src/test/tenant-isolation.test.ts +175 -0
  207. package/src/testing.ts +26 -0
  208. package/src/upgrade/codemods.ts +1 -1
  209. 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
+ }
@@ -482,3 +482,5 @@ export function buildRlsIdentityPreludeSql(identity: RlsIdentity): readonly RlsP
482
482
  },
483
483
  ];
484
484
  }
485
+
486
+ export { buildRowPassesProbe, ROW_PASSES_POLICIES_STATEMENTS } from "./pg-rls-row-passes.ts";