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
package/src/okid.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * OKID — OKE's native id generator.
3
+ *
4
+ * A compact, URL-safe, cryptographically random identifier with an opt-in
5
+ * time-sortable variant and opt-in alphabet control. Zero dependencies, zero
6
+ * shared state, safe under concurrent generation.
7
+ *
8
+ * Design properties:
9
+ *
10
+ * - **Default form** — 21 chars over a 64-char URL-safe alphabet
11
+ * (`A-Za-z0-9-_`) = exactly 6 bits per char = **126 bits** of entropy.
12
+ * Birthday bound: n² / 2¹²⁷ — generating one billion ids yields a collision
13
+ * probability around 10⁻²¹. No timestamps, counters, or machine
14
+ * fingerprints are exposed by default.
15
+ * - **Sortable variant** — `okid({ sortable: true })` prefixes the id with a
16
+ * 48-bit epoch-millisecond timestamp encoded in exactly 8 chars, leaving
17
+ * `length − 8` random chars (78 bits at the default length). Lexicographic
18
+ * order equals time order across milliseconds; ids minted within the same
19
+ * millisecond tie on the prefix and carry no intra-ms ordering. Clock skew
20
+ * distorts ordering but can never cause duplicates (the tail stays random).
21
+ * - **Alphabet control** — group toggles (`numbers`, `lowercase`,
22
+ * `uppercase`, `symbols`) and `lookAlikes` shrink the alphabet for
23
+ * human-transcribed codes. Non-power-of-two alphabets use rejection
24
+ * sampling, so every character remains equally likely — no modulo bias at
25
+ * any alphabet size.
26
+ * - **Randomness** — exclusively `crypto.getRandomValues()`. Never
27
+ * `Math.random`. Stateless, therefore concurrency-safe.
28
+ *
29
+ * Use OKID for application identifiers: database primary keys, request /
30
+ * job / workflow / resource ids. Do NOT use it as a secret or token (ids are
31
+ * not unguessable credentials), and use UUID instead where an external
32
+ * protocol explicitly requires that format. Sortable ids embed their creation
33
+ * time (~ms precision) — keep them internal, not publicly enumerable.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { okid } from "okengine/okid";
38
+ *
39
+ * const userId = okid(); // 21 chars, 126 bits
40
+ * const requestId = okid(16); // explicit length
41
+ * const eventKey = okid({ sortable: true }); // time-prefixed
42
+ * const inviteCode = okid({
43
+ * lookAlikes: false,
44
+ * uppercase: false,
45
+ * }); // human-transcribable
46
+ * ```
47
+ *
48
+ * @module
49
+ */
50
+
51
+ /** Character groups addressable through {@link OkidOptions} toggles. */
52
+ const GROUPS = {
53
+ numbers: "0123456789",
54
+ lowercase: "abcdefghijklmnopqrstuvwxyz",
55
+ uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
56
+ symbols: "-_",
57
+ } as const;
58
+
59
+ /**
60
+ * Characters removed when `lookAlikes` is disabled (human transcription).
61
+ */
62
+ export const OKID_LOOKALIKE_CHARS = "1lI0Oouv5Ss";
63
+
64
+ /** Default URL-safe alphabet: Base64URL charset in its conventional order. */
65
+ export const OKID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
66
+
67
+ /**
68
+ * Codepoint-ordered variant of {@link OKID_ALPHABET}: identical characters,
69
+ * sorted by code unit so plain string comparison equals value comparison.
70
+ * The default Base64URL order is NOT lexicographic (`_` sorts between `Z`
71
+ * and `a`), which would silently break time ordering — so the sortable
72
+ * encoder always uses this order.
73
+ *
74
+ * Kept as a literal (not `[...OKID_ALPHABET].sort().join("")`) so the public
75
+ * JSR API stays fast-type / explicitly typed.
76
+ */
77
+ export const OKID_SORTABLE_ALPHABET: string =
78
+ "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
79
+
80
+ /** Default id length — 21 chars × 6 bits = 126 bits of entropy. */
81
+ export const OKID_DEFAULT_LENGTH = 21;
82
+
83
+ /** Shortest permitted id length (8 chars ≈ 48 bits entropy floor). */
84
+ export const OKID_MIN_LENGTH = 8;
85
+
86
+ /** Longest permitted id length. */
87
+ export const OKID_MAX_LENGTH = 128;
88
+
89
+ /** Sortable ids need ≥ 16: 8 timestamp chars alone would leave no randomness. */
90
+ export const OKID_SORTABLE_MIN_LENGTH = 16;
91
+
92
+ /** Options for {@link okid}. All alphabet toggles default to included. */
93
+ export interface OkidOptions {
94
+ /** Total id length (default {@link OKID_DEFAULT_LENGTH}). */
95
+ readonly length?: number;
96
+ /**
97
+ * Prefix a 48-bit epoch-ms timestamp (exactly 8 chars) so lexicographic
98
+ * order tracks creation time across milliseconds. Alphabet toggles are
99
+ * ignored under this mode — see {@link OKID_SORTABLE_ALPHABET}.
100
+ */
101
+ readonly sortable?: boolean;
102
+ /** Include `a-z` (default true). */
103
+ readonly lowercase?: boolean;
104
+ /** Include `A-Z` (default true). */
105
+ readonly uppercase?: boolean;
106
+ /** Include `0-9` (default true). */
107
+ readonly numbers?: boolean;
108
+ /** Include `-` and `_` (default true). */
109
+ readonly symbols?: boolean;
110
+ /**
111
+ * Include visually confusable characters (`1lI0Oouv5Ss`, default true).
112
+ * Set `false` to drop them for human transcription.
113
+ */
114
+ readonly lookAlikes?: boolean;
115
+ }
116
+
117
+ /** Resolved alphabet + encoding metadata for one options combination. */
118
+ interface ResolvedAlphabet {
119
+ readonly chars: string;
120
+ readonly size: number;
121
+ /** Bitmask covering `size` values (`size` is always a power of two here). */
122
+ readonly mask: number;
123
+ }
124
+
125
+ /** Memoized resolutions keyed by the toggle bitmask (32 combinations max). */
126
+ const ALPHABET_CACHE = new Map<number, ResolvedAlphabet>();
127
+
128
+ function resolveAlphabet(
129
+ numbers: boolean,
130
+ lowercase: boolean,
131
+ uppercase: boolean,
132
+ symbols: boolean,
133
+ lookAlikes: boolean,
134
+ ): ResolvedAlphabet {
135
+ const key =
136
+ (numbers ? 1 : 0) |
137
+ (lowercase ? 2 : 0) |
138
+ (uppercase ? 4 : 0) |
139
+ (symbols ? 8 : 0) |
140
+ (lookAlikes ? 0 : 16);
141
+ const cached = ALPHABET_CACHE.get(key);
142
+ if (cached) return cached;
143
+
144
+ let chars = "";
145
+ if (numbers) chars += GROUPS.numbers;
146
+ if (lowercase) chars += GROUPS.lowercase;
147
+ if (uppercase) chars += GROUPS.uppercase;
148
+ if (symbols) chars += GROUPS.symbols;
149
+ if (!chars) {
150
+ throw new RangeError("okid: alphabet is empty — enable at least one character group");
151
+ }
152
+ if (!lookAlikes) {
153
+ chars = [...chars].filter((c) => !OKID_LOOKALIKE_CHARS.includes(c)).join("");
154
+ }
155
+
156
+ // Round up to a power of two for mask-based rejection sampling: bytes below
157
+ // `size` map uniformly, bytes above are discarded and re-drawn — unbiased at
158
+ // every alphabet size, unlike naive modulo.
159
+ const rawSize = chars.length;
160
+ const size = 1 << Math.ceil(Math.log2(rawSize));
161
+ const resolved: ResolvedAlphabet = { chars, size: rawSize, mask: size - 1 };
162
+ ALPHABET_CACHE.set(key, resolved);
163
+ return resolved;
164
+ }
165
+
166
+ /** Encode one random byte stream into `length` characters of `alphabet`. */
167
+ function encode(alphabet: ResolvedAlphabet, length: number): string {
168
+ const { chars, size, mask } = alphabet;
169
+ const bytes = new Uint8Array(length + Math.ceil(length >> 2));
170
+ crypto.getRandomValues(bytes.subarray(0, length));
171
+ let out = "";
172
+ let i = 0;
173
+ while (out.length < length && i < bytes.length) {
174
+ const byte = bytes[i++]!;
175
+ if ((byte & mask) < size) out += chars[byte & mask];
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /** Pack epoch-ms into exactly 8 codepoint-ordered characters (48 bits). */
181
+ function encodeTimestamp(nowMs: number): string {
182
+ let t = nowMs % 2 ** 48;
183
+ let out = "";
184
+ for (let i = 0; i < 8; i++) {
185
+ out = OKID_SORTABLE_ALPHABET[t & 63]! + out;
186
+ t = Math.floor(t / 64);
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Assert `length` is a valid integer within bounds for the requested mode.
193
+ *
194
+ * @param length - Requested length
195
+ * @param min - Mode-specific minimum
196
+ * @param label - Option name used in the error message
197
+ */
198
+ function assertLength(length: number, min: number, label: string): void {
199
+ if (!Number.isInteger(length)) {
200
+ throw new RangeError(`okid: ${label} must be an integer, got ${length}`);
201
+ }
202
+ if (length < min || length > OKID_MAX_LENGTH) {
203
+ throw new RangeError(`okid: ${label} ${length} is out of range [${min}, ${OKID_MAX_LENGTH}]`);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Generate an OKE-native id.
209
+ *
210
+ * Accepts either a bare length or an options object; the bare-number form is
211
+ * the hot path and skips all option resolution beyond validation.
212
+ *
213
+ * @param options - Length in chars, or {@link OkidOptions}
214
+ * @returns A URL-safe id of exactly the requested length
215
+ * @throws RangeError on invalid input (non-integer, out-of-range length,
216
+ * empty alphabet)
217
+ */
218
+ export function okid(options: number | OkidOptions = OKID_DEFAULT_LENGTH): string {
219
+ if (typeof options === "number") {
220
+ assertLength(options, OKID_MIN_LENGTH, "length");
221
+ return encode(resolveAlphabet(true, true, true, true, true), options);
222
+ }
223
+
224
+ const {
225
+ length = OKID_DEFAULT_LENGTH,
226
+ sortable = false,
227
+ lowercase = true,
228
+ uppercase = true,
229
+ numbers = true,
230
+ symbols = true,
231
+ lookAlikes = true,
232
+ } = options;
233
+
234
+ if (sortable) {
235
+ assertLength(length, OKID_SORTABLE_MIN_LENGTH, "length");
236
+ // Time ordering requires lexicographic encoding, which requires the full
237
+ // codepoint-ordered alphabet — partial subsets cannot preserve both the
238
+ // caller's charset choice AND cross-ms ordering, so toggles are ignored.
239
+ const alphabet = resolveAlphabet(true, true, true, true, true);
240
+ return encodeTimestamp(Date.now()) + encode(alphabet, length - 8);
241
+ }
242
+
243
+ assertLength(length, OKID_MIN_LENGTH, "length");
244
+ return encode(resolveAlphabet(numbers, lowercase, uppercase, symbols, lookAlikes), length);
245
+ }
@@ -2,6 +2,7 @@
2
2
  * Anonymous sign-in Gate auth method plugin.
3
3
  */
4
4
 
5
+ import { IdentityError, linkOrProvision } from "../auth/identity.ts";
5
6
  import { issueSessionWithScopes } from "../auth/sessions.ts";
6
7
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
7
8
  import {
@@ -10,7 +11,9 @@ import {
10
11
  SessionTokensOut,
11
12
  bindPublicAuth,
12
13
  createMethodRuntime,
14
+ fail,
13
15
  flow,
16
+ resolveSharedIdentities,
14
17
  type AuthMethodOptions,
15
18
  } from "./auth/shared.ts";
16
19
 
@@ -29,13 +32,28 @@ export interface AnonymousPluginOptions extends AuthMethodOptions {
29
32
  */
30
33
  export function anonymous(opts: AnonymousPluginOptions = {}): PluginDef {
31
34
  const runtime = createMethodRuntime(opts);
35
+ const identities = resolveSharedIdentities(opts);
32
36
 
33
37
  const signIn = flow("auth.signInAnonymous", {
34
38
  plane: "user",
35
39
  out: SessionTokensOut,
36
40
  errors: { AuthFailed, AuthRateLimited },
37
41
  do: async () => {
38
- const userId = crypto.randomUUID();
42
+ let userId: string;
43
+ try {
44
+ userId = (
45
+ await linkOrProvision(identities, {
46
+ provider: "anonymous",
47
+ providerAccountId: crypto.randomUUID(),
48
+ now: runtime.now,
49
+ })
50
+ ).user.id;
51
+ } catch (err) {
52
+ if (err instanceof IdentityError) {
53
+ return fail("AuthFailed", { reason: "invalid_credentials" });
54
+ }
55
+ throw err;
56
+ }
39
57
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
40
58
  id: userId,
41
59
  plane: "user",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import { AUTH_RATE_PRESETS, AUTH_SESSION_GATE, bindAuthHttp } from "../../auth/bindings.ts";
7
+ import { createIdentityStore, type IdentityStore } from "../../auth/identity.ts";
7
8
  import { getActiveGateAuthContext } from "../../auth/method-context.ts";
8
9
  import { createSessionStore, type SessionCrypto, type SessionStore } from "../../auth/sessions.ts";
9
10
  import { gate } from "../../elements/gate.ts";
@@ -34,6 +35,8 @@ export interface AuthMethodOptions {
34
35
  readonly secret?: string;
35
36
  /** Shared session store (prefer the same store as `gate.auth`). */
36
37
  readonly sessions?: SessionStore;
38
+ /** Shared identity/credential store (defaults to `gate.auth.identities`). */
39
+ readonly identities?: IdentityStore;
37
40
  /** Injectable clock. */
38
41
  readonly now?: () => number;
39
42
  }
@@ -76,6 +79,18 @@ export function createMethodRuntime(opts: AuthMethodOptions = {}): {
76
79
  };
77
80
  }
78
81
 
82
+ /**
83
+ * Resolve the shared identity/credential store for a method plugin.
84
+ * Prefers explicit opts, then the active `gate.auth.identities`, else a new
85
+ * isolated store (advanced / test-injection).
86
+ *
87
+ * @param opts - Method options carrying an optional `identities`
88
+ */
89
+ export function resolveSharedIdentities(opts: AuthMethodOptions = {}): IdentityStore {
90
+ const active = getActiveGateAuthContext();
91
+ return opts.identities ?? active?.identities ?? createIdentityStore();
92
+ }
93
+
79
94
  /**
80
95
  * Public + rate gate chain for credential-ish auth paths.
81
96
  *
@@ -42,6 +42,8 @@ export {
42
42
  type PasskeyOptions,
43
43
  type PasskeyStore,
44
44
  } from "./passkey.ts";
45
+ export { oauth, type OAuthOptions } from "./oauth.ts";
46
+ export { mcpOauth, mcpOauthConsentFlows, type McpOauthOptions } from "./mcp-oauth.ts";
45
47
  export {
46
48
  b64urlDecode,
47
49
  b64urlEncode,
@@ -6,12 +6,7 @@
6
6
  * local DX without Mailpit / SMTP.
7
7
  */
8
8
 
9
- import {
10
- createIdentityStore,
11
- ensureUserByEmail,
12
- normalizeEmail,
13
- type IdentityStore,
14
- } from "../auth/identity.ts";
9
+ import { linkOrProvision, normalizeEmail, type IdentityStore } from "../auth/identity.ts";
15
10
  import { issueSessionWithScopes } from "../auth/sessions.ts";
16
11
  import {
17
12
  createVerificationStore,
@@ -29,6 +24,7 @@ import {
29
24
  createMethodRuntime,
30
25
  fail,
31
26
  flow,
27
+ resolveSharedIdentities,
32
28
  z,
33
29
  type AuthMethodOptions,
34
30
  } from "./auth/shared.ts";
@@ -90,7 +86,7 @@ export interface MagicLinkOptions extends AuthMethodOptions {
90
86
  */
91
87
  export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
92
88
  const runtime = createMethodRuntime(opts);
93
- const identities = opts.identities ?? createIdentityStore();
89
+ const identities = resolveSharedIdentities(opts);
94
90
  const verifications = opts.verifications ?? createVerificationStore();
95
91
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
96
92
  const baseUrl = (opts.baseUrl ?? process.env.OKE_APP_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
@@ -172,7 +168,13 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
172
168
  if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
173
169
  row.consumedAt = now;
174
170
  const email = row.identifier.slice("magic:".length);
175
- const user = ensureUserByEmail(identities, email, now);
171
+ const { user } = await linkOrProvision(identities, {
172
+ provider: "magic-link",
173
+ providerAccountId: email,
174
+ email,
175
+ emailVerified: true,
176
+ now: () => now,
177
+ });
176
178
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
177
179
  id: user.id,
178
180
  plane: "user",
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `mcpOauth` — hand-rolled OAuth 2.1 Authorization Server plugin for MCP.
3
+ *
4
+ * This is the ISSUER track, separate from the `oauth()` RP plugin (which
5
+ * consumes other IdPs for social sign-in). They share identity tables via
6
+ * gate.auth; they do not share token formats or endpoints.
7
+ *
8
+ * Locked decisions implemented here:
9
+ * - CIMD only (client_id is a metadata-document URL; no DCR)
10
+ * - DPoP required on every token grant (`cnf.jkt` on issued tokens)
11
+ * - RFC 8707 `resource` required on every authorize/token request,
12
+ * matched against the canonical resource URI
13
+ * - Exact redirect_uri matching from fetched client metadata
14
+ * - Consent is a JSON Flow contract the app's own UI renders;
15
+ * Console gets read-only audit only
16
+ *
17
+ * The AS crypto/JWKS module is dynamically imported at `.plug()` time so
18
+ * Store-only / non-MCP apps never pay for it (zero-cost when unplugged).
19
+ */
20
+
21
+ import { field } from "../elements/store/schema-decl.ts";
22
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
23
+
24
+ /**
25
+ * Options for {@link mcpOauth}.
26
+ */
27
+ export interface McpOauthOptions {
28
+ /** Issuer origin (e.g. `https://api.example.com`). */
29
+ readonly issuer: string;
30
+ /**
31
+ * Canonical RFC 8707 resource URI clients must request (the MCP RS
32
+ * origin/path). Every authorize/token request must match exactly.
33
+ */
34
+ readonly resource: string;
35
+ /** Refresh-token TTL (default 14 days). */
36
+ readonly refreshTtlMs?: number;
37
+ /** App consent screen path (default `/oauth/consent`). */
38
+ readonly consentPath?: string;
39
+ /** Injectable clock. */
40
+ readonly now?: () => number;
41
+ /** Injectable CIMD document fetch (tests / mock IdP). */
42
+ readonly fetchDoc?: (url: string) => Promise<unknown>;
43
+ }
44
+
45
+ /**
46
+ * Define the Authorization Server plugin.
47
+ *
48
+ * The plugin contributes the six `oke_oauth_*` tables and an edge handler
49
+ * serving: `/.well-known/oauth-authorization-server`,
50
+ * `/.well-known/oauth-protected-resource`, `/oauth/authorize`,
51
+ * `/oauth/token`, `/oauth/jwks`.
52
+ *
53
+ * Consent Flows are created with {@link mcpOauthConsentFlows} — bind them
54
+ * with session gates so the app's own consent screen can drive them over
55
+ * JSON; the framework never ships end-user HTML.
56
+ *
57
+ * @param options - Issuer/resource identity plus injectables
58
+ */
59
+ export function mcpOauth(options: McpOauthOptions): PluginDef {
60
+ // Lazy: a static import would pin ES256/DPoP code on every app bundle.
61
+ const runtimePromise = import("../auth/oauth-as/http.ts").then((mod) =>
62
+ mod.createOauthAs({
63
+ issuer: options.issuer,
64
+ resource: options.resource,
65
+ stores: mod.createAsStores(),
66
+ ...(options.refreshTtlMs !== undefined ? { refreshTtlMs: options.refreshTtlMs } : {}),
67
+ ...(options.now !== undefined ? { now: options.now } : {}),
68
+ ...(options.fetchDoc !== undefined ? { fetchDoc: options.fetchDoc } : {}),
69
+ ...(options.consentPath !== undefined ? { consentPath: options.consentPath } : {}),
70
+ }),
71
+ );
72
+
73
+ const def = plugin("mcpOauth", {
74
+ version: "0.0.1",
75
+ config: { issuer: options.issuer, resource: options.resource },
76
+ })
77
+ .table("oke_oauth_signing_keys", signingKeyColumns(), { plane: "user" })
78
+ .table("oke_oauth_client_cache", cimdColumns(), { plane: "user" })
79
+ .table("oke_oauth_auth_codes", authCodeColumns(), { plane: "user" })
80
+ .table("oke_oauth_access_tokens", accessTokenColumns(), { plane: "user" })
81
+ .table("oke_oauth_refresh_tokens", refreshTokenColumns(), { plane: "user" })
82
+ .table("oke_oauth_consents", consentColumns(), {
83
+ plane: "user",
84
+ description: "OAuth consents — audit source for Console",
85
+ })
86
+ .edge((request, info) => runtimePromise.then((rt) => rt.edge(request, info)));
87
+
88
+ return def;
89
+ }
90
+
91
+ /**
92
+ * Create the three consent JSON Flows (`view` / `approve` / `deny`) against
93
+ * a resolved AS facade. The developer's SPA calls these over HTTP; each
94
+ * returns plain JSON — never HTML.
95
+ *
96
+ * @param facade - Resolved facade from {@link mcpOauth}'s lazy module
97
+ * (`await import("okengine/auth/oauth-as/http").then(m => m.createOauthAs(...))`)
98
+ * @param userId - The signed-in gate.auth user id (session-gated Flow input)
99
+ */
100
+ export function mcpOauthConsentFlows(facade: {
101
+ describeConsent(pendingId: string): {
102
+ clientId: string;
103
+ clientName: string | null;
104
+ scope: readonly string[];
105
+ resource: string;
106
+ };
107
+ approveConsent(pendingId: string, userId: string): Promise<{ redirectTo: string }>;
108
+ denyConsent(pendingId: string, userId: string): { redirectTo: string };
109
+ }) {
110
+ const view = async (input: { pendingId: string }) => facade.describeConsent(input.pendingId);
111
+ const approve = async (input: { pendingId: string; userId: string }) =>
112
+ facade.approveConsent(input.pendingId, input.userId);
113
+ const deny = async (input: { pendingId: string; userId: string }) =>
114
+ facade.denyConsent(input.pendingId, input.userId);
115
+ return { view, approve, deny };
116
+ }
117
+
118
+ /* ------------------------------------------------------------------ */
119
+ /* Column declarations (field.* builders) */
120
+ /* ------------------------------------------------------------------ */
121
+
122
+ /** Column shape of `oke_oauth_signing_keys`. */
123
+ function signingKeyColumns() {
124
+ return {
125
+ kid: field.text().primaryKey(),
126
+ alg: field.text().notNull(),
127
+ public_jwk: field.text().notNull(),
128
+ private_key: field.text().notNull(),
129
+ active: field.integer().notNull(),
130
+ created_at: field.integer().notNull(),
131
+ rotated_at: field.integer(),
132
+ };
133
+ }
134
+
135
+ /** Column shape of `oke_oauth_client_cache`. */
136
+ function cimdColumns() {
137
+ return {
138
+ client_id: field.text().primaryKey(),
139
+ metadata: field.text().notNull(),
140
+ fetched_at: field.integer().notNull(),
141
+ denied_at: field.integer(),
142
+ };
143
+ }
144
+
145
+ /** Column shape of `oke_oauth_auth_codes`. */
146
+ function authCodeColumns() {
147
+ return {
148
+ id: field.text().primaryKey(),
149
+ code_hash: field.text().notNull(),
150
+ user_id: field.text().notNull(),
151
+ client_id: field.text().notNull(),
152
+ redirect_uri: field.text().notNull(),
153
+ resource: field.text().notNull(),
154
+ scope: field.text().notNull(),
155
+ code_challenge: field.text().notNull(),
156
+ code_challenge_method: field.text().notNull(),
157
+ jkt: field.text(),
158
+ expires_at: field.integer().notNull(),
159
+ consumed_at: field.integer(),
160
+ created_at: field.integer().notNull(),
161
+ };
162
+ }
163
+
164
+ /** Column shape of `oke_oauth_access_tokens`. */
165
+ function accessTokenColumns() {
166
+ return {
167
+ jti: field.text().primaryKey(),
168
+ user_id: field.text().notNull(),
169
+ client_id: field.text().notNull(),
170
+ resource: field.text().notNull(),
171
+ scope: field.text().notNull(),
172
+ jkt: field.text(),
173
+ expires_at: field.integer().notNull(),
174
+ revoked_at: field.integer(),
175
+ created_at: field.integer().notNull(),
176
+ };
177
+ }
178
+
179
+ /** Column shape of `oke_oauth_refresh_tokens`. */
180
+ function refreshTokenColumns() {
181
+ return {
182
+ id: field.text().primaryKey(),
183
+ family_id: field.text().notNull(),
184
+ user_id: field.text().notNull(),
185
+ client_id: field.text().notNull(),
186
+ resource: field.text().notNull(),
187
+ scope: field.text().notNull(),
188
+ jkt: field.text(),
189
+ hash: field.text().notNull(),
190
+ expires_at: field.integer().notNull(),
191
+ used_at: field.integer(),
192
+ revoked_at: field.integer(),
193
+ };
194
+ }
195
+
196
+ /** Column shape of `oke_oauth_consents`. */
197
+ function consentColumns() {
198
+ return {
199
+ user_id: field.text().notNull(),
200
+ client_id: field.text().notNull(),
201
+ client_name: field.text(),
202
+ resource: field.text().notNull(),
203
+ scope: field.text().notNull(),
204
+ granted_at: field.integer().notNull(),
205
+ updated_at: field.integer().notNull(),
206
+ revoked_at: field.integer(),
207
+ };
208
+ }