okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,188 @@
1
+ ---
2
+ title: "OAuth"
3
+ description: "Official plugin — social sign-in with eight providers under /auth, Authorization Code + PKCE only."
4
+ icon: "FingerprintPattern"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ `oauth()` adds social login to Gate auth. Start at
9
+ `/auth/oauth/{provider}/start`, approve, then land on
10
+ `/auth/oauth/callback/{provider}` with a session — eight providers.
11
+
12
+ <Callout title="The one rule">
13
+ Enable `gate.auth`, then `.plug(oauth({ providers: { ... } }))`. Every
14
+ provider runs Authorization Code + PKCE with an exact registered redirect URI
15
+ — implicit and password grants do not exist here.
16
+ </Callout>
17
+
18
+ ## Quick start
19
+
20
+ <Steps>
21
+
22
+ <Step>
23
+ ### Plug it
24
+
25
+ ```typescript title="src/app.ts"
26
+ import { oke } from "okengine";
27
+ import { oauth } from "okengine/plugins";
28
+
29
+ export const app = oke({
30
+ name: "shop",
31
+ env: "dev",
32
+ gate: { auth: {} },
33
+ }).plug(
34
+ oauth({
35
+ baseUrl: "https://app.example.com",
36
+ providers: {
37
+ google: { enabled: true },
38
+ github: { enabled: true },
39
+ },
40
+ }),
41
+ );
42
+ ```
43
+
44
+ `baseUrl` synthesizes `{baseUrl}/auth/oauth/callback/{provider}` — register
45
+ that exact string. Client secrets resolve from Vault
46
+ (`OAUTH_GOOGLE_CLIENT_SECRET`, `OAUTH_GITHUB_CLIENT_SECRET`, …).
47
+
48
+ </Step>
49
+
50
+ <Step>
51
+ ### Start the flow
52
+
53
+ ```typescript
54
+ const { data } = await api.auth.oauthStart({ provider: "google" });
55
+ // redirect the browser to data.authorizationUrl
56
+ ```
57
+
58
+ The response carries `authorizationUrl`, `expiresInMs`, and the provider echo.
59
+ The flow row (state hash, PKCE verifier, nonce) lives for ten minutes.
60
+
61
+ </Step>
62
+
63
+ <Step>
64
+ ### Callback issues a session
65
+
66
+ The provider redirects to your callback route; the flow verifies state,
67
+ exchanges the code, and signs the person in:
68
+
69
+ ```typescript
70
+ const body = {
71
+ accessToken: "...",
72
+ refreshToken: "...",
73
+ userId: "usr_...",
74
+ };
75
+ ```
76
+
77
+ A brand-new visitor gets a fresh account. Someone whose email already belongs
78
+ to another credential is refused with `email_in_use`.
79
+
80
+ </Step>
81
+
82
+ </Steps>
83
+
84
+ ## Providers
85
+
86
+ | Provider | Shape | Identity source | Email verified |
87
+ | --------- | ------ | ------------------------ | ------------------------------------ |
88
+ | Google | OIDC | JWKS-verified ID token | `email_verified` claim |
89
+ | Apple | OIDC | JWKS-verified ID token | strict parse — `"false"` stays false |
90
+ | Microsoft | OIDC | JWKS-verified ID token | `email_verified` claim |
91
+ | GitHub | OAuth2 | `/user` + `/user/emails` | primary `verified` flag |
92
+ | Discord | OAuth2 | `/users/@me` | `verified` flag |
93
+ | X | OAuth2 | `/2/users/me` | never — no trustworthy signal |
94
+ | Facebook | OAuth2 | Graph `/me` | never — no trustworthy signal |
95
+ | Figma | OAuth2 | `/v1/me` | never — no verification field |
96
+
97
+ Each provider has its own page under this category — endpoints, scopes, setup,
98
+ and trust rules.
99
+
100
+ ## Security model
101
+
102
+ | Threat | Defense |
103
+ | ---------------------------------- | ----------------------------------------------------------------------- |
104
+ | Code interception | PKCE S256 on every provider |
105
+ | CSRF / forged callbacks | single-use `state`, SHA-256-hashed at rest |
106
+ | Mix-up across providers (RFC 9700) | per-provider callback routes + issuer pinning on every assertion |
107
+ | Unverified-email takeover | `linkOrProvision` refuses `email_in_use` without an authenticated owner |
108
+ | Redirect manipulation | byte-exact stored `redirect_uri` at token exchange |
109
+
110
+ **Consequence:** an attacker who completes a social login claiming your email
111
+ gains nothing — the account stays untouched unless they already own a session
112
+ for it.
113
+
114
+ ## Options
115
+
116
+ | Option | Type | Default | Meaning |
117
+ | --------------- | ------------------- | -------- | ---------------------------------------- |
118
+ | `providers` | map | `{}` | Per-provider config, disabled by default |
119
+ | `baseUrl` | `string` | — | Origin for synthesized callback URIs |
120
+ | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
121
+ | `sessions` | `SessionStore` | active\* | Session store |
122
+ | `identities` | `IdentityStore` | active\* | Shared user store |
123
+ | `verifications` | `VerificationStore` | new | Flow-state store |
124
+ | `fetch` | `typeof fetch` | global | Injectable transport (tests) |
125
+
126
+ Per-provider config: `enabled`, `clientId`, `redirectUri`, `scopes`,
127
+ `storeProviderTokens`, plus Microsoft `tenant` and Apple `teamId` / `keyId`.
128
+
129
+ ## Surfaces
130
+
131
+ | Flow | Path | Gate |
132
+ | --------------------- | ------------------------------------------ | ------------------------ |
133
+ | `auth.oauthStart` | `POST /auth/oauth/{provider}/start` | `gate.public` + otp rate |
134
+ | `auth.oauthCallback` | `GET+POST /auth/oauth/callback/{provider}` | `gate.public` + otp rate |
135
+ | `auth.oauthLinkStart` | `POST /auth/oauth/{provider}/link` | session + bearer |
136
+
137
+ The GET + POST callback pair exists because Apple posts its response instead of
138
+ redirecting.
139
+
140
+ ## Troubleshooting
141
+
142
+ <Accordions>
143
+ <Accordion title="Boot fails listing OAUTH_…_CLIENT_SECRET">
144
+
145
+ Every enabled provider needs its secret in Vault before boot. Seed
146
+ `OAUTH_{PROVIDER}_CLIENT_SECRET` (Apple uses `OAUTH_APPLE_PRIVATE_KEY`) through
147
+ your Vault driver or `.env.local`.
148
+
149
+ </Accordion>
150
+ <Accordion title="Callback returns invalid_state">
151
+
152
+ The state was consumed, expired (ten-minute TTL), or minted by a different
153
+ provider's start call. Restart from `/start`.
154
+
155
+ </Accordion>
156
+ <Accordion title="Callback returns issuer_mismatch">
157
+
158
+ The ID token's issuer does not match the provider that started the flow — the
159
+ signature was valid but the token came from elsewhere. This rejection is the
160
+ mix-up defense working; retry the real provider.
161
+
162
+ </Accordion>
163
+ <Accordion title="Callback returns email_in_use">
164
+
165
+ The provider returned an email already registered to another account without
166
+ proof you own that account. Sign in with the original method first, then link
167
+ via `POST /auth/oauth/{provider}/link`.
168
+
169
+ </Accordion>
170
+ </Accordions>
171
+
172
+ ## Learn more
173
+
174
+ - [Gate](/docs/elements/gate) — `gate.auth`
175
+ - [Passkey](/docs/plugins/passkey) — phishing-resistant alternative
176
+ - [Client](/docs/reference/client) — calling `/auth` from the browser
177
+
178
+ ## Next
179
+
180
+ <Cards>
181
+ <Card title="Google" description="OIDC reference provider." href="/docs/plugins/google" />
182
+ <Card
183
+ title="GitHub"
184
+ description="OAuth2 with verified-email lookup."
185
+ href="/docs/plugins/github"
186
+ />
187
+ <Card title="Passkey" description="WebAuthn-shaped passkeys." href="/docs/plugins/passkey" />
188
+ </Cards>
@@ -0,0 +1,125 @@
1
+ ---
2
+ title: "X"
3
+ description: "Official plugin — X (Twitter) sign-in as a PKCE public client with confidential-client secrets left out."
4
+ icon: "X"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ X is the one provider where `oauth()` behaves as a **public client**: the
9
+ token exchange carries the code verifier but no secret. PKCE is the only
10
+ proof the callback is yours.
11
+
12
+ <Callout title="The one rule">
13
+ Enable OAuth 2.0 in the X Developer Portal, set up your exact callback URI, and request
14
+ `users.email` access if you need addresses. No `OAUTH_X_CLIENT_SECRET` exists — none is read.
15
+ </Callout>
16
+
17
+ ## Quick start
18
+
19
+ <Steps>
20
+
21
+ <Step>
22
+ ### Configure the app
23
+
24
+ X Developer Portal → Project → User authentication settings → **Set up**.
25
+ Choose _Web App_, enable OAuth 2.0, and add
26
+ `https://app.example.com/auth/oauth/callback/x` as a callback URI.
27
+
28
+ </Step>
29
+
30
+ <Step>
31
+ ### Plug it
32
+
33
+ ```typescript title="src/app.ts"
34
+ import { oke } from "okengine";
35
+ import { oauth } from "okengine/plugins";
36
+
37
+ export const app = oke({
38
+ name: "shop",
39
+ env: "dev",
40
+ gate: { auth: {} },
41
+ }).plug(
42
+ oauth({
43
+ baseUrl: "https://app.example.com",
44
+ providers: {
45
+ x: { enabled: true },
46
+ },
47
+ }),
48
+ );
49
+ ```
50
+
51
+ </Step>
52
+
53
+ </Steps>
54
+
55
+ That is the whole setup — no Vault contract for this provider.
56
+
57
+ ## How identity works
58
+
59
+ | Aspect | Behavior |
60
+ | -------------- | ------------------------------------------------------------------------------------------- |
61
+ | Token exchange | `POST https://api.x.com/2/oauth2/token` with the code verifier, no secret |
62
+ | Profile | `GET /2/users/me?user.fields=id,name,username,confirmed_email` |
63
+ | Subject | the string `id` inside `data` |
64
+ | Email trust | **always unverified** — `confirmed_email` gates API access, it does not attest verification |
65
+
66
+ **Consequence:** an X email never claims an existing account during sign-up.
67
+ It can attach to a fresh account or to an existing one you already control via
68
+ authenticated linking.
69
+
70
+ Default scopes: `users.read`, `tweet.read`.
71
+
72
+ ## Options
73
+
74
+ | Option | Type | Default | Meaning |
75
+ | --------------------- | ---------- | --------------- | ---------------------------------- |
76
+ | `enabled` | `boolean` | `false` | Turn the provider on |
77
+ | `clientId` | `string` | Vault/env\* | `\*OAUTH_X_CLIENT_ID` |
78
+ | `redirectUri` | `string` | `{baseUrl}…/x` | Exact registered URI |
79
+ | `scopes` | `string[]` | driver defaults | Extra scopes (`offline.access`, …) |
80
+ | `storeProviderTokens` | `boolean` | `false` | Keep tokens in Vault |
81
+
82
+ ## Surfaces
83
+
84
+ | Flow | Path |
85
+ | -------- | --------------------------------- |
86
+ | Start | `POST /auth/oauth/x/start` |
87
+ | Callback | `GET+POST /auth/oauth/callback/x` |
88
+ | Link | `POST /auth/oauth/x/link` |
89
+
90
+ ## Troubleshooting
91
+
92
+ <Accordions>
93
+ <Accordion title="invalid_request on token exchange">
94
+
95
+ Callback URIs must match exactly and the code must be fresh. X codes are
96
+ single-use and short-lived; flow rows expire after ten minutes too.
97
+
98
+ </Accordion>
99
+ <Accordion title="No email ever arrives">
100
+
101
+ `confirmed_email` requires elevated access plus the `users.email` scope. Even
102
+ then the address stays unverified by design — see the trust table above.
103
+
104
+ </Accordion>
105
+ <Accordion title="403 forbidden">
106
+
107
+ Your app tier lacks user-context authentication, or the requested scope set
108
+ exceeds what the portal grants.
109
+
110
+ </Accordion>
111
+ </Accordions>
112
+
113
+ ## Learn more
114
+
115
+ - [OAuth](/docs/plugins/oauth) — shared flows and security model
116
+ - [Facebook](/docs/plugins/facebook) — also never-verified emails
117
+ - [Vault](/docs/elements/vault) — optional token storage
118
+
119
+ ## Next
120
+
121
+ <Cards>
122
+ <Card title="Facebook" description="Never-verified emails." href="/docs/plugins/facebook" />
123
+ <Card title="Figma" description="Basic-auth token endpoint." href="/docs/plugins/figma" />
124
+ <Card title="Discord" description="Nullable emails." href="/docs/plugins/discord" />
125
+ </Cards>
@@ -27,7 +27,8 @@ oke test # bun test with PGLite posture (NODE_ENV=test,
27
27
  oke mode # removed — prints error; use oke dev (Compose) / oke test (PGLite)
28
28
 
29
29
  oke start # runs exactly what production runs (this is the Docker CMD)
30
- oke doctor # verify secrets, ports, drivers, tenancy, schema drift
30
+ oke doctor # verify secrets, ports, drivers, tenancy, schema drift, FD headroom,
31
+ # CDC outbox backlog/retention, live fan-out pressure
31
32
  oke console claim-code # print setup claim code from .oke/claim-code (after Console boot)
32
33
  # → `bun run dev:console` boots kernel + Vite with a fixed
33
34
  # operator (`dev@oke.dev` / `Okengine123!`) — Sign in (prefilled)
@@ -141,7 +142,7 @@ the hero (not once per process).
141
142
  that `@electric-sql/pglite` resolves, then forwards argv to `bun test`.
142
143
 
143
144
  `oke mode` and `oke dev --local` / `-l` are removed — they exit with an error.
144
- See [Migrating environments](/docs/reference/migrating-environments).
145
+ Rename the env keys in `oke.config.ts` to `dev` / `test` / `prod`.
145
146
 
146
147
  ### create-oke
147
148
 
@@ -172,7 +172,8 @@ const { data } = await api.auth.signInEmail({ email, password });
172
172
  if (data) session.set(data);
173
173
  ```
174
174
 
175
- React: `useSession(api, session)` and `useLive(api, signal, input?)` from `okengine/client-react`.
175
+ React: `useSession(api, session)`, `useLive(api, signal, input?)`, and
176
+ `useLiveQuery({ api, listFlow, live, query? })` from `okengine/client-react`.
176
177
 
177
178
  | Step | What happens |
178
179
  | ---------------------- | ---------------------------------------------------------------- |
@@ -205,6 +206,7 @@ A 401 with no `{ data, error }` body becomes `TransportError` with `data.status:
205
206
  | `AUTH_ERROR_CODES` | `okengine/client/auth` | Common auth Flow / gate codes |
206
207
  | `useSession(api, session?)` | `okengine/client-react` | React status + `auth.me` |
207
208
  | `useLive(api, signal, …)` | `okengine/client-react` | React `events` / `latest` / `isConnected` |
209
+ | `useLiveQuery({ … })` | `okengine/client-react` | Live list + optimistic `mutate` over a resource |
208
210
 
209
211
  Core `okengine/client` stays under the size budget — helpers are separate exports. Not in
210
212
  core today: cookie jars or plugin `.client()` decorations. Browser apps: also see
@@ -305,6 +307,61 @@ const { events, latest, error, isConnected } = useLive(
305
307
  );
306
308
  ```
307
309
 
310
+ ### Live queries (`store.resource({ live: true })`)
311
+
312
+ When a resource opts into `live: true`, the compiler mounts `GET <path>/live` next to the CRUD
313
+ verbs. That route streams **classified** row events — not a shared tape — so each subscriber only
314
+ sees rows that still pass their RLS stamp + list filters:
315
+
316
+ | `kind` | Meaning |
317
+ | --------- | -------------------------------------------------------------- |
318
+ | `upsert` | Row visible under stamp + query — merge/replace by primary key |
319
+ | `revoked` | Row left visibility (`reason: "rls"` \| `"query"`) — remove |
320
+ | `delete` | Row deleted in CDC — remove |
321
+
322
+ ```typescript
323
+ import { useLiveQuery } from "okengine/client-react";
324
+
325
+ const { data, error, isLoading, isConnected, isReconnecting, refetch, mutate } = useLiveQuery({
326
+ api,
327
+ listFlow: api.tasks.list,
328
+ query: { status: "open" },
329
+ live: { method: "GET", path: "/tasks/live" }, // from app.$routes
330
+ options: {
331
+ enabled: session.status === "authenticated", // default true — idle when false
332
+ refreshKey: tenantId, // identity change → full re-subscribe
333
+ onAuthRefresh: onAuthRefreshed, // auth.refresh() → new snapshot + replay
334
+ },
335
+ });
336
+
337
+ await mutate(
338
+ api.tasks.update,
339
+ { id, status: "done" },
340
+ {
341
+ optimistic: (rows) => rows.map((r) => (r.id === id ? { ...r, status: "done" } : r)),
342
+ pkOf: (input) => input.id,
343
+ },
344
+ );
345
+ ```
346
+
347
+ Every `mutate` call generates a client UUID sent as the `X-Oke-Mutation-Id`
348
+ header — the server echoes it onto that write's CDC events, so:
349
+
350
+ - Your own late SSE echoes never double-apply (pending-set dedupe).
351
+ - Reconnects replay-guard by event `seq` (`isReplayedEvent`).
352
+ - Manual `refetch()` re-runs only the HTTP list read; reconnects always do a
353
+ full subscribe-protocol cycle (new snapshot + replay).
354
+
355
+ | State | Meaning |
356
+ | ---------------- | ------------------------------------------------------------------- |
357
+ | `isLoading` | Waiting for the first snapshot — no data yet |
358
+ | `isConnected` | SSE stream is open |
359
+ | `isReconnecting` | Stream dropped after a successful load; reconnect backoff in flight |
360
+
361
+ **Consequence:** optimistic patches roll back automatically when the Flow returns
362
+ `error !== null`. Server CDC / the successful response clear the override so the next upsert is
363
+ authoritative.
364
+
308
365
  ## Exports
309
366
 
310
367
  | Export | Kind | Role |
@@ -28,8 +28,7 @@ export default defineConfig({
28
28
  | `prod` | Production deploy | Shared durable backends |
29
29
 
30
30
  Missing `dev` pins fill from `prod` (`fillDevFromProd`). Only `dev` / `test` / `prod` keys are
31
- valid — see [Migrating environments](/docs/reference/migrating-environments) if you still have
32
- `local` / `docker` maps.
31
+ valid — rename `local` `dev` and `docker` → `prod` if you still have the old maps.
33
32
 
34
33
  ## Quick start
35
34
 
@@ -246,7 +245,7 @@ Domain schema sync for `oke db push | generate | migrate` (Drizzle). Unrelated t
246
245
 
247
246
  <Accordion title='oke.config: … uses removed driver "sqlite"'>
248
247
  The `sqlite` driver is gone. Pin `postgres` for `dev`/`prod` and `pglite` for `test`. Edit the
249
- file by hand — see [Migrating environments](/docs/reference/migrating-environments).
248
+ file by hand — rename the env keys to `dev` / `test` / `prod` first.
250
249
  </Accordion>
251
250
 
252
251
  <Accordion title='drivers.store.sql.test must be "pglite"'>
@@ -268,7 +267,6 @@ Domain schema sync for `oke db push | generate | migrate` (Drizzle). Unrelated t
268
267
 
269
268
  ## Learn more
270
269
 
271
- - [Migrating environments](/docs/reference/migrating-environments) — four-key maps → `dev`/`test`/`prod`
272
270
  - [CLI Reference](/docs/reference/cli) — `oke dev` · `oke test` · `oke db`
273
271
  - [Environment Variables](/docs/reference/environment-variables) — URL and secret resolution
274
272
  - [Store](/docs/elements/store) — what the store drivers back
@@ -230,7 +230,9 @@ Returning a plain value instead answers 200 with `{ data: value, error: null }`
230
230
  | `fx.log.debug/info/warn/error(msg, data?)` | Redacting — secrets print as `***` |
231
231
  | `fx.t(key, values?)` | ICU MessageFormat — active locale → `i18n.default` → key |
232
232
  | `fx.locale` | Active locale (`Accept-Language` matched to `i18n.locales`) |
233
- | `fx.id()` | UUIDthe only legal id generator |
233
+ | `fx.id()` | OKID21-char native id from `okengine/okid` |
234
+
235
+ See [OKID](/docs/reference/okid) for generation options and the entropy model.
234
236
 
235
237
  `fx.t` uses [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/)
236
238
  (FormatJS): `{name}`, plurals, `select` / `selectordinal`, rich-text tags.
@@ -15,11 +15,6 @@ Dense tables and command lists. Reach for these when you already know what you a
15
15
  description="Every option in oke.config.ts."
16
16
  href="/docs/reference/configuration"
17
17
  />
18
- <Card
19
- title="Migrating environments"
20
- description="local/docker maps → dev/test/prod."
21
- href="/docs/reference/migrating-environments"
22
- />
23
18
  <Card title="fx" description="The complete fx surface and effects." href="/docs/reference/fx" />
24
19
  <Card
25
20
  title="i18n"
@@ -4,7 +4,6 @@
4
4
  "pages": [
5
5
  "index",
6
6
  "configuration",
7
- "migrating-environments",
8
7
  "fx",
9
8
  "i18n",
10
9
  "client",
@@ -12,6 +11,7 @@
12
11
  "errors",
13
12
  "plugins",
14
13
  "cli",
15
- "security"
14
+ "security",
15
+ "okid"
16
16
  ]
17
17
  }
@@ -0,0 +1,137 @@
1
+ ---
2
+ title: "OKID"
3
+ description: "OKE's native id generator — compact, URL-safe, cryptographically random ids from okengine/okid, with an opt-in time-sortable and alphabet-controlled variant."
4
+ icon: "Hash"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ Using `okengine/okid` gives you an id for any primary key, request trace, or event that is short (`okid()` is 21 characters), URL-safe, and random from a cryptographic source. Turn to it when a plain UUID string is more than you need; your app already generates them wherever `defaultFn(id)` is used.
9
+
10
+ <Callout title="The one rule">
11
+ Use OKID for identity, never for secrets. An id is enumerable by design, so anything you hand to
12
+ an untrusted client must be a token from the Vault, not an OKID.
13
+ </Callout>
14
+
15
+ ## Quick start
16
+
17
+ <Steps>
18
+
19
+ <Step>
20
+ ### Install nothing — it is exported by the package
21
+
22
+ ```typescript
23
+ import { okid } from "okengine/okid";
24
+ ```
25
+
26
+ </Step>
27
+
28
+ <Step>
29
+ ### Generate an id
30
+
31
+ ```typescript
32
+ const userId = okid();
33
+ const requestId = okid(16);
34
+ const eventKey = okid({ sortable: true });
35
+ const inviteCode = okid({ lookAlikes: false, uppercase: false });
36
+ ```
37
+
38
+ </Step>
39
+
40
+ <Step>
41
+ ### Store it anywhere a string fits
42
+
43
+ ```typescript
44
+ // field.id() is shorthand for "default generation id" — currently OK ID.
45
+ field.id().primaryKey();
46
+ // Or pin OK ID explicitly:
47
+ field.okid().primaryKey();
48
+ ```
49
+
50
+ The same 21-character id lands in your SQL primary keys, KV keys, and trace ids.
51
+
52
+ </Step>
53
+
54
+ </Steps>
55
+
56
+ ## Reference
57
+
58
+ | Call | Result | Notes |
59
+ | -------------------------------------------------- | ----------------------------------------- | --------------------------------------- |
60
+ | `okid()` | 21-char URL-safe id, 126 bits of entropy | 64-char alphabet, `a-zA-Z0-9-_` |
61
+ | `okid(length)` | id of exactly `length` characters | integer between 8 and 128 |
62
+ | `okid({ length })` | options form, id of `length` | default 21 |
63
+ | `okid({ sortable })` | time-prefixed id, 8 prefix + `length − 8` | lexicographic order ≈ creation order |
64
+ | `okid({ numbers, lowercase, uppercase, symbols })` | charset control | each group defaults to on |
65
+ | `okid({ lookAlikes })` | confusable-char control | `lookAlikes: false` drops `1lI0Oouv5Ss` |
66
+
67
+ ### Options
68
+
69
+ | Option | Type | Default | Meaning |
70
+ | ------------ | --------- | ------- | ----------------------------------------------------------- |
71
+ | `length` | `number` | `21` | total id length in characters, between 8 and 128 |
72
+ | `sortable` | `boolean` | `false` | prefix an 8-char epoch-ms timestamp so sorting follows time |
73
+ | `numbers` | `boolean` | `true` | include `0-9` |
74
+ | `lowercase` | `boolean` | `true` | include `a-z` |
75
+ | `uppercase` | `boolean` | `true` | include `A-Z` |
76
+ | `symbols` | `boolean` | `true` | include `-` and `_` |
77
+ | `lookAlikes` | `boolean` | `true` | include confusable chars `1lI0Oouv5Ss`; set `false` to drop |
78
+
79
+ ### Exported constants
80
+
81
+ | Constant | Value | Meaning |
82
+ | -------------------------- | ----------------------------------------- | -------------------------------- |
83
+ | `OKID_ALPHABET` | `a-zA-Z0-9-_` | default, Base64URL order |
84
+ | `OKID_SORTABLE_ALPHABET` | alphabet sorted by code unit (same chars) | used by the sortable encoder |
85
+ | `OKID_LOOKALIKE_CHARS` | `1lI0Oouv5Ss` | dropped when `lookAlikes: false` |
86
+ | `OKID_DEFAULT_LENGTH` | `21` | default length |
87
+ | `OKID_MIN_LENGTH` | `8` | shortest non-sortable length |
88
+ | `OKID_MAX_LENGTH` | `128` | longest length |
89
+ | `OKID_SORTABLE_MIN_LENGTH` | `16` | shortest sortable length (8+8) |
90
+
91
+ ## Collision resistance
92
+
93
+ Every character is drawn uniformly from the alphabet with `crypto.getRandomValues()`. Because it uses an unbiased character selection (never modulo), each character carries exactly `log2(alphabet)` bits of entropy. At the default 21 characters over 64 symbols, that is 126 bits — the birthday-bound collision probability across one billion ids is on the order of `10⁻²¹`. You do not need a UUID for collision resistance; this is where a UUID is stronger only because it is a different format, not a different amount of randomness.
94
+
95
+ **Consequence:** two ids minted at the same millisecond are still distinct — the timestamp prefix never replaces entropy, it prefixes it.
96
+
97
+ ## Sortable ids
98
+
99
+ `sortable: true` prepends 48 bits of `Date.now()` encoded in exactly 8 characters, in an alphabet whose sort order matches time order. Sorting a batch of these ids reproduces the creation order across milliseconds.
100
+
101
+ **Consequence:** a sortable id embeds its creation time (millisecond precision), so keep them out of public, enumerable surfaces. Clock skew distorts order but can never produce a duplicate — the tail stays random.
102
+
103
+ ## Alphabet control
104
+
105
+ Turning groups off shrinks the alphabet. With a non-power-of-two alphabet, OKID uses rejection sampling instead of modulo, so every remaining character stays equally likely — the output never becomes measurably biased.
106
+
107
+ **Consequence:** smaller alphabets mean fewer bits per character. `lookAlikes: false` alone drops the default entropy only slightly (126 → ~120 bits); dropping whole groups costs more. Choose the smallest alphabet that fits the human-transcription use case.
108
+
109
+ ## Under the hood
110
+
111
+ The generator is a pure function: no counters, no process or machine fingerprint, no shared mutable state. It is safe to call concurrently from any number of workers, and every id uses only the bytes it needs — no hidden timestamp, no hidden prefix.
112
+
113
+ ## Troubleshooting
114
+
115
+ <Accordions>
116
+ <Accordion title="I get an error for an empty alphabet">
117
+
118
+ Passing `numbers: false, lowercase: false, uppercase: false, symbols: false` at the same time throws a `RangeError` with the message `okid: alphabet is empty — enable at least one character group`. Re-enable at least one group, or don't use the option object and rely on the default alphabet.
119
+
120
+ </Accordion>
121
+ <Accordion title="I get a RangeError for a length">
122
+
123
+ `okid(0)`, `okid(-1)`, `okid(7)`, `okid(129)`, and non-integer lengths throw a `RangeError`. Sortable ids have a higher floor: passing a sortable length below 16 throws. Keep lengths between 8 and 128 (16–128 for sortable).
124
+
125
+ </Accordion>
126
+ <Accordion title="My ids are not sortable by the alphabet order I expected">
127
+
128
+ The default alphabet order is not lexicographic; `_` sorts between uppercase and lowercase. When `sortable` is on, ids use the code-point-ordered alphabet, so plain string comparison matches time order. Do not customize the alphabet in sortable mode — the option exists exactly because the default order is not trustworthy for ordering.
129
+
130
+ </Accordion>
131
+ </Accordions>
132
+
133
+ ## References
134
+
135
+ - [Store](/docs/elements/store) — `defaultFn(id)` in table declarations delegates to `okid()`
136
+ - [fx](/docs/reference/fx) — `fx.id()` in the runtime uses `okid()`
137
+ - [Clock](/docs/elements/clock) — process `instanceId` (`inst-<okid>`) is an OKID
@@ -175,7 +175,7 @@ export function createAuthHttpBindings(
175
175
  } = {},
176
176
  ): AuthHttpMaterialization {
177
177
  const sessions = options.sessions ?? config.sessions ?? createSessionStore();
178
- const identities = options.identities ?? createIdentityStore();
178
+ const identities = options.identities ?? config.identities ?? createIdentityStore();
179
179
  const loginAttempts = createLoginAttemptBag();
180
180
  const rateGates = createAuthRateGates(options.rateLimitEnabled !== false);
181
181
  const policyGates = createAuthPolicyGates(config);
@@ -8,6 +8,7 @@ import type { PasswordHashOptions } from "../runtime/types.ts";
8
8
  import type { SessionStore } from "./sessions.ts";
9
9
  import { createApiKeyStore, type ApiKeyStore } from "./api-keys.ts";
10
10
  import type { BreachCheckFn } from "./breach-check.ts";
11
+ import { createIdentityStore, type IdentityStore } from "./identity.ts";
11
12
  import type { PasswordPolicyOptions } from "./password-policy.ts";
12
13
  import {
13
14
  resolveAuthSchema,
@@ -116,6 +117,11 @@ export interface GateAuthOptions extends AuthSchemaOptions {
116
117
  * Not part of the public app DX — escape hatch for embedding.
117
118
  */
118
119
  readonly sessions?: SessionStore;
120
+ /**
121
+ * Shared identity / credential store. Created automatically when omitted.
122
+ * All Gate auth method plugins resolve against this one store by default.
123
+ */
124
+ readonly identities?: IdentityStore;
119
125
  /**
120
126
  * Shared API key store. Created automatically when omitted
121
127
  * (`gate.auth.secret` is the HMAC pepper).
@@ -186,6 +192,8 @@ export interface ResolvedGateAuth {
186
192
  readonly hooks: AuthDatabaseHooks | undefined;
187
193
  readonly sessions: SessionStore | undefined;
188
194
  readonly apiKeyStore: ApiKeyStore;
195
+ /** Shared identity store — the single user/credential source across Gate auth + method plugins. */
196
+ readonly identities: IdentityStore;
189
197
  readonly tenant: ResolvedTenantAuth | undefined;
190
198
  readonly tenantStore: TenantStore | undefined;
191
199
  readonly now: (() => number) | undefined;
@@ -296,6 +304,7 @@ export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAu
296
304
  hooks: auth.hooks,
297
305
  sessions: auth.sessions,
298
306
  apiKeyStore: auth.apiKeyStore ?? createApiKeyStore({ pepper: secret }),
307
+ identities: auth.identities ?? createIdentityStore(),
299
308
  tenant: auth.tenant ? resolveTenantAuth(auth.tenant) : undefined,
300
309
  tenantStore: auth.tenant ? (auth.tenantStore ?? createTenantStore()) : auth.tenantStore,
301
310
  now: auth.now,