okengine 0.4.3 → 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/package.json +3 -1
- 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/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/gate.mdx +187 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +4 -3
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/meta.json +1 -1
- 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} +1 -1
- package/site/content/docs/plugins/index.mdx +69 -0
- 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/fx.mdx +20 -5
- 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/dev.test.ts +3 -3
- 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/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/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/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/index.ts +11 -0
- package/src/kernel/app.ts +253 -32
- 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/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +60 -0
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +12 -0
- 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/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -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 +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional secondary storage for hot auth data (Phase 1a) — `store.kv`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ResolvedGateAuth } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
/** Minimal kv surface used by auth secondary storage. */
|
|
8
|
+
export interface AuthKv {
|
|
9
|
+
get(key: string): Promise<unknown>;
|
|
10
|
+
set(key: string, value: unknown, opts?: { readonly ttlMs?: number }): Promise<void>;
|
|
11
|
+
delete(key: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a namespaced kv helper for session / challenge hot paths.
|
|
16
|
+
*
|
|
17
|
+
* @param kv - Kv handle
|
|
18
|
+
* @param config - Resolved auth
|
|
19
|
+
*/
|
|
20
|
+
export function createAuthSecondaryStorage(
|
|
21
|
+
kv: AuthKv,
|
|
22
|
+
config: ResolvedGateAuth,
|
|
23
|
+
): AuthKv | undefined {
|
|
24
|
+
if (!config.secondaryStorage.enabled) return undefined;
|
|
25
|
+
const prefix = config.secondaryStorage.prefix;
|
|
26
|
+
return {
|
|
27
|
+
async get(key) {
|
|
28
|
+
return kv.get(`${prefix}${key}`);
|
|
29
|
+
},
|
|
30
|
+
async set(key, value, opts) {
|
|
31
|
+
await kv.set(`${prefix}${key}`, value, opts);
|
|
32
|
+
},
|
|
33
|
+
async delete(key) {
|
|
34
|
+
await kv.delete(`${prefix}${key}`);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/auth/sessions.ts
CHANGED
|
@@ -52,6 +52,21 @@ export interface SessionCrypto {
|
|
|
52
52
|
readonly now?: () => number;
|
|
53
53
|
readonly accessTtlMs?: number;
|
|
54
54
|
readonly refreshTtlMs?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Idle timeout — reject refresh when `now - lastActiveAt > idleTtlMs`.
|
|
57
|
+
* Opt-in (undefined = disabled).
|
|
58
|
+
*/
|
|
59
|
+
readonly idleTtlMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Absolute lifetime from `session.createdAt` (hard stop even if refresh would extend).
|
|
62
|
+
* Opt-in; when set should be ≤ refreshTtlMs.
|
|
63
|
+
*/
|
|
64
|
+
readonly absoluteTtlMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* When true, issuing a session revokes other active families for the same principal.
|
|
67
|
+
* Opt-in (default false).
|
|
68
|
+
*/
|
|
69
|
+
readonly singleSessionPerUser?: boolean;
|
|
55
70
|
/**
|
|
56
71
|
* Audience stamped on issued access tokens.
|
|
57
72
|
* MCP tokens must use `"oke-mcp"` (console §10.3 — never accept another aud).
|
|
@@ -97,9 +112,15 @@ export async function issueSession(
|
|
|
97
112
|
const t = now();
|
|
98
113
|
const accessTtl = crypto.accessTtlMs ?? ACCESS_TTL_MS;
|
|
99
114
|
const refreshTtl = crypto.refreshTtlMs ?? REFRESH_TTL_MS;
|
|
115
|
+
const absoluteCap =
|
|
116
|
+
crypto.absoluteTtlMs !== undefined ? Math.min(refreshTtl, crypto.absoluteTtlMs) : refreshTtl;
|
|
100
117
|
const sessionId = cryptoRandomId();
|
|
101
118
|
const familyId = cryptoRandomId();
|
|
102
119
|
|
|
120
|
+
if (crypto.singleSessionPerUser) {
|
|
121
|
+
revokePrincipalSessions(store, principal.plane, principal.id, t);
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
const session: SessionRow = {
|
|
104
125
|
id: sessionId,
|
|
105
126
|
plane: principal.plane,
|
|
@@ -107,7 +128,8 @@ export async function issueSession(
|
|
|
107
128
|
familyId,
|
|
108
129
|
revokedAt: null,
|
|
109
130
|
createdAt: t,
|
|
110
|
-
expiresAt: t +
|
|
131
|
+
expiresAt: t + absoluteCap,
|
|
132
|
+
lastActiveAt: t,
|
|
111
133
|
};
|
|
112
134
|
store.sessions.set(sessionId, session);
|
|
113
135
|
if (crypto.audience !== undefined) {
|
|
@@ -174,9 +196,17 @@ export async function rotateRefresh(
|
|
|
174
196
|
if (existing.expiresAt <= t || session.expiresAt <= t) {
|
|
175
197
|
throw new SessionError("refresh token expired");
|
|
176
198
|
}
|
|
199
|
+
if (crypto.absoluteTtlMs !== undefined && session.createdAt + crypto.absoluteTtlMs <= t) {
|
|
200
|
+
throw new SessionError("session absolute lifetime exceeded");
|
|
201
|
+
}
|
|
202
|
+
const lastActive = session.lastActiveAt ?? session.createdAt;
|
|
203
|
+
if (crypto.idleTtlMs !== undefined && lastActive + crypto.idleTtlMs <= t) {
|
|
204
|
+
throw new SessionError("session idle timeout exceeded");
|
|
205
|
+
}
|
|
177
206
|
|
|
178
207
|
existing.usedAt = t;
|
|
179
208
|
existing.revokedAt = t;
|
|
209
|
+
session.lastActiveAt = t;
|
|
180
210
|
|
|
181
211
|
const accessTtl = crypto.accessTtlMs ?? ACCESS_TTL_MS;
|
|
182
212
|
const refreshTtl = crypto.refreshTtlMs ?? REFRESH_TTL_MS;
|
|
@@ -305,6 +335,8 @@ export async function verifyAccess(
|
|
|
305
335
|
if (session.principalId !== claims.sub) {
|
|
306
336
|
throw new SessionError("session does not belong to requester");
|
|
307
337
|
}
|
|
338
|
+
// Touch idle clock on successful access verify.
|
|
339
|
+
session.lastActiveAt = now();
|
|
308
340
|
return claims;
|
|
309
341
|
}
|
|
310
342
|
|
|
@@ -324,6 +356,31 @@ export function revokeFamily(store: SessionStore, familyId: string, at: number =
|
|
|
324
356
|
}
|
|
325
357
|
}
|
|
326
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Revoke all active sessions for a principal (single-session-per-user).
|
|
361
|
+
*
|
|
362
|
+
* @param store - Session store
|
|
363
|
+
* @param plane - Auth plane
|
|
364
|
+
* @param principalId - Subject id
|
|
365
|
+
* @param at - Timestamp
|
|
366
|
+
*/
|
|
367
|
+
export function revokePrincipalSessions(
|
|
368
|
+
store: SessionStore,
|
|
369
|
+
plane: AuthPlane,
|
|
370
|
+
principalId: string,
|
|
371
|
+
at: number = Date.now(),
|
|
372
|
+
): void {
|
|
373
|
+
for (const session of store.sessions.values()) {
|
|
374
|
+
if (
|
|
375
|
+
session.plane === plane &&
|
|
376
|
+
session.principalId === principalId &&
|
|
377
|
+
session.revokedAt === null
|
|
378
|
+
) {
|
|
379
|
+
revokeFamily(store, session.familyId, at);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
327
384
|
/** Session / token error. */
|
|
328
385
|
export class SessionError extends Error {
|
|
329
386
|
/** @param message - Diagnostic */
|
package/src/auth/tables.ts
CHANGED
|
@@ -23,6 +23,8 @@ export const AUTH_TABLES = {
|
|
|
23
23
|
// Session tables (auth element owns these)
|
|
24
24
|
sessions: "oke_sessions",
|
|
25
25
|
refreshTokens: "oke_refresh_tokens",
|
|
26
|
+
// Challenges / OTP / magic-link (Gate auth + method plugins)
|
|
27
|
+
verifications: "oke_verifications",
|
|
26
28
|
} as const;
|
|
27
29
|
|
|
28
30
|
/** Plane column values stored on shared tables. */
|
|
@@ -117,6 +119,8 @@ export interface SessionRow {
|
|
|
117
119
|
revokedAt: number | null;
|
|
118
120
|
createdAt: number;
|
|
119
121
|
expiresAt: number;
|
|
122
|
+
/** Last activity epoch-ms (idle timeout). Updated on refresh / access touch. */
|
|
123
|
+
lastActiveAt: number;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** Refresh token (hashed at rest); rotation with reuse detection. */
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared verification / challenge store for magic-link, OTP, phone, etc.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One pending challenge. */
|
|
6
|
+
export interface VerificationRow {
|
|
7
|
+
id: string;
|
|
8
|
+
identifier: string;
|
|
9
|
+
value: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
consumedAt: number | null;
|
|
13
|
+
attempts: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** In-memory verification store. */
|
|
17
|
+
export interface VerificationStore {
|
|
18
|
+
rows: Map<string, VerificationRow>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an empty verification store.
|
|
23
|
+
*/
|
|
24
|
+
export function createVerificationStore(): VerificationStore {
|
|
25
|
+
return { rows: new Map() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Store a challenge (hashed value recommended by callers).
|
|
30
|
+
*
|
|
31
|
+
* @param store - Store
|
|
32
|
+
* @param row - Challenge row
|
|
33
|
+
*/
|
|
34
|
+
export function putVerification(store: VerificationStore, row: VerificationRow): void {
|
|
35
|
+
store.rows.set(row.id, row);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find a non-consumed, non-expired challenge by identifier.
|
|
40
|
+
*
|
|
41
|
+
* @param store - Store
|
|
42
|
+
* @param identifier - Email / phone / username key
|
|
43
|
+
* @param now - Clock
|
|
44
|
+
*/
|
|
45
|
+
export function findActiveVerification(
|
|
46
|
+
store: VerificationStore,
|
|
47
|
+
identifier: string,
|
|
48
|
+
now: number,
|
|
49
|
+
): VerificationRow | undefined {
|
|
50
|
+
for (const row of store.rows.values()) {
|
|
51
|
+
if (row.identifier !== identifier) continue;
|
|
52
|
+
if (row.consumedAt !== null) continue;
|
|
53
|
+
if (row.expiresAt <= now) continue;
|
|
54
|
+
return row;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* SHA-256 hex hash for challenge secrets / OTPs.
|
|
61
|
+
*
|
|
62
|
+
* @param raw - Raw secret
|
|
63
|
+
*/
|
|
64
|
+
export async function hashChallenge(raw: string): Promise<string> {
|
|
65
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw));
|
|
66
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a numeric OTP of `digits` length.
|
|
71
|
+
*
|
|
72
|
+
* @param digits - Length (default 6)
|
|
73
|
+
*/
|
|
74
|
+
export function generateOtp(digits = 6): string {
|
|
75
|
+
const max = 10 ** digits;
|
|
76
|
+
const n = crypto.getRandomValues(new Uint32Array(1))[0]! % max;
|
|
77
|
+
return n.toString().padStart(digits, "0");
|
|
78
|
+
}
|
package/src/cli/dev.test.ts
CHANGED
|
@@ -396,14 +396,14 @@ describe("oke dev docs MCP", () => {
|
|
|
396
396
|
async function writePingApp(dir: string, version: string): Promise<void> {
|
|
397
397
|
await Bun.write(
|
|
398
398
|
join(dir, "src/flows/ping.ts"),
|
|
399
|
-
`import { on, flow, http } from ${JSON.stringify(OKE_INDEX)};
|
|
399
|
+
`import { on, flow, http, gate } from ${JSON.stringify(OKE_INDEX)};
|
|
400
400
|
|
|
401
|
-
export const ping = on(http.get("/ping"), flow({
|
|
401
|
+
export const ping = on(http.get("/ping").gate(gate.public), flow({
|
|
402
402
|
name: "ping",
|
|
403
403
|
do: () => ({ version: ${JSON.stringify(version)} as const }),
|
|
404
404
|
}));
|
|
405
405
|
|
|
406
|
-
export const slow = on(http.get("/slow"), flow({
|
|
406
|
+
export const slow = on(http.get("/slow").gate(gate.public), flow({
|
|
407
407
|
name: "slow",
|
|
408
408
|
do: async () => {
|
|
409
409
|
const started = ${JSON.stringify(version)};
|
package/src/cli/schema.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `oke schema generate` — core + plugin tables → `schema/oke.ts`.
|
|
2
|
+
* `oke schema generate` — core + auth + plugin tables → `schema/oke.ts`.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { mkdir } from "node:fs/promises";
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
resolveAuthSchema,
|
|
10
|
+
type AuthSchemaOptions,
|
|
11
|
+
type ResolvedAuthModel,
|
|
12
|
+
type ResolvedAuthSchema,
|
|
13
|
+
} from "../auth/schema.ts";
|
|
8
14
|
import type { Manifest } from "../manifest/types.ts";
|
|
9
15
|
import { loadManifest } from "./load-config.ts";
|
|
10
16
|
|
|
@@ -25,10 +31,12 @@ export interface SchemaGenerateOptions {
|
|
|
25
31
|
readonly write?: (text: string) => void;
|
|
26
32
|
/** Extra table names (plugins / tests). */
|
|
27
33
|
readonly extraTables?: readonly string[];
|
|
34
|
+
/** Auth schema customization (from `gate.auth` / tests). */
|
|
35
|
+
readonly authSchema?: AuthSchemaOptions | false;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
|
-
* Generate `schema/oke.ts` from Manifest store tables + core
|
|
39
|
+
* Generate `schema/oke.ts` from Manifest store tables + core auth columns.
|
|
32
40
|
*
|
|
33
41
|
* @param options - Manifest / check flag
|
|
34
42
|
*/
|
|
@@ -42,23 +50,41 @@ export async function runSchemaGenerate(options: SchemaGenerateOptions = {}): Pr
|
|
|
42
50
|
if (await file.exists()) manifest = await loadManifest(path);
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
const authSchema =
|
|
54
|
+
options.authSchema === false
|
|
55
|
+
? undefined
|
|
56
|
+
: resolveAuthSchema(options.authSchema ?? loadAuthSchemaFromManifest(manifest));
|
|
57
|
+
|
|
45
58
|
const tables = new Set<string>([
|
|
46
|
-
"oke_roles",
|
|
47
|
-
"oke_role_grants",
|
|
48
|
-
"oke_api_keys",
|
|
49
59
|
"oke_overrides",
|
|
50
60
|
"oke_crons",
|
|
51
61
|
"oke_signal_config",
|
|
52
62
|
"oke_console_prefs",
|
|
53
63
|
...(options.extraTables ?? []),
|
|
54
64
|
]);
|
|
65
|
+
if (authSchema) {
|
|
66
|
+
for (const name of authSchema.tableNames) tables.add(name);
|
|
67
|
+
} else {
|
|
68
|
+
for (const name of [
|
|
69
|
+
"oke_roles",
|
|
70
|
+
"oke_role_grants",
|
|
71
|
+
"oke_api_keys",
|
|
72
|
+
"oke_identities",
|
|
73
|
+
"oke_credentials",
|
|
74
|
+
"oke_sessions",
|
|
75
|
+
"oke_refresh_tokens",
|
|
76
|
+
"oke_verifications",
|
|
77
|
+
]) {
|
|
78
|
+
tables.add(name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
55
81
|
if (manifest?.stores) {
|
|
56
82
|
for (const store of Object.values(manifest.stores)) {
|
|
57
83
|
for (const t of Object.keys(store.tables ?? {})) tables.add(t);
|
|
58
84
|
}
|
|
59
85
|
}
|
|
60
86
|
|
|
61
|
-
const source = emitSchemaSource([...tables].sort());
|
|
87
|
+
const source = emitSchemaSource([...tables].sort(), authSchema);
|
|
62
88
|
const out = resolve(cwd, options.out ?? SCHEMA_OUT);
|
|
63
89
|
const fp = hashSource(source);
|
|
64
90
|
|
|
@@ -91,26 +117,25 @@ export async function runSchemaGenerate(options: SchemaGenerateOptions = {}): Pr
|
|
|
91
117
|
* @param manifest - Optional manifest
|
|
92
118
|
*/
|
|
93
119
|
export async function schemaFingerprint(cwd: string, manifest?: Manifest): Promise<string> {
|
|
120
|
+
let m = manifest;
|
|
121
|
+
if (!m) {
|
|
122
|
+
const path = resolve(cwd, "oke.manifest.json");
|
|
123
|
+
if (await Bun.file(path).exists()) m = await loadManifest(path);
|
|
124
|
+
}
|
|
125
|
+
const authSchema = resolveAuthSchema(loadAuthSchemaFromManifest(m));
|
|
94
126
|
const tables = new Set<string>([
|
|
95
|
-
"oke_roles",
|
|
96
|
-
"oke_role_grants",
|
|
97
|
-
"oke_api_keys",
|
|
98
127
|
"oke_overrides",
|
|
99
128
|
"oke_crons",
|
|
100
129
|
"oke_signal_config",
|
|
101
130
|
"oke_console_prefs",
|
|
131
|
+
...authSchema.tableNames,
|
|
102
132
|
]);
|
|
103
|
-
let m = manifest;
|
|
104
|
-
if (!m) {
|
|
105
|
-
const path = resolve(cwd, "oke.manifest.json");
|
|
106
|
-
if (await Bun.file(path).exists()) m = await loadManifest(path);
|
|
107
|
-
}
|
|
108
133
|
if (m?.stores) {
|
|
109
134
|
for (const store of Object.values(m.stores)) {
|
|
110
135
|
for (const t of Object.keys(store.tables ?? {})) tables.add(t);
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
|
-
return hashSource(emitSchemaSource([...tables].sort()));
|
|
138
|
+
return hashSource(emitSchemaSource([...tables].sort(), authSchema));
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
/**
|
|
@@ -150,7 +175,7 @@ export async function schemaCli(args: readonly string[]): Promise<number> {
|
|
|
150
175
|
else if (a === "--help" || a === "-h") {
|
|
151
176
|
console.log(`oke schema generate [--check|-c] [--out|-o schema/oke.ts]
|
|
152
177
|
|
|
153
|
-
Emit core +
|
|
178
|
+
Emit core auth columns + Manifest store tables. Use --check in CI to fail on drift.
|
|
154
179
|
`);
|
|
155
180
|
return 0;
|
|
156
181
|
}
|
|
@@ -159,20 +184,33 @@ Emit core + plugin tables. Use --check in CI to fail on drift.
|
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
/**
|
|
162
|
-
* Emit a
|
|
187
|
+
* Emit a schema module with real auth columns when resolved.
|
|
163
188
|
*
|
|
164
189
|
* @param tables - Table names
|
|
190
|
+
* @param authSchema - Resolved auth schema (optional)
|
|
165
191
|
*/
|
|
166
|
-
export function emitSchemaSource(
|
|
192
|
+
export function emitSchemaSource(
|
|
193
|
+
tables: readonly string[],
|
|
194
|
+
authSchema?: ResolvedAuthSchema,
|
|
195
|
+
): string {
|
|
196
|
+
const byTable = new Map<string, ResolvedAuthModel>();
|
|
197
|
+
if (authSchema) {
|
|
198
|
+
for (const model of Object.values(authSchema.models)) {
|
|
199
|
+
byTable.set(model.tableName, model);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
167
203
|
const decls = tables
|
|
168
|
-
.map(
|
|
169
|
-
(name)
|
|
170
|
-
|
|
171
|
-
|
|
204
|
+
.map((name) => {
|
|
205
|
+
const model = byTable.get(name);
|
|
206
|
+
if (model) return emitAuthTable(model);
|
|
207
|
+
return `/** Generated table handle — wire columns in your app schema. */\nexport const ${camel(name)} = { name: "${name}" } as const;\n`;
|
|
208
|
+
})
|
|
172
209
|
.join("\n");
|
|
210
|
+
|
|
173
211
|
return `/**
|
|
174
212
|
* Generated by \`oke schema generate\` — do not edit.
|
|
175
|
-
* Core
|
|
213
|
+
* Core Gate auth tables + Manifest store tables.
|
|
176
214
|
*/
|
|
177
215
|
|
|
178
216
|
${decls}
|
|
@@ -180,6 +218,40 @@ export const okeTables = [${tables.map((t) => `"${t}"`).join(", ")}] as const;
|
|
|
180
218
|
`;
|
|
181
219
|
}
|
|
182
220
|
|
|
221
|
+
function emitAuthTable(model: ResolvedAuthModel): string {
|
|
222
|
+
const cols = model.columns
|
|
223
|
+
.map((c) => {
|
|
224
|
+
const extras: string[] = [`sqlType: "${c.sqlType}"`];
|
|
225
|
+
if (c.primary) extras.push("primary: true");
|
|
226
|
+
if (c.required) extras.push("required: true");
|
|
227
|
+
if (c.defaultValue !== undefined) {
|
|
228
|
+
extras.push(`defaultValue: ${JSON.stringify(c.defaultValue)}`);
|
|
229
|
+
}
|
|
230
|
+
return ` ${c.logical}: { name: "${c.sqlName}", ${extras.join(", ")} }`;
|
|
231
|
+
})
|
|
232
|
+
.join(",\n");
|
|
233
|
+
return `/** Auth model \`${model.model}\` → \`${model.tableName}\`. */
|
|
234
|
+
export const ${camel(model.tableName)} = {
|
|
235
|
+
name: "${model.tableName}",
|
|
236
|
+
model: "${model.model}",
|
|
237
|
+
columns: {
|
|
238
|
+
${cols}
|
|
239
|
+
},
|
|
240
|
+
} as const;
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read optional auth schema customization from Manifest extensions.
|
|
246
|
+
*
|
|
247
|
+
* @param manifest - Loaded Manifest
|
|
248
|
+
*/
|
|
249
|
+
function loadAuthSchemaFromManifest(manifest: Manifest | undefined): AuthSchemaOptions {
|
|
250
|
+
if (!manifest) return {};
|
|
251
|
+
const ext = (manifest as { authSchema?: AuthSchemaOptions }).authSchema;
|
|
252
|
+
return ext ?? {};
|
|
253
|
+
}
|
|
254
|
+
|
|
183
255
|
function hashSource(source: string): string {
|
|
184
256
|
return createHash("sha256").update(source).digest("hex");
|
|
185
257
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional auth helpers for {@link createClient} — not a second client factory.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Common auth Flow error codes (server `fail` codes). */
|
|
8
|
+
export const AUTH_ERROR_CODES = {
|
|
9
|
+
AuthFailed: "AuthFailed",
|
|
10
|
+
AuthRateLimited: "AuthRateLimited",
|
|
11
|
+
Unauthorized: "Unauthorized",
|
|
12
|
+
Forbidden: "Forbidden",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/** In-memory session token bag for SPA / Node clients. */
|
|
16
|
+
export interface MemorySession {
|
|
17
|
+
accessToken: string | null;
|
|
18
|
+
refreshToken: string | null;
|
|
19
|
+
accessExpiresAt: number | null;
|
|
20
|
+
userId: string | null;
|
|
21
|
+
set(tokens: {
|
|
22
|
+
readonly accessToken: string;
|
|
23
|
+
readonly refreshToken: string;
|
|
24
|
+
readonly accessExpiresAt?: number;
|
|
25
|
+
readonly userId?: string;
|
|
26
|
+
}): void;
|
|
27
|
+
clear(): void;
|
|
28
|
+
/** Wire into `createClient` `auth.getToken`. */
|
|
29
|
+
getToken(): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Wire into `createClient` `auth.refresh` — calls the refresh Flow via the client.
|
|
32
|
+
*
|
|
33
|
+
* @param api - Client with `auth.refresh` route (or custom path)
|
|
34
|
+
*/
|
|
35
|
+
refresh(api: {
|
|
36
|
+
auth: {
|
|
37
|
+
refresh: (input: { refreshToken: string }) => Promise<{
|
|
38
|
+
data: {
|
|
39
|
+
accessToken: string;
|
|
40
|
+
refreshToken: string;
|
|
41
|
+
accessExpiresAt?: number;
|
|
42
|
+
userId?: string;
|
|
43
|
+
} | null;
|
|
44
|
+
error: unknown;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
}): Promise<string | null>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create an in-memory session helper for Bearer `createClient` wiring.
|
|
52
|
+
*/
|
|
53
|
+
export function memorySession(): MemorySession {
|
|
54
|
+
const state: {
|
|
55
|
+
accessToken: string | null;
|
|
56
|
+
refreshToken: string | null;
|
|
57
|
+
accessExpiresAt: number | null;
|
|
58
|
+
userId: string | null;
|
|
59
|
+
} = {
|
|
60
|
+
accessToken: null,
|
|
61
|
+
refreshToken: null,
|
|
62
|
+
accessExpiresAt: null,
|
|
63
|
+
userId: null,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
get accessToken() {
|
|
68
|
+
return state.accessToken;
|
|
69
|
+
},
|
|
70
|
+
set accessToken(v) {
|
|
71
|
+
state.accessToken = v;
|
|
72
|
+
},
|
|
73
|
+
get refreshToken() {
|
|
74
|
+
return state.refreshToken;
|
|
75
|
+
},
|
|
76
|
+
set refreshToken(v) {
|
|
77
|
+
state.refreshToken = v;
|
|
78
|
+
},
|
|
79
|
+
get accessExpiresAt() {
|
|
80
|
+
return state.accessExpiresAt;
|
|
81
|
+
},
|
|
82
|
+
set accessExpiresAt(v) {
|
|
83
|
+
state.accessExpiresAt = v;
|
|
84
|
+
},
|
|
85
|
+
get userId() {
|
|
86
|
+
return state.userId;
|
|
87
|
+
},
|
|
88
|
+
set userId(v) {
|
|
89
|
+
state.userId = v;
|
|
90
|
+
},
|
|
91
|
+
set(tokens) {
|
|
92
|
+
state.accessToken = tokens.accessToken;
|
|
93
|
+
state.refreshToken = tokens.refreshToken;
|
|
94
|
+
state.accessExpiresAt = tokens.accessExpiresAt ?? null;
|
|
95
|
+
state.userId = tokens.userId ?? null;
|
|
96
|
+
},
|
|
97
|
+
clear() {
|
|
98
|
+
state.accessToken = null;
|
|
99
|
+
state.refreshToken = null;
|
|
100
|
+
state.accessExpiresAt = null;
|
|
101
|
+
state.userId = null;
|
|
102
|
+
},
|
|
103
|
+
getToken() {
|
|
104
|
+
return state.accessToken;
|
|
105
|
+
},
|
|
106
|
+
async refresh(api) {
|
|
107
|
+
if (!state.refreshToken) return null;
|
|
108
|
+
const { data, error } = await api.auth.refresh({ refreshToken: state.refreshToken });
|
|
109
|
+
if (error || !data) {
|
|
110
|
+
state.accessToken = null;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
state.accessToken = data.accessToken;
|
|
114
|
+
state.refreshToken = data.refreshToken;
|
|
115
|
+
state.accessExpiresAt = data.accessExpiresAt ?? null;
|
|
116
|
+
if (data.userId) state.userId = data.userId;
|
|
117
|
+
return data.accessToken;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React helpers for okengine clients — `useSession` over {@link createClient}.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
8
|
+
import type { MemorySession } from "../client/auth.ts";
|
|
9
|
+
|
|
10
|
+
/** Minimal client surface for session reading. */
|
|
11
|
+
export interface SessionClient {
|
|
12
|
+
auth: {
|
|
13
|
+
me: (input?: unknown) => Promise<{
|
|
14
|
+
data: {
|
|
15
|
+
userId: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name: string;
|
|
18
|
+
emailVerified?: boolean;
|
|
19
|
+
} | null;
|
|
20
|
+
error: unknown;
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Session snapshot returned by {@link useSession}. */
|
|
26
|
+
export interface SessionState {
|
|
27
|
+
readonly status: "loading" | "authenticated" | "unauthenticated";
|
|
28
|
+
readonly user: {
|
|
29
|
+
readonly userId: string;
|
|
30
|
+
readonly email: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly emailVerified?: boolean;
|
|
33
|
+
} | null;
|
|
34
|
+
readonly accessToken: string | null;
|
|
35
|
+
refresh(): Promise<void>;
|
|
36
|
+
signOut(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* React hook: read session via `auth.me` + optional {@link memorySession} helper.
|
|
41
|
+
*
|
|
42
|
+
* @param api - Typed client from `createClient`
|
|
43
|
+
* @param session - Optional memory session (Bearer storage)
|
|
44
|
+
*/
|
|
45
|
+
export function useSession(api: SessionClient, session?: MemorySession): SessionState {
|
|
46
|
+
const token = useSyncExternalStore(
|
|
47
|
+
(onStoreChange) => {
|
|
48
|
+
if (!session) return () => {};
|
|
49
|
+
const id = setInterval(onStoreChange, 250);
|
|
50
|
+
return () => clearInterval(id);
|
|
51
|
+
},
|
|
52
|
+
() => session?.accessToken ?? null,
|
|
53
|
+
() => session?.accessToken ?? null,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const [user, setUser] = useState<SessionState["user"]>(null);
|
|
57
|
+
const [status, setStatus] = useState<SessionState["status"]>("loading");
|
|
58
|
+
|
|
59
|
+
const refresh = useCallback(async () => {
|
|
60
|
+
if (!token) {
|
|
61
|
+
setUser(null);
|
|
62
|
+
setStatus("unauthenticated");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setStatus("loading");
|
|
66
|
+
const { data, error } = await api.auth.me({});
|
|
67
|
+
if (error || !data) {
|
|
68
|
+
setUser(null);
|
|
69
|
+
setStatus("unauthenticated");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setUser(data);
|
|
73
|
+
setStatus("authenticated");
|
|
74
|
+
}, [api, token]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
void refresh();
|
|
78
|
+
}, [refresh]);
|
|
79
|
+
|
|
80
|
+
const signOut = useCallback(() => {
|
|
81
|
+
session?.clear();
|
|
82
|
+
setUser(null);
|
|
83
|
+
setStatus("unauthenticated");
|
|
84
|
+
}, [session]);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
status,
|
|
88
|
+
user,
|
|
89
|
+
accessToken: token,
|
|
90
|
+
refresh,
|
|
91
|
+
signOut,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/compiler/aot.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { gate } from "../elements/gate.ts";
|
|
3
4
|
import { oke } from "../kernel/app.ts";
|
|
4
5
|
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
5
6
|
import { on, resetBindings } from "../kernel/on.ts";
|
|
@@ -118,7 +119,7 @@ describe("typed error narrowing end-to-end", () => {
|
|
|
118
119
|
const FlightFull = z.object({ seatsLeft: z.number() });
|
|
119
120
|
|
|
120
121
|
on(
|
|
121
|
-
http.post("/bookings"),
|
|
122
|
+
http.post("/bookings").gate(gate.public),
|
|
122
123
|
flow({
|
|
123
124
|
name: "bookings.create",
|
|
124
125
|
in: Booking,
|