okengine 0.3.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/package.json +14 -12
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/ai/skills.mdx +5 -3
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/ai.mdx +2 -0
- package/site/content/docs/elements/channel.mdx +2 -0
- package/site/content/docs/elements/clock.mdx +1 -4
- package/site/content/docs/elements/flow.mdx +4 -10
- package/site/content/docs/elements/gate.mdx +189 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/elements/signal.mdx +1 -5
- package/site/content/docs/elements/store.mdx +27 -6
- package/site/content/docs/elements/vault.mdx +10 -11
- package/site/content/docs/get-started/basic-usage.mdx +79 -43
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/installation.mdx +95 -43
- package/site/content/docs/get-started/introduction.mdx +128 -75
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +141 -0
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +2 -2
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +128 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +116 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/configuration.mdx +1 -1
- package/site/content/docs/reference/environment-variables.mdx +5 -3
- package/site/content/docs/reference/fx.mdx +36 -8
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +117 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/competitor-mention-removal.test.ts +117 -0
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/dev.ts +20 -0
- package/src/cli/meilisearch-local.test.ts +69 -0
- package/src/cli/meilisearch-local.ts +188 -0
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/server/store.test.ts +1 -1
- package/src/console/server/store.ts +11 -1
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
- package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
- package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
- package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
- package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
- package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
- package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
- package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
- package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
- package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
- package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
- package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
- package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
- package/src/console/ui/dist/index.html +2 -2
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/docker/compose.ts +5 -0
- package/src/docker/docker.test.ts +41 -0
- package/src/docker/recipes/index.ts +10 -2
- package/src/docker/recipes/meilisearch.ts +31 -0
- package/src/drivers/conformance.test.ts +16 -1
- package/src/drivers/conformance.ts +40 -3
- package/src/drivers/index.ts +14 -2
- package/src/drivers/libsql.ts +4 -4
- package/src/drivers/meilisearch.integration.test.ts +77 -0
- package/src/drivers/meilisearch.test.ts +181 -0
- package/src/drivers/meilisearch.ts +208 -0
- package/src/drivers/memory.ts +4 -4
- package/src/drivers/pgvector.ts +6 -6
- package/src/drivers/types.ts +93 -12
- package/src/drivers/vault-driver-removal.test.ts +6 -0
- package/src/drivers/vault-types.ts +4 -4
- package/src/elements/ai/runtime.ts +6 -0
- package/src/elements/ai.test.ts +22 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/elements/store/index-boot.test.ts +49 -7
- package/src/elements/store/runtime.ts +50 -15
- package/src/elements/store.ts +2 -0
- package/src/elements/vault.test.ts +27 -4
- package/src/elements/vault.ts +1 -1
- package/src/index.ts +15 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot-bind/store.test.ts +9 -0
- package/src/kernel/boot-bind/store.ts +30 -2
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/concurrency.test.ts +58 -0
- package/src/kernel/concurrency.ts +48 -0
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +12 -2
- package/src/kernel/fx.ts +97 -5
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +22 -1
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/redacted.ts +74 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/kernel/router.ts +3 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/config-source.test.ts +1 -1
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/index.ts +31 -0
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey.ts +216 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/security-headers.test.ts +14 -14
- package/src/plugins/two-factor.ts +249 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +2 -2
- package/site/content/docs/get-started/comparison.mdx +0 -65
- package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
- package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
- package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
- package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
- package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
- package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
- package/src/drivers/vault-infisical.ts +0 -57
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOTP two-factor Gate auth method plugin (RFC 6238, HMAC-SHA1).
|
|
3
|
+
*
|
|
4
|
+
* v1 verify accepts `{ userId, code }` after password sign-in when 2FA is enabled.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
8
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
9
|
+
import {
|
|
10
|
+
AuthFailed,
|
|
11
|
+
AuthRateLimited,
|
|
12
|
+
SessionTokensOut,
|
|
13
|
+
bindPublicAuth,
|
|
14
|
+
bindSessionAuth,
|
|
15
|
+
createMethodRuntime,
|
|
16
|
+
fail,
|
|
17
|
+
flow,
|
|
18
|
+
z,
|
|
19
|
+
type AuthMethodOptions,
|
|
20
|
+
} from "./auth/shared.ts";
|
|
21
|
+
|
|
22
|
+
/** Per-user TOTP + recovery state. */
|
|
23
|
+
export interface TwoFactorRow {
|
|
24
|
+
userId: string;
|
|
25
|
+
secret: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
/** SHA-256 hex of recovery codes (unused codes only). */
|
|
28
|
+
recoveryHashes: Set<string>;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** In-memory two-factor store. */
|
|
33
|
+
export interface TwoFactorStore {
|
|
34
|
+
readonly byUserId: Map<string, TwoFactorRow>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create an empty two-factor store.
|
|
39
|
+
*/
|
|
40
|
+
export function createTwoFactorStore(): TwoFactorStore {
|
|
41
|
+
return { byUserId: new Map() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Options for {@link twoFactor}. */
|
|
45
|
+
export interface TwoFactorOptions extends AuthMethodOptions {
|
|
46
|
+
readonly factors?: TwoFactorStore;
|
|
47
|
+
/** Issuer label for otpauth URLs (default `oke`). */
|
|
48
|
+
readonly issuer?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Enable / verify / disable TOTP two-factor (`oke_two_factor`).
|
|
53
|
+
*
|
|
54
|
+
* @param opts - Secret / session / factor store
|
|
55
|
+
*/
|
|
56
|
+
export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
|
|
57
|
+
const runtime = createMethodRuntime(opts);
|
|
58
|
+
const factors = opts.factors ?? createTwoFactorStore();
|
|
59
|
+
const issuer = opts.issuer ?? "oke";
|
|
60
|
+
|
|
61
|
+
const enable = flow({
|
|
62
|
+
name: "auth.twoFactorEnable",
|
|
63
|
+
unit: "auth",
|
|
64
|
+
plane: "user",
|
|
65
|
+
out: z.object({
|
|
66
|
+
secret: z.string(),
|
|
67
|
+
otpauthUrl: z.string(),
|
|
68
|
+
recoveryCodes: z.array(z.string()),
|
|
69
|
+
}),
|
|
70
|
+
errors: { AuthFailed },
|
|
71
|
+
do: async (_input, fx) => {
|
|
72
|
+
const userId = fx.auth.userId;
|
|
73
|
+
if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
|
|
74
|
+
const secret = generateBase32Secret(20);
|
|
75
|
+
const recoveryCodes = Array.from({ length: 8 }, () => randomRecoveryCode());
|
|
76
|
+
const recoveryHashes = new Set<string>();
|
|
77
|
+
for (const code of recoveryCodes) {
|
|
78
|
+
recoveryHashes.add(await sha256Hex(code));
|
|
79
|
+
}
|
|
80
|
+
factors.byUserId.set(userId, {
|
|
81
|
+
userId,
|
|
82
|
+
secret,
|
|
83
|
+
enabled: true,
|
|
84
|
+
recoveryHashes,
|
|
85
|
+
createdAt: runtime.now(),
|
|
86
|
+
});
|
|
87
|
+
const label = encodeURIComponent(userId);
|
|
88
|
+
const otpauthUrl = `otpauth://totp/${encodeURIComponent(issuer)}:${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
|
89
|
+
return { secret, otpauthUrl, recoveryCodes };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const verify = flow({
|
|
94
|
+
name: "auth.twoFactorVerify",
|
|
95
|
+
unit: "auth",
|
|
96
|
+
plane: "user",
|
|
97
|
+
in: z.object({
|
|
98
|
+
userId: z.string().min(1),
|
|
99
|
+
code: z.string().min(6).max(16),
|
|
100
|
+
}),
|
|
101
|
+
out: SessionTokensOut,
|
|
102
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
103
|
+
do: async (input) => {
|
|
104
|
+
const row = factors.byUserId.get(input.userId);
|
|
105
|
+
if (!row?.enabled) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
106
|
+
const code = input.code.trim().replace(/\s+/g, "");
|
|
107
|
+
const totpOk = await verifyTotp(row.secret, code);
|
|
108
|
+
if (!totpOk) {
|
|
109
|
+
const hash = await sha256Hex(code);
|
|
110
|
+
if (!row.recoveryHashes.has(hash)) {
|
|
111
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
112
|
+
}
|
|
113
|
+
row.recoveryHashes.delete(hash);
|
|
114
|
+
}
|
|
115
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
116
|
+
id: input.userId,
|
|
117
|
+
plane: "user",
|
|
118
|
+
scopes: [],
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
accessToken: issued.accessToken,
|
|
122
|
+
refreshToken: issued.refreshToken,
|
|
123
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
124
|
+
userId: input.userId,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const disable = flow({
|
|
130
|
+
name: "auth.twoFactorDisable",
|
|
131
|
+
unit: "auth",
|
|
132
|
+
plane: "user",
|
|
133
|
+
out: z.object({ ok: z.literal(true) }),
|
|
134
|
+
errors: { AuthFailed },
|
|
135
|
+
do: (_input, fx) => {
|
|
136
|
+
const userId = fx.auth.userId;
|
|
137
|
+
if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
|
|
138
|
+
factors.byUserId.delete(userId);
|
|
139
|
+
return { ok: true as const };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return plugin("twoFactor", { version: "0.0.1", config: { method: "two-factor" } })
|
|
144
|
+
.needs("auth")
|
|
145
|
+
.table("oke_two_factor", undefined, { plane: "user", description: "TOTP secrets + recovery" })
|
|
146
|
+
.binding(bindSessionAuth("/two-factor/enable", enable))
|
|
147
|
+
.binding(bindPublicAuth("/two-factor/verify", verify, "otp"))
|
|
148
|
+
.binding(bindSessionAuth("/two-factor/disable", disable));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verify a 6-digit TOTP against a base32 secret (RFC 6238, 30s step, ±1 window).
|
|
153
|
+
*
|
|
154
|
+
* @param secretBase32 - Shared secret
|
|
155
|
+
* @param code - Submitted code
|
|
156
|
+
* @param nowSec - Epoch seconds (test injection)
|
|
157
|
+
*/
|
|
158
|
+
export async function verifyTotp(
|
|
159
|
+
secretBase32: string,
|
|
160
|
+
code: string,
|
|
161
|
+
nowSec: number = Math.floor(Date.now() / 1000),
|
|
162
|
+
): Promise<boolean> {
|
|
163
|
+
if (!/^\d{6}$/.test(code)) return false;
|
|
164
|
+
const key = base32Decode(secretBase32);
|
|
165
|
+
const counter = Math.floor(nowSec / 30);
|
|
166
|
+
for (let w = -1; w <= 1; w++) {
|
|
167
|
+
const otp = await hotp(key, counter + w);
|
|
168
|
+
if (otp === code) return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function hotp(key: Uint8Array, counter: number): Promise<string> {
|
|
174
|
+
const msg = new Uint8Array(8);
|
|
175
|
+
let c = counter;
|
|
176
|
+
for (let i = 7; i >= 0; i--) {
|
|
177
|
+
msg[i] = c & 0xff;
|
|
178
|
+
c = Math.floor(c / 256);
|
|
179
|
+
}
|
|
180
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
181
|
+
"raw",
|
|
182
|
+
key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,
|
|
183
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
184
|
+
false,
|
|
185
|
+
["sign"],
|
|
186
|
+
);
|
|
187
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg));
|
|
188
|
+
const offset = sig[sig.length - 1]! & 0x0f;
|
|
189
|
+
const bin =
|
|
190
|
+
((sig[offset]! & 0x7f) << 24) |
|
|
191
|
+
((sig[offset + 1]! & 0xff) << 16) |
|
|
192
|
+
((sig[offset + 2]! & 0xff) << 8) |
|
|
193
|
+
(sig[offset + 3]! & 0xff);
|
|
194
|
+
return (bin % 1_000_000).toString().padStart(6, "0");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function generateBase32Secret(byteLen: number): string {
|
|
198
|
+
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
|
|
199
|
+
return base32Encode(bytes);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function randomRecoveryCode(): string {
|
|
203
|
+
const bytes = crypto.getRandomValues(new Uint8Array(5));
|
|
204
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function sha256Hex(raw: string): Promise<string> {
|
|
208
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw));
|
|
209
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
213
|
+
|
|
214
|
+
function base32Encode(bytes: Uint8Array): string {
|
|
215
|
+
let bits = 0;
|
|
216
|
+
let value = 0;
|
|
217
|
+
let out = "";
|
|
218
|
+
for (const b of bytes) {
|
|
219
|
+
value = (value << 8) | b;
|
|
220
|
+
bits += 8;
|
|
221
|
+
while (bits >= 5) {
|
|
222
|
+
out += B32[(value >>> (bits - 5)) & 31];
|
|
223
|
+
bits -= 5;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function base32Decode(input: string): Uint8Array {
|
|
231
|
+
const cleaned = input
|
|
232
|
+
.replace(/=+$/, "")
|
|
233
|
+
.toUpperCase()
|
|
234
|
+
.replace(/[^A-Z2-7]/g, "");
|
|
235
|
+
let bits = 0;
|
|
236
|
+
let value = 0;
|
|
237
|
+
const out: number[] = [];
|
|
238
|
+
for (const ch of cleaned) {
|
|
239
|
+
const idx = B32.indexOf(ch);
|
|
240
|
+
if (idx < 0) continue;
|
|
241
|
+
value = (value << 5) | idx;
|
|
242
|
+
bits += 5;
|
|
243
|
+
if (bits >= 8) {
|
|
244
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
245
|
+
bits -= 8;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return new Uint8Array(out);
|
|
249
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Username + password Gate auth method plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createBunCrypto } from "../runtime/primitives.ts";
|
|
6
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
7
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
8
|
+
import {
|
|
9
|
+
AuthFailed,
|
|
10
|
+
AuthRateLimited,
|
|
11
|
+
SessionTokensOut,
|
|
12
|
+
bindPublicAuth,
|
|
13
|
+
createMethodRuntime,
|
|
14
|
+
fail,
|
|
15
|
+
flow,
|
|
16
|
+
z,
|
|
17
|
+
type AuthMethodOptions,
|
|
18
|
+
} from "./auth/shared.ts";
|
|
19
|
+
|
|
20
|
+
/** In-memory username credential row. */
|
|
21
|
+
export interface UsernameRow {
|
|
22
|
+
readonly userId: string;
|
|
23
|
+
readonly username: string;
|
|
24
|
+
passwordHash: string;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Username → credential map. */
|
|
29
|
+
export interface UsernameStore {
|
|
30
|
+
readonly byUsername: Map<string, UsernameRow>;
|
|
31
|
+
readonly byUserId: Map<string, UsernameRow>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create an empty username store.
|
|
36
|
+
*/
|
|
37
|
+
export function createUsernameStore(): UsernameStore {
|
|
38
|
+
return { byUsername: new Map(), byUserId: new Map() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link username}. */
|
|
42
|
+
export interface UsernamePluginOptions extends AuthMethodOptions {
|
|
43
|
+
/** Shared username credential store. */
|
|
44
|
+
readonly usernames?: UsernameStore;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const UsernameIn = z.object({
|
|
48
|
+
username: z.string().min(3).max(64),
|
|
49
|
+
password: z.string().min(1),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function normalizeUsername(raw: string): string {
|
|
53
|
+
return raw.trim().toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Username + password sign-up / sign-in (`oke_usernames`).
|
|
58
|
+
*
|
|
59
|
+
* @param opts - Secret / session / store overrides
|
|
60
|
+
*/
|
|
61
|
+
export function username(opts: UsernamePluginOptions = {}): PluginDef {
|
|
62
|
+
const runtime = createMethodRuntime(opts);
|
|
63
|
+
const usernames = opts.usernames ?? createUsernameStore();
|
|
64
|
+
const crypto = createBunCrypto();
|
|
65
|
+
|
|
66
|
+
const signUp = flow({
|
|
67
|
+
name: "auth.signUpUsername",
|
|
68
|
+
unit: "auth",
|
|
69
|
+
plane: "user",
|
|
70
|
+
in: UsernameIn,
|
|
71
|
+
out: SessionTokensOut,
|
|
72
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
73
|
+
do: async (input) => {
|
|
74
|
+
const key = normalizeUsername(input.username);
|
|
75
|
+
if (!/^[a-z0-9._-]{3,64}$/.test(key)) {
|
|
76
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
77
|
+
}
|
|
78
|
+
if (usernames.byUsername.has(key)) {
|
|
79
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
80
|
+
}
|
|
81
|
+
const userId = crypto.randomUUID();
|
|
82
|
+
const passwordHash = await crypto.hashPassword(input.password, {
|
|
83
|
+
algorithm: "argon2id",
|
|
84
|
+
});
|
|
85
|
+
const row: UsernameRow = {
|
|
86
|
+
userId,
|
|
87
|
+
username: key,
|
|
88
|
+
passwordHash,
|
|
89
|
+
createdAt: runtime.now(),
|
|
90
|
+
};
|
|
91
|
+
usernames.byUsername.set(key, row);
|
|
92
|
+
usernames.byUserId.set(userId, row);
|
|
93
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
94
|
+
id: userId,
|
|
95
|
+
plane: "user",
|
|
96
|
+
scopes: [],
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
accessToken: issued.accessToken,
|
|
100
|
+
refreshToken: issued.refreshToken,
|
|
101
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
102
|
+
userId,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const signIn = flow({
|
|
108
|
+
name: "auth.signInUsername",
|
|
109
|
+
unit: "auth",
|
|
110
|
+
plane: "user",
|
|
111
|
+
in: UsernameIn,
|
|
112
|
+
out: SessionTokensOut,
|
|
113
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
114
|
+
do: async (input) => {
|
|
115
|
+
const key = normalizeUsername(input.username);
|
|
116
|
+
const row = usernames.byUsername.get(key);
|
|
117
|
+
const hash = row?.passwordHash ?? (await dummyHash(crypto));
|
|
118
|
+
const ok = await crypto.verifyPassword(input.password, hash);
|
|
119
|
+
if (!ok || !row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
120
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
121
|
+
id: row.userId,
|
|
122
|
+
plane: "user",
|
|
123
|
+
scopes: [],
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
accessToken: issued.accessToken,
|
|
127
|
+
refreshToken: issued.refreshToken,
|
|
128
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
129
|
+
userId: row.userId,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return plugin("username", { version: "0.0.1", config: { method: "username" } })
|
|
135
|
+
.needs("auth")
|
|
136
|
+
.table("oke_usernames", undefined, { plane: "user", description: "Username credentials" })
|
|
137
|
+
.binding(bindPublicAuth("/sign-up/username", signUp, "signUp"))
|
|
138
|
+
.binding(bindPublicAuth("/sign-in/username", signIn, "signIn"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let dummyPasswordHash: string | null = null;
|
|
142
|
+
|
|
143
|
+
async function dummyHash(crypto: ReturnType<typeof createBunCrypto>): Promise<string> {
|
|
144
|
+
if (dummyPasswordHash === null) {
|
|
145
|
+
dummyPasswordHash = await crypto.hashPassword("oke-username-timing-dummy", "argon2id");
|
|
146
|
+
}
|
|
147
|
+
return dummyPasswordHash;
|
|
148
|
+
}
|
package/src/runs/runs.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
|
|
16
16
|
import { fsDriver, memoryFilesDriver } from "../drivers/index.ts";
|
|
17
|
+
import { gate } from "../elements/gate.ts";
|
|
17
18
|
import { oke } from "../kernel/app.ts";
|
|
18
19
|
import { flow } from "../kernel/flow.ts";
|
|
19
20
|
import { on, resetBindings } from "../kernel/on.ts";
|
|
@@ -54,8 +55,10 @@ describe("zero-instrumentation dimensions", () => {
|
|
|
54
55
|
});
|
|
55
56
|
await runs.open();
|
|
56
57
|
|
|
58
|
+
const member = gate.policy("member", ({ auth }) => !!auth.verified);
|
|
59
|
+
|
|
57
60
|
on(
|
|
58
|
-
http.get("/book").gate(
|
|
61
|
+
http.get("/book").gate(member),
|
|
59
62
|
flow({
|
|
60
63
|
name: "book.create",
|
|
61
64
|
unit: "bookings",
|
|
@@ -74,8 +77,9 @@ describe("zero-instrumentation dimensions", () => {
|
|
|
74
77
|
const app = oke({
|
|
75
78
|
name: "runs-dims",
|
|
76
79
|
runs,
|
|
80
|
+
gate: { policies: [member] },
|
|
77
81
|
fx: {
|
|
78
|
-
auth: { userId: "user_42", scopes: new Set(["book:write"]) },
|
|
82
|
+
auth: { userId: "user_42", scopes: new Set(["book:write"]), verified: true },
|
|
79
83
|
tenant: { id: "org_a41" },
|
|
80
84
|
},
|
|
81
85
|
});
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
PasswordAlgorithm,
|
|
9
|
+
PasswordHashOptions,
|
|
9
10
|
RuntimeCrypto,
|
|
10
11
|
RuntimeEnv,
|
|
11
12
|
RuntimeFiles,
|
|
12
13
|
RuntimeTimers,
|
|
13
14
|
} from "./types.ts";
|
|
15
|
+
import { ARGON2ID_MEMORY_COST_FLOOR, ARGON2ID_TIME_COST_FLOOR } from "./types.ts";
|
|
14
16
|
|
|
15
17
|
/** HTML / WinterTC timers on `globalThis`. */
|
|
16
18
|
export function createTimers(): RuntimeTimers {
|
|
@@ -124,7 +126,11 @@ export function createWebCrypto(): RuntimeCrypto {
|
|
|
124
126
|
getRandomValues(array) {
|
|
125
127
|
return web.getRandomValues(array);
|
|
126
128
|
},
|
|
127
|
-
async hashPassword(password,
|
|
129
|
+
async hashPassword(password, algorithmOrOptions = "pbkdf2") {
|
|
130
|
+
const algorithm =
|
|
131
|
+
typeof algorithmOrOptions === "string"
|
|
132
|
+
? algorithmOrOptions
|
|
133
|
+
: (algorithmOrOptions.algorithm ?? "pbkdf2");
|
|
128
134
|
if (algorithm !== "pbkdf2") {
|
|
129
135
|
throw new Error(
|
|
130
136
|
`web-standard runtime supports only pbkdf2 passwords (got ${algorithm}); use the Bun adapter for argon2id/bcrypt`,
|
|
@@ -156,10 +162,37 @@ export function createBunCrypto(): RuntimeCrypto {
|
|
|
156
162
|
getRandomValues(array) {
|
|
157
163
|
return web.getRandomValues(array);
|
|
158
164
|
},
|
|
159
|
-
async hashPassword(password,
|
|
165
|
+
async hashPassword(password, algorithmOrOptions = "argon2id") {
|
|
166
|
+
const opts: PasswordHashOptions =
|
|
167
|
+
typeof algorithmOrOptions === "string"
|
|
168
|
+
? { algorithm: algorithmOrOptions }
|
|
169
|
+
: algorithmOrOptions;
|
|
170
|
+
const algorithm: PasswordAlgorithm = opts.algorithm ?? "argon2id";
|
|
160
171
|
if (algorithm === "pbkdf2") return hashPbkdf2(password);
|
|
161
|
-
|
|
162
|
-
|
|
172
|
+
if (algorithm === "bcrypt") {
|
|
173
|
+
if (opts.cost !== undefined) {
|
|
174
|
+
return Bun.password.hash(password, { algorithm: "bcrypt", cost: opts.cost });
|
|
175
|
+
}
|
|
176
|
+
return Bun.password.hash(password, "bcrypt");
|
|
177
|
+
}
|
|
178
|
+
// argon2id — never weaker than Bun's defaults (m=65536, t=2).
|
|
179
|
+
const memoryCost = opts.memoryCost ?? ARGON2ID_MEMORY_COST_FLOOR;
|
|
180
|
+
const timeCost = opts.timeCost ?? ARGON2ID_TIME_COST_FLOOR;
|
|
181
|
+
if (memoryCost < ARGON2ID_MEMORY_COST_FLOOR) {
|
|
182
|
+
throw new TypeError(
|
|
183
|
+
`argon2id memoryCost must be >= ${ARGON2ID_MEMORY_COST_FLOOR} (Bun default floor)`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (timeCost < ARGON2ID_TIME_COST_FLOOR) {
|
|
187
|
+
throw new TypeError(
|
|
188
|
+
`argon2id timeCost must be >= ${ARGON2ID_TIME_COST_FLOOR} (Bun default floor)`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return Bun.password.hash(password, {
|
|
192
|
+
algorithm: "argon2id",
|
|
193
|
+
memoryCost,
|
|
194
|
+
timeCost,
|
|
195
|
+
});
|
|
163
196
|
},
|
|
164
197
|
async verifyPassword(password, hash) {
|
|
165
198
|
if (hash.startsWith("pbkdf2$")) return verifyPbkdf2(password, hash);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { gate } from "../elements/gate.ts";
|
|
2
3
|
import { oke } from "../kernel/app.ts";
|
|
3
4
|
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
4
5
|
import { on, resetBindings } from "../kernel/on.ts";
|
|
@@ -55,14 +56,14 @@ function rawHttp(port: number, payload: string): Promise<string> {
|
|
|
55
56
|
|
|
56
57
|
function buildApp() {
|
|
57
58
|
on(
|
|
58
|
-
http.get("/ping"),
|
|
59
|
+
http.get("/ping").gate(gate.public),
|
|
59
60
|
flow({
|
|
60
61
|
name: "ping",
|
|
61
62
|
do: () => ({ ok: true as const, n: 1 }),
|
|
62
63
|
}),
|
|
63
64
|
);
|
|
64
65
|
on(
|
|
65
|
-
http.get("/notes/:id"),
|
|
66
|
+
http.get("/notes/:id").gate(gate.public),
|
|
66
67
|
flow({
|
|
67
68
|
name: "notes.get",
|
|
68
69
|
do: ({ id }: { id: string }) => ({ id }),
|
package/src/runtime/types.ts
CHANGED
|
@@ -130,6 +130,26 @@ export interface RuntimeTimers {
|
|
|
130
130
|
/** Password algorithm accepted by {@link RuntimeCrypto.hashPassword}. */
|
|
131
131
|
export type PasswordAlgorithm = "argon2id" | "bcrypt" | "pbkdf2";
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Bun.password cost knobs. Defaults match Bun's argon2id floor
|
|
135
|
+
* (`memoryCost: 65536`, `timeCost: 2`) — never weaker.
|
|
136
|
+
*/
|
|
137
|
+
export interface PasswordHashOptions {
|
|
138
|
+
/** Algorithm (Bun default `argon2id`). */
|
|
139
|
+
readonly algorithm?: PasswordAlgorithm;
|
|
140
|
+
/** Argon2 memory cost in KiB (floor 65536). */
|
|
141
|
+
readonly memoryCost?: number;
|
|
142
|
+
/** Argon2 time cost / iterations (floor 2). */
|
|
143
|
+
readonly timeCost?: number;
|
|
144
|
+
/** bcrypt cost (only when `algorithm: "bcrypt"`). */
|
|
145
|
+
readonly cost?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Bun argon2id defaults — also the configuration floor. */
|
|
149
|
+
export const ARGON2ID_MEMORY_COST_FLOOR = 65_536;
|
|
150
|
+
/** Bun argon2id timeCost floor. */
|
|
151
|
+
export const ARGON2ID_TIME_COST_FLOOR = 2;
|
|
152
|
+
|
|
133
153
|
/**
|
|
134
154
|
* Crypto surface — Web Crypto plus password hash/verify.
|
|
135
155
|
* Bun binds `Bun.password`; web-standard uses PBKDF2 via SubtleCrypto.
|
|
@@ -149,9 +169,12 @@ export interface RuntimeCrypto {
|
|
|
149
169
|
* Hash a password.
|
|
150
170
|
*
|
|
151
171
|
* @param password - Plaintext
|
|
152
|
-
* @param
|
|
172
|
+
* @param algorithmOrOptions - Algorithm string or Bun cost options
|
|
153
173
|
*/
|
|
154
|
-
hashPassword(
|
|
174
|
+
hashPassword(
|
|
175
|
+
password: string,
|
|
176
|
+
algorithmOrOptions?: PasswordAlgorithm | PasswordHashOptions,
|
|
177
|
+
): Promise<string>;
|
|
155
178
|
/**
|
|
156
179
|
* Verify a password against a hash from {@link RuntimeCrypto.hashPassword}.
|
|
157
180
|
*
|
|
@@ -77,7 +77,7 @@ describe("createTestApp — four-applications surface", () => {
|
|
|
77
77
|
|
|
78
78
|
const app = oke({
|
|
79
79
|
name: "harness",
|
|
80
|
-
|
|
80
|
+
gate: { policies: [member] },
|
|
81
81
|
signals: [orderPlaced],
|
|
82
82
|
clocks: [clock("expire-stale", { every: "1h" })],
|
|
83
83
|
channel: { templates: [orderConfirmed] },
|
|
@@ -215,8 +215,11 @@ export async function createTestApp<App extends OkeApp>(
|
|
|
215
215
|
await app.boot({
|
|
216
216
|
...(options.boot ?? {}),
|
|
217
217
|
env: "test",
|
|
218
|
+
// Test-only opt-out (honoured because env is "test" below). Production
|
|
219
|
+
// boots never skip posture via this flag.
|
|
220
|
+
unguardedHttp: options.boot?.unguardedHttp ?? appOpts.gate?.unguardedHttp ?? "allow",
|
|
218
221
|
startScheduler: options.startScheduler ?? options.boot?.startScheduler ?? false,
|
|
219
|
-
gates: options.gates ?? options.boot?.gates ?? appOpts.
|
|
222
|
+
gates: options.gates ?? options.boot?.gates ?? appOpts.gate?.policies,
|
|
220
223
|
secrets: options.secrets ?? options.boot?.secrets ?? appOpts.secrets,
|
|
221
224
|
signals: options.signals ?? options.boot?.signals ?? appOpts.signals,
|
|
222
225
|
stores: options.boot?.stores ?? appOpts.stores,
|
|
@@ -56,7 +56,7 @@ describe("Provisions integration", () => {
|
|
|
56
56
|
effects: { secrets: ["STRIPE_KEY"], emits: ["order-news"] },
|
|
57
57
|
do: async ({ orderId }, fx) => {
|
|
58
58
|
const key = fx.vault(stripeKey);
|
|
59
|
-
expect(key.startsWith("sk_")).toBe(true);
|
|
59
|
+
expect(key.reveal().startsWith("sk_")).toBe(true);
|
|
60
60
|
|
|
61
61
|
await fx.step("create-intent", async () => ({ id: `pi_${orderId}` }));
|
|
62
62
|
await fx.clock.sleep("verify-window", "2m");
|
|
@@ -111,7 +111,7 @@ describe("Provisions integration", () => {
|
|
|
111
111
|
|
|
112
112
|
const app = oke({
|
|
113
113
|
name: "provisions",
|
|
114
|
-
|
|
114
|
+
gate: { policies: [member, canOrder] },
|
|
115
115
|
secrets: [stripeKey],
|
|
116
116
|
signals: [orderPlaced, orderNews],
|
|
117
117
|
channel: { templates: [orderConfirmed] },
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Comparison
|
|
3
|
-
description: How OKE compares to Hono, Elysia, Encore.ts, and iii — and when to pick it.
|
|
4
|
-
source: docs/spec/unified-theory.md
|
|
5
|
-
icon: Scale
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
> **OKE is the batteries-included TypeScript backend for the Bun era:** contract-first APIs with end-to-end type safety, declarative infrastructure primitives, an OpenTelemetry-native Console, secure-by-default auth and ABAC — pure TypeScript, Web-Standards portable, MIT-licensed, self-hostable with zero cloud lock-in.
|
|
9
|
-
|
|
10
|
-
_"Encore's batteries and dashboard, Elysia's speed and DX, Hono's portability — without the Rust lock-in, the cloud gravity, or the source-available license."_
|
|
11
|
-
|
|
12
|
-
## When to pick OKE
|
|
13
|
-
|
|
14
|
-
Choose OKE if you want:
|
|
15
|
-
|
|
16
|
-
- **One mental model** for HTTP, jobs, consumers, and durable work (`on(Trigger) → Effects`)
|
|
17
|
-
- **Batteries included** — store, signals, clock, gates, vault, channels, and AI as first-class elements
|
|
18
|
-
- **A local Console** derived from your code (not a separate SaaS product you must adopt)
|
|
19
|
-
- **MIT + self-host** with drivers named after protocols (`postgres`, `redis`, `s3`), not vendors
|
|
20
|
-
- Effects that power cache invalidation, live queries, and least privilege **without hand annotations**
|
|
21
|
-
|
|
22
|
-
Prefer a thinner router (Hono / Elysia alone) if you only need HTTP and will assemble queues, cron, and auth yourself.
|
|
23
|
-
|
|
24
|
-
## Matrix
|
|
25
|
-
|
|
26
|
-
| | Hono | Elysia | Encore.ts | iii | **OKE** |
|
|
27
|
-
| ----------------------------------------------- | --------------- | ----------- | ------------------- | --------------------- | --------------------------------- |
|
|
28
|
-
| Primary runtime | Multi (Web Std) | Bun-first | Node + Rust core | Rust engine, polyglot | **Bun-first, Web-Std portable** |
|
|
29
|
-
| License | MIT | MIT | MPL-2.0 | ELv2 engine | **MIT** |
|
|
30
|
-
| Typed client | hc RPC | Eden Treaty | generated | SDK | **contract-first + live queries** |
|
|
31
|
-
| DB · queue · cron · storage | ❌ | ❌ | ✅ | ✅ | **✅ (7 elements)** |
|
|
32
|
-
| Durable workflows | ❌ | ❌ | ❌ | partial | **✅ (a flag)** |
|
|
33
|
-
| Local Console | ❌ | ❌ | ✅ | ✅ | **✅ (dev + prod)** |
|
|
34
|
-
| Auto cache invalidation | ❌ | ❌ | ❌ | ❌ | **✅ (from effects)** |
|
|
35
|
-
| Least-privilege by compiler | ❌ | ❌ | ❌ | ❌ | **✅** |
|
|
36
|
-
| Human channels (email/SMS/WA) | ❌ | ❌ | ❌ | ❌ | **✅ (7th element)** |
|
|
37
|
-
| AI in the application (models, prompts, agents) | ❌ | ❌ | ❌ | ❌ | **✅ (8th element)** |
|
|
38
|
-
| Self-host, no lock-in | ✅ | ✅ | ✅ (Cloud optional) | ⚠️ | **✅ first-class** |
|
|
39
|
-
|
|
40
|
-
## vs Collections of libraries
|
|
41
|
-
|
|
42
|
-
Frameworks that ship a router plus a queue library plus a cron library plus websockets share one flaw: the concepts do not derive from one another. Documentation sprawls; each new fashion adds a concept that never gets removed.
|
|
43
|
-
|
|
44
|
-
OKE starts from one law so docs, traces, hooks, and agent surfaces stay **one shape**.
|
|
45
|
-
|
|
46
|
-
## vs Cloud-gravity platforms
|
|
47
|
-
|
|
48
|
-
OKE is MIT, self-hostable, and names drivers after **protocols** — not vendors. Vendor choice lives in images keyed by role. There is no separate “deploy to our cloud” product you must adopt to get the Console.
|
|
49
|
-
|
|
50
|
-
## vs Rolling your own effect system
|
|
51
|
-
|
|
52
|
-
Effects are inferred from what a Flow touches through `fx`. Cache invalidation, live queries, least privilege, deterministic tests, and Manifest Diff fall out of that one decision — without hand-written annotations for each capability.
|
|
53
|
-
|
|
54
|
-
<Cards>
|
|
55
|
-
<Card
|
|
56
|
-
title="Installation"
|
|
57
|
-
description="Install and scaffold in minutes."
|
|
58
|
-
href="/docs/get-started/installation"
|
|
59
|
-
/>
|
|
60
|
-
<Card
|
|
61
|
-
title="Introduction"
|
|
62
|
-
description="The one law, eight elements, ten exports."
|
|
63
|
-
href="/docs/get-started/introduction"
|
|
64
|
-
/>
|
|
65
|
-
</Cards>
|