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.
- package/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
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
|
+
}
|
package/src/plugins/anonymous.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/src/plugins/index.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
+
}
|