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.
Files changed (127) hide show
  1. package/package.json +3 -1
  2. package/site/content/docs/ai/index.mdx +24 -0
  3. package/site/content/docs/ai/llms-txt.mdx +3 -0
  4. package/site/content/docs/ai/meta.json +1 -1
  5. package/site/content/docs/console/gates.mdx +46 -8
  6. package/site/content/docs/console/index.mdx +54 -0
  7. package/site/content/docs/console/meta.json +1 -0
  8. package/site/content/docs/elements/gate.mdx +187 -48
  9. package/site/content/docs/elements/index.mdx +45 -0
  10. package/site/content/docs/elements/meta.json +1 -1
  11. package/site/content/docs/get-started/basic-usage.mdx +4 -3
  12. package/site/content/docs/get-started/index.mdx +33 -0
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/index.mdx +9 -31
  15. package/site/content/docs/plugins/anonymous.mdx +95 -0
  16. package/site/content/docs/plugins/compression.mdx +2 -2
  17. package/site/content/docs/plugins/cors.mdx +2 -2
  18. package/site/content/docs/plugins/csrf.mdx +2 -2
  19. package/site/content/docs/plugins/email-otp.mdx +111 -0
  20. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +1 -1
  21. package/site/content/docs/plugins/index.mdx +69 -0
  22. package/site/content/docs/plugins/magic-link.mdx +112 -0
  23. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  24. package/site/content/docs/plugins/meta.json +10 -1
  25. package/site/content/docs/plugins/passkey.mdx +128 -0
  26. package/site/content/docs/plugins/phone-number.mdx +111 -0
  27. package/site/content/docs/plugins/two-factor.mdx +116 -0
  28. package/site/content/docs/plugins/username.mdx +117 -0
  29. package/site/content/docs/reference/client.mdx +331 -0
  30. package/site/content/docs/reference/fx.mdx +20 -5
  31. package/site/content/docs/reference/index.mdx +45 -0
  32. package/site/content/docs/reference/meta.json +11 -1
  33. package/site/content/docs/reference/plugins.mdx +25 -14
  34. package/src/auth/auth.test.ts +20 -2
  35. package/src/auth/bindings.ts +439 -0
  36. package/src/auth/breach-check.ts +112 -0
  37. package/src/auth/config.ts +288 -0
  38. package/src/auth/cookies.ts +123 -0
  39. package/src/auth/gate-auth.test.ts +379 -0
  40. package/src/auth/identity.ts +190 -0
  41. package/src/auth/index.ts +117 -1
  42. package/src/auth/method-context.ts +33 -0
  43. package/src/auth/operator.ts +27 -1
  44. package/src/auth/password-policy.test.ts +126 -0
  45. package/src/auth/password-policy.ts +77 -0
  46. package/src/auth/plugin.ts +62 -4
  47. package/src/auth/rate.ts +45 -0
  48. package/src/auth/schema.ts +260 -0
  49. package/src/auth/secondary-storage.ts +37 -0
  50. package/src/auth/sessions.ts +58 -1
  51. package/src/auth/tables.ts +4 -0
  52. package/src/auth/verification.ts +78 -0
  53. package/src/cli/dev.test.ts +3 -3
  54. package/src/cli/schema.ts +95 -23
  55. package/src/client/auth.ts +120 -0
  56. package/src/client-react/index.ts +93 -0
  57. package/src/compiler/aot.test.ts +2 -1
  58. package/src/compiler/extract.ts +19 -0
  59. package/src/compiler/response.ts +16 -2
  60. package/src/console/server/app.ts +10 -6
  61. package/src/console/server/auth-rate.test.ts +3 -3
  62. package/src/console/server/bind.ts +12 -1
  63. package/src/console/server/channels.test.ts +1 -1
  64. package/src/console/server/console-gates.ts +14 -0
  65. package/src/console/server/console.test.ts +6 -6
  66. package/src/console/server/flows-invoke.test.ts +2 -2
  67. package/src/console/server/flows.ts +2 -0
  68. package/src/console/server/gates.ts +8 -1
  69. package/src/console/server/operator-db.test.ts +4 -4
  70. package/src/console/server/operator-db.ts +22 -4
  71. package/src/console/server/security.gate.test.ts +3 -3
  72. package/src/console/ui/gates/fixture.ts +4 -0
  73. package/src/console/ui/gates/types.ts +2 -0
  74. package/src/console/ui/shell/client.ts +1 -0
  75. package/src/elements/gate/boot.ts +136 -0
  76. package/src/elements/gate/config.ts +69 -0
  77. package/src/elements/gate/declare.ts +51 -1
  78. package/src/elements/gate/runtime.ts +3 -1
  79. package/src/elements/gate.test.ts +77 -0
  80. package/src/elements/gate.ts +20 -1
  81. package/src/elements/index.ts +8 -0
  82. package/src/index.ts +11 -0
  83. package/src/kernel/app.ts +253 -32
  84. package/src/kernel/boot.test.ts +40 -3
  85. package/src/kernel/boot.ts +8 -0
  86. package/src/kernel/call.test.ts +46 -2
  87. package/src/kernel/edge.test.ts +3 -3
  88. package/src/kernel/flow.test.ts +2 -2
  89. package/src/kernel/fx.test.ts +1 -0
  90. package/src/kernel/fx.ts +60 -0
  91. package/src/kernel/hooks.test.ts +4 -4
  92. package/src/kernel/index.ts +12 -0
  93. package/src/kernel/pipeline.test.ts +12 -8
  94. package/src/kernel/pipeline.ts +23 -4
  95. package/src/kernel/plugin/decorate.test.ts +3 -3
  96. package/src/kernel/plugin/scoping.test.ts +3 -3
  97. package/src/kernel/plugin-elements.test.ts +51 -0
  98. package/src/kernel/plugin-needs.test.ts +83 -0
  99. package/src/kernel/plugin-needs.ts +129 -0
  100. package/src/kernel/plugin.ts +101 -0
  101. package/src/kernel/registry-isolation.test.ts +5 -5
  102. package/src/kernel/registry.ts +102 -3
  103. package/src/manifest/types.ts +2 -0
  104. package/src/plugins/anonymous.ts +58 -0
  105. package/src/plugins/auth/shared.ts +121 -0
  106. package/src/plugins/auth-methods.test.ts +176 -0
  107. package/src/plugins/compression.test.ts +5 -5
  108. package/src/plugins/config-source.test.ts +1 -1
  109. package/src/plugins/cors.test.ts +16 -10
  110. package/src/plugins/csrf.test.ts +1 -1
  111. package/src/plugins/email-otp.ts +161 -0
  112. package/src/plugins/index.ts +31 -0
  113. package/src/plugins/ip-allowlist.test.ts +19 -9
  114. package/src/plugins/magic-link.ts +163 -0
  115. package/src/plugins/maintenance-mode.test.ts +9 -5
  116. package/src/plugins/passkey.ts +216 -0
  117. package/src/plugins/phone-number.ts +149 -0
  118. package/src/plugins/security-headers.test.ts +14 -14
  119. package/src/plugins/two-factor.ts +249 -0
  120. package/src/plugins/username.ts +148 -0
  121. package/src/runs/runs.test.ts +6 -2
  122. package/src/runtime/primitives.ts +37 -4
  123. package/src/runtime/serve.test.ts +3 -2
  124. package/src/runtime/types.ts +25 -2
  125. package/src/test/create-test-app.test.ts +1 -1
  126. package/src/test/create-test-app.ts +4 -1
  127. package/src/test/provisions.integration.test.ts +1 -1
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Active Gate auth context for method plugins plugged on the same app.
3
+ *
4
+ * Set during `oke({ gate: { auth } })` so `.plug(username())` etc. share
5
+ * the app session store + HMAC secret without callers re-passing them.
6
+ */
7
+
8
+ import type { SessionStore } from "./sessions.ts";
9
+
10
+ /** Shared context for auth method plugins. */
11
+ export interface ActiveGateAuthContext {
12
+ readonly secret: string;
13
+ readonly sessions: SessionStore;
14
+ readonly now?: () => number;
15
+ }
16
+
17
+ let active: ActiveGateAuthContext | undefined;
18
+
19
+ /**
20
+ * Publish the app's Gate auth binding for subsequent `.plug()` method plugins.
21
+ *
22
+ * @param ctx - Secret + sessions (or undefined to clear)
23
+ */
24
+ export function setActiveGateAuthContext(ctx: ActiveGateAuthContext | undefined): void {
25
+ active = ctx;
26
+ }
27
+
28
+ /**
29
+ * Read the active Gate auth context, if any.
30
+ */
31
+ export function getActiveGateAuthContext(): ActiveGateAuthContext | undefined {
32
+ return active;
33
+ }
@@ -7,7 +7,10 @@
7
7
  * @see docs/spec/console.md §2.2–2.3
8
8
  */
9
9
 
10
+ import type { PasswordHashOptions } from "../runtime/types.ts";
10
11
  import { createBunCrypto } from "../runtime/primitives.ts";
12
+ import { assertNotBreached, type BreachCheckFn } from "./breach-check.ts";
13
+ import { assertPasswordPolicy, type PasswordPolicyOptions } from "./password-policy.ts";
11
14
  import type { OperatorCredentialRow, OperatorRow, OperatorSsoLinkRow } from "./tables.ts";
12
15
 
13
16
  /** Operator-plane store. */
@@ -38,6 +41,22 @@ export interface CreateOperatorOptions {
38
41
  readonly invitedBy?: string | null;
39
42
  readonly id?: string;
40
43
  readonly roles?: readonly string[];
44
+ /**
45
+ * Password length / character-class policy. Defaults applied when omitted
46
+ * (minLength 12, letter + number). Pass custom knobs to tighten/loosen
47
+ * within reason — never skip without {@link skipPasswordPolicy}.
48
+ */
49
+ readonly passwordPolicy?: PasswordPolicyOptions;
50
+ /**
51
+ * Explicit opt-out of password policy (test harness only).
52
+ * Production credential-set paths must omit this — same shape as
53
+ * `unguardedHttp: "allow"` restricted to tests.
54
+ */
55
+ readonly skipPasswordPolicy?: boolean;
56
+ /** Bun.password cost knobs (argon2id floor enforced). */
57
+ readonly passwordHash?: PasswordHashOptions;
58
+ /** Optional breach check (`true` = reject). */
59
+ readonly breachCheck?: BreachCheckFn;
41
60
  }
42
61
 
43
62
  /**
@@ -50,6 +69,10 @@ export async function createOperator(
50
69
  store: OperatorStore,
51
70
  options: CreateOperatorOptions,
52
71
  ): Promise<OperatorRow> {
72
+ if (!options.skipPasswordPolicy) {
73
+ assertPasswordPolicy(options.password, options.passwordPolicy ?? {});
74
+ }
75
+ await assertNotBreached(options.password, options.breachCheck);
53
76
  const crypto = createBunCrypto();
54
77
  const id = options.id ?? crypto.randomUUID();
55
78
  const row: OperatorRow = {
@@ -64,7 +87,10 @@ export async function createOperator(
64
87
  store.operators.set(id, row);
65
88
  store.credentials.set(id, {
66
89
  operatorId: id,
67
- passwordHash: await crypto.hashPassword(options.password, "argon2id"),
90
+ passwordHash: await crypto.hashPassword(
91
+ options.password,
92
+ options.passwordHash ?? { algorithm: "argon2id" },
93
+ ),
68
94
  loginEnabled: true,
69
95
  });
70
96
  store.ssoLinks.set(id, []);
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ assertNotBreached,
4
+ createHibpBreachCheck,
5
+ sha1HexUpper,
6
+ BreachCheckError,
7
+ } from "./breach-check.ts";
8
+ import { assertPasswordPolicy, PasswordPolicyError } from "./password-policy.ts";
9
+ import { createBunCrypto } from "../runtime/primitives.ts";
10
+ import { ARGON2ID_MEMORY_COST_FLOOR } from "../runtime/types.ts";
11
+ import { createOperator, createOperatorStore } from "./operator.ts";
12
+ import { createSessionStore, issueSession, rotateRefresh, SessionError } from "./sessions.ts";
13
+
14
+ describe("password policy", () => {
15
+ test("defaults require length 12, letter, number", () => {
16
+ expect(() => assertPasswordPolicy("short1A", {})).toThrow(PasswordPolicyError);
17
+ expect(() => assertPasswordPolicy("longenoughword", {})).toThrow(PasswordPolicyError);
18
+ expect(() => assertPasswordPolicy("Longenough12", {})).not.toThrow();
19
+ });
20
+
21
+ test("createOperator rejects weak passwords by default without passwordPolicy", async () => {
22
+ const store = createOperatorStore();
23
+ await expect(
24
+ createOperator(store, {
25
+ email: "a@example.com",
26
+ name: "A",
27
+ password: "123",
28
+ }),
29
+ ).rejects.toThrow(PasswordPolicyError);
30
+ });
31
+
32
+ test("createOperator skipPasswordPolicy is the explicit test opt-out", async () => {
33
+ const store = createOperatorStore();
34
+ const op = await createOperator(store, {
35
+ email: "b@example.com",
36
+ name: "B",
37
+ password: "123",
38
+ skipPasswordPolicy: true,
39
+ });
40
+ expect(op.email).toBe("b@example.com");
41
+ });
42
+ });
43
+
44
+ describe("Bun.password costs", () => {
45
+ test("rejects memoryCost below Bun floor", async () => {
46
+ const crypto = createBunCrypto();
47
+ await expect(
48
+ crypto.hashPassword("Longenough12", { algorithm: "argon2id", memoryCost: 1024 }),
49
+ ).rejects.toThrow(/memoryCost/);
50
+ });
51
+
52
+ test("hashes at Bun default floor", async () => {
53
+ const crypto = createBunCrypto();
54
+ const hash = await crypto.hashPassword("Longenough12", {
55
+ algorithm: "argon2id",
56
+ memoryCost: ARGON2ID_MEMORY_COST_FLOOR,
57
+ timeCost: 2,
58
+ });
59
+ expect(hash).toContain("m=65536,t=2");
60
+ expect(await crypto.verifyPassword("Longenough12", hash)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("HIBP k-anonymity helper", () => {
65
+ test("matches suffix locally without sending full hash", async () => {
66
+ const full = await sha1HexUpper("password");
67
+ const prefix = full.slice(0, 5);
68
+ const suffix = full.slice(5);
69
+ const check = createHibpBreachCheck({
70
+ userAgent: "oke-test",
71
+ fetch: (async (input) => {
72
+ const url = String(input);
73
+ expect(url).toContain("/range/");
74
+ if (url.includes(`/range/${prefix}`)) {
75
+ return new Response(`${suffix}:12\nDEADBEEF00:1\n`, { status: 200 });
76
+ }
77
+ return new Response("AAAAA00000:1\n", { status: 200 });
78
+ }) as typeof fetch,
79
+ });
80
+ expect(await check("password")).toBe(true);
81
+ expect(await check("not-in-list-xyz")).toBe(false);
82
+ });
83
+
84
+ test("assertNotBreached throws when check returns true", async () => {
85
+ await expect(assertNotBreached("x", async () => true)).rejects.toThrow(BreachCheckError);
86
+ });
87
+ });
88
+
89
+ describe("session idle / absolute / single-session", () => {
90
+ test("idle timeout rejects refresh", async () => {
91
+ let now = 1_000_000;
92
+ const store = createSessionStore();
93
+ const crypto = {
94
+ secret: "test-secret-at-least-32-bytes-long!!",
95
+ now: () => now,
96
+ idleTtlMs: 60_000,
97
+ };
98
+ const issued = await issueSession(store, crypto, {
99
+ id: "u1",
100
+ plane: "user",
101
+ scopes: [],
102
+ });
103
+ now += 120_000;
104
+ await expect(rotateRefresh(store, crypto, issued.refreshToken)).rejects.toThrow(SessionError);
105
+ });
106
+
107
+ test("singleSessionPerUser revokes prior family", async () => {
108
+ const store = createSessionStore();
109
+ const crypto = {
110
+ secret: "test-secret-at-least-32-bytes-long!!",
111
+ now: () => 1_000_000,
112
+ singleSessionPerUser: true,
113
+ };
114
+ const first = await issueSession(store, crypto, {
115
+ id: "u1",
116
+ plane: "user",
117
+ scopes: [],
118
+ });
119
+ await issueSession(store, crypto, {
120
+ id: "u1",
121
+ plane: "user",
122
+ scopes: [],
123
+ });
124
+ expect(store.sessions.get(first.session.id)?.revokedAt).not.toBeNull();
125
+ });
126
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Password policy — length + character-class rules (never weaker defaults).
3
+ */
4
+
5
+ /** Options for {@link assertPasswordPolicy}. */
6
+ export interface PasswordPolicyOptions {
7
+ /** Minimum length (default 12). */
8
+ readonly minLength?: number;
9
+ /** Require at least one letter (default true). */
10
+ readonly requireLetter?: boolean;
11
+ /** Require at least one digit (default true). */
12
+ readonly requireNumber?: boolean;
13
+ /** Require at least one non-alphanumeric symbol (default false). */
14
+ readonly requireSymbol?: boolean;
15
+ }
16
+
17
+ /** Resolved policy with defaults applied. */
18
+ export interface ResolvedPasswordPolicy {
19
+ readonly minLength: number;
20
+ readonly requireLetter: boolean;
21
+ readonly requireNumber: boolean;
22
+ readonly requireSymbol: boolean;
23
+ }
24
+
25
+ /** Default minimum password length. */
26
+ export const DEFAULT_PASSWORD_MIN_LENGTH = 12;
27
+
28
+ /**
29
+ * Resolve policy options with safe defaults.
30
+ *
31
+ * @param options - Partial policy
32
+ */
33
+ export function resolvePasswordPolicy(options: PasswordPolicyOptions = {}): ResolvedPasswordPolicy {
34
+ return {
35
+ minLength: options.minLength ?? DEFAULT_PASSWORD_MIN_LENGTH,
36
+ requireLetter: options.requireLetter ?? true,
37
+ requireNumber: options.requireNumber ?? true,
38
+ requireSymbol: options.requireSymbol ?? false,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Validate a password against policy. Throws {@link PasswordPolicyError}.
44
+ *
45
+ * @param password - Plaintext
46
+ * @param options - Policy (defaults applied)
47
+ */
48
+ export function assertPasswordPolicy(password: string, options: PasswordPolicyOptions = {}): void {
49
+ const policy = resolvePasswordPolicy(options);
50
+ const reasons: string[] = [];
51
+ if (password.length < policy.minLength) {
52
+ reasons.push(`minLength ${policy.minLength} (got ${password.length})`);
53
+ }
54
+ if (policy.requireLetter && !/[A-Za-z]/.test(password)) {
55
+ reasons.push("requireLetter");
56
+ }
57
+ if (policy.requireNumber && !/\d/.test(password)) {
58
+ reasons.push("requireNumber");
59
+ }
60
+ if (policy.requireSymbol && !/[^A-Za-z0-9]/.test(password)) {
61
+ reasons.push("requireSymbol");
62
+ }
63
+ if (reasons.length > 0) {
64
+ throw new PasswordPolicyError(reasons);
65
+ }
66
+ }
67
+
68
+ /** Password failed policy checks. */
69
+ export class PasswordPolicyError extends Error {
70
+ readonly reasons: readonly string[];
71
+
72
+ constructor(reasons: readonly string[]) {
73
+ super(`password policy failed: ${reasons.join(", ")}`);
74
+ this.name = "PasswordPolicyError";
75
+ this.reasons = reasons;
76
+ }
77
+ }
@@ -4,23 +4,79 @@
4
4
  * Uses only the public plugin API (unified-theory §14).
5
5
  */
6
6
 
7
+ import type { PasswordHashOptions } from "../runtime/types.ts";
7
8
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
9
+ import type { BreachCheckFn } from "./breach-check.ts";
10
+ import type { PasswordPolicyOptions } from "./password-policy.ts";
8
11
  import { AUTH_TABLES } from "./tables.ts";
9
12
 
13
+ /** Session knobs beyond raw access/refresh TTLs. */
14
+ export interface AuthSessionOptions {
15
+ /** Access-token TTL override (ms). Default 14 minutes. */
16
+ readonly accessTtlMs?: number;
17
+ /** Refresh-token TTL override (ms). Default 30 days. */
18
+ readonly refreshTtlMs?: number;
19
+ /** Idle timeout from last activity (ms). Opt-in. */
20
+ readonly idleTtlMs?: number;
21
+ /** Absolute lifetime from session creation (ms). Opt-in. */
22
+ readonly absoluteTtlMs?: number;
23
+ /** Revoke other families for the same principal on issue. Opt-in. */
24
+ readonly singleSessionPerUser?: boolean;
25
+ }
26
+
10
27
  /** Options for {@link auth}. */
11
28
  export interface AuthPluginOptions {
12
29
  /** HMAC secret for access tokens (required in production). */
13
30
  readonly secret?: string;
14
- /** Access-token TTL override (ms). */
31
+ /** Access-token TTL override (ms). Prefer {@link session}. */
32
+ readonly accessTtlMs?: number;
33
+ /** Refresh-token TTL override (ms). Prefer {@link session}. */
34
+ readonly refreshTtlMs?: number;
35
+ /** Session timeouts and single-session policy. */
36
+ readonly session?: AuthSessionOptions;
37
+ /**
38
+ * Bun.password cost knobs. Defaults match Bun argon2id (`m=65536`, `t=2`);
39
+ * weaker values are rejected.
40
+ */
41
+ readonly password?: PasswordHashOptions;
42
+ /**
43
+ * Password length / character-class policy (defaults: minLength 12,
44
+ * letter + number). Credential-set paths enforce defaults unless a
45
+ * caller passes `skipPasswordPolicy` (tests only).
46
+ */
47
+ readonly passwordPolicy?: PasswordPolicyOptions;
48
+ /**
49
+ * Pluggable breach check (`true` = reject). Default off.
50
+ * Use {@link createHibpBreachCheck} for Have I Been Pwned k-anonymity.
51
+ */
52
+ readonly breachCheck?: BreachCheckFn;
53
+ }
54
+
55
+ /**
56
+ * Flatten plugin options into {@link SessionCrypto}-compatible fields.
57
+ *
58
+ * @param options - Auth plugin options
59
+ */
60
+ export function sessionCryptoFromAuthOptions(options: AuthPluginOptions): {
15
61
  readonly accessTtlMs?: number;
16
- /** Refresh-token TTL override (ms). */
17
62
  readonly refreshTtlMs?: number;
63
+ readonly idleTtlMs?: number;
64
+ readonly absoluteTtlMs?: number;
65
+ readonly singleSessionPerUser?: boolean;
66
+ } {
67
+ return {
68
+ accessTtlMs: options.session?.accessTtlMs ?? options.accessTtlMs,
69
+ refreshTtlMs: options.session?.refreshTtlMs ?? options.refreshTtlMs,
70
+ idleTtlMs: options.session?.idleTtlMs,
71
+ absoluteTtlMs: options.session?.absoluteTtlMs,
72
+ singleSessionPerUser: options.session?.singleSessionPerUser,
73
+ };
18
74
  }
19
75
 
20
76
  /**
21
77
  * Builtin auth plugin.
22
78
  *
23
- * @param options - Session crypto options
79
+ * @param options - Session crypto / password policy options
24
80
  */
25
81
  export function auth(options: AuthPluginOptions = {}): PluginDef {
26
82
  let builder = plugin("auth", { version: "0.0.1", config: options }).hook("onAuth", (_ctx) => {
@@ -31,7 +87,9 @@ export function auth(options: AuthPluginOptions = {}): PluginDef {
31
87
  builder = builder.table(name, undefined, {
32
88
  plane: name.includes("operator")
33
89
  ? "operator"
34
- : name.includes("identit") || name === AUTH_TABLES.credentials
90
+ : name.includes("identit") ||
91
+ name === AUTH_TABLES.credentials ||
92
+ name === AUTH_TABLES.verifications
35
93
  ? "user"
36
94
  : "shared",
37
95
  });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Sliding-window rate limit for auth credential checks.
3
+ *
4
+ * Shared strategy with Console operator login (5 / 60s).
5
+ */
6
+
7
+ /** Max attempts per window. */
8
+ export const AUTH_RATE_LIMIT = 5;
9
+
10
+ /** Rate-limit window. */
11
+ export const AUTH_RATE_WINDOW_MS = 60 * 1000;
12
+
13
+ /**
14
+ * Record an attempt and return whether the key is still under the limit.
15
+ *
16
+ * @param attempts - Sliding timestamps for one key
17
+ * @param now - Epoch-ms
18
+ * @param limit - Max attempts
19
+ * @param windowMs - Window length
20
+ */
21
+ export function touchRateLimit(
22
+ attempts: number[],
23
+ now: number,
24
+ limit: number = AUTH_RATE_LIMIT,
25
+ windowMs: number = AUTH_RATE_WINDOW_MS,
26
+ ): "ok" | "rate_limited" {
27
+ while (attempts.length > 0 && attempts[0]! <= now - windowMs) {
28
+ attempts.shift();
29
+ }
30
+ if (attempts.length >= limit) {
31
+ return "rate_limited";
32
+ }
33
+ attempts.push(now);
34
+ return "ok";
35
+ }
36
+
37
+ /** Per-key attempt bags (email → timestamps). */
38
+ export type LoginAttemptBag = Map<string, number[]>;
39
+
40
+ /**
41
+ * Create an empty login attempt bag.
42
+ */
43
+ export function createLoginAttemptBag(): LoginAttemptBag {
44
+ return new Map();
45
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Core Gate auth schema — defaults + `modelName` / `fields` / `additionalFields`.
3
+ *
4
+ * Used by `gate.auth` resolution and `oke schema generate`.
5
+ */
6
+
7
+ import { AUTH_TABLES } from "./tables.ts";
8
+
9
+ /** Supported additional-field value types. */
10
+ export type AuthFieldType = "string" | "number" | "boolean" | "json";
11
+
12
+ /** Extra column declared on a core auth model. */
13
+ export interface AuthAdditionalField {
14
+ readonly type: AuthFieldType;
15
+ readonly required?: boolean;
16
+ readonly defaultValue?: unknown;
17
+ /** Database column name when different from the field key. */
18
+ readonly fieldName?: string;
19
+ }
20
+
21
+ /** Per-model customization. */
22
+ export interface AuthModelOptions {
23
+ /** Physical table name. */
24
+ readonly modelName?: string;
25
+ /**
26
+ * Rename logical fields → physical columns
27
+ * (e.g. `{ principalId: "user_id" }`).
28
+ */
29
+ readonly fields?: Readonly<Record<string, string>>;
30
+ /** Extra columns beyond the core set. */
31
+ readonly additionalFields?: Readonly<Record<string, AuthAdditionalField>>;
32
+ }
33
+
34
+ /** Logical core models Gate auth owns. */
35
+ export type AuthCoreModel =
36
+ | "user"
37
+ | "account"
38
+ | "session"
39
+ | "refreshToken"
40
+ | "verification"
41
+ | "roles"
42
+ | "apiKeys";
43
+
44
+ /** Default logical → physical field map per model. */
45
+ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
46
+ Record<AuthCoreModel, Readonly<Record<string, string>>>
47
+ > = {
48
+ user: {
49
+ id: "id",
50
+ email: "email",
51
+ name: "name",
52
+ emailVerified: "email_verified",
53
+ image: "image",
54
+ createdAt: "created_at",
55
+ updatedAt: "updated_at",
56
+ status: "status",
57
+ },
58
+ account: {
59
+ id: "id",
60
+ userId: "user_id",
61
+ provider: "provider",
62
+ providerAccountId: "provider_account_id",
63
+ passwordHash: "password_hash",
64
+ createdAt: "created_at",
65
+ updatedAt: "updated_at",
66
+ },
67
+ session: {
68
+ id: "id",
69
+ principalId: "principal_id",
70
+ plane: "plane",
71
+ familyId: "family_id",
72
+ revokedAt: "revoked_at",
73
+ createdAt: "created_at",
74
+ expiresAt: "expires_at",
75
+ lastActiveAt: "last_active_at",
76
+ },
77
+ refreshToken: {
78
+ id: "id",
79
+ sessionId: "session_id",
80
+ familyId: "family_id",
81
+ hash: "hash",
82
+ expiresAt: "expires_at",
83
+ usedAt: "used_at",
84
+ revokedAt: "revoked_at",
85
+ },
86
+ verification: {
87
+ id: "id",
88
+ identifier: "identifier",
89
+ value: "value",
90
+ expiresAt: "expires_at",
91
+ createdAt: "created_at",
92
+ },
93
+ roles: {
94
+ id: "id",
95
+ name: "name",
96
+ plane: "plane",
97
+ description: "description",
98
+ },
99
+ apiKeys: {
100
+ id: "id",
101
+ plane: "plane",
102
+ hash: "hash",
103
+ name: "name",
104
+ scopes: "scopes",
105
+ expiresAt: "expires_at",
106
+ createdAt: "created_at",
107
+ lastUsedAt: "last_used_at",
108
+ revokedAt: "revoked_at",
109
+ },
110
+ };
111
+
112
+ /** Default table names (aligned with {@link AUTH_TABLES} where they overlap). */
113
+ export const AUTH_MODEL_DEFAULT_TABLES: Readonly<Record<AuthCoreModel, string>> = {
114
+ user: AUTH_TABLES.identities,
115
+ account: AUTH_TABLES.credentials,
116
+ session: AUTH_TABLES.sessions,
117
+ refreshToken: AUTH_TABLES.refreshTokens,
118
+ verification: "oke_verifications",
119
+ roles: AUTH_TABLES.roles,
120
+ apiKeys: AUTH_TABLES.apiKeys,
121
+ };
122
+
123
+ /** SQL type hint for generated columns. */
124
+ export type AuthColumnSqlType = "TEXT" | "INTEGER" | "REAL" | "BLOB";
125
+
126
+ /** One resolved column for schema generate / DDL. */
127
+ export interface ResolvedAuthColumn {
128
+ readonly logical: string;
129
+ readonly sqlName: string;
130
+ readonly sqlType: AuthColumnSqlType;
131
+ readonly primary?: boolean;
132
+ readonly required?: boolean;
133
+ readonly defaultValue?: unknown;
134
+ }
135
+
136
+ /** One resolved auth model. */
137
+ export interface ResolvedAuthModel {
138
+ readonly model: AuthCoreModel;
139
+ readonly tableName: string;
140
+ readonly fields: Readonly<Record<string, string>>;
141
+ readonly columns: readonly ResolvedAuthColumn[];
142
+ readonly additionalFields: Readonly<Record<string, AuthAdditionalField>>;
143
+ }
144
+
145
+ /** Fully resolved auth schema. */
146
+ export interface ResolvedAuthSchema {
147
+ readonly models: Readonly<Record<AuthCoreModel, ResolvedAuthModel>>;
148
+ /** All physical table names (sorted unique). */
149
+ readonly tableNames: readonly string[];
150
+ }
151
+
152
+ /** Input bag for {@link resolveAuthSchema}. */
153
+ export interface AuthSchemaOptions {
154
+ readonly user?: AuthModelOptions;
155
+ readonly account?: AuthModelOptions;
156
+ readonly session?: AuthModelOptions;
157
+ readonly refreshToken?: AuthModelOptions;
158
+ readonly verification?: AuthModelOptions;
159
+ readonly roles?: AuthModelOptions;
160
+ readonly apiKeys?: AuthModelOptions;
161
+ }
162
+
163
+ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
164
+ id: "TEXT",
165
+ email: "TEXT",
166
+ name: "TEXT",
167
+ emailVerified: "INTEGER",
168
+ image: "TEXT",
169
+ createdAt: "INTEGER",
170
+ updatedAt: "INTEGER",
171
+ status: "TEXT",
172
+ userId: "TEXT",
173
+ provider: "TEXT",
174
+ providerAccountId: "TEXT",
175
+ passwordHash: "TEXT",
176
+ principalId: "TEXT",
177
+ plane: "TEXT",
178
+ familyId: "TEXT",
179
+ revokedAt: "INTEGER",
180
+ expiresAt: "INTEGER",
181
+ lastActiveAt: "INTEGER",
182
+ sessionId: "TEXT",
183
+ hash: "TEXT",
184
+ usedAt: "INTEGER",
185
+ identifier: "TEXT",
186
+ value: "TEXT",
187
+ description: "TEXT",
188
+ scopes: "TEXT",
189
+ lastUsedAt: "INTEGER",
190
+ };
191
+
192
+ /**
193
+ * Map an additional-field type to a SQL column type.
194
+ *
195
+ * @param type - Declared field type
196
+ */
197
+ export function authFieldSqlType(type: AuthFieldType): AuthColumnSqlType {
198
+ switch (type) {
199
+ case "number":
200
+ return "INTEGER";
201
+ case "boolean":
202
+ return "INTEGER";
203
+ case "json":
204
+ return "TEXT";
205
+ default:
206
+ return "TEXT";
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Resolve core auth schema with defaults + caller overrides.
212
+ *
213
+ * @param options - Per-model customization from `gate.auth`
214
+ */
215
+ export function resolveAuthSchema(options: AuthSchemaOptions = {}): ResolvedAuthSchema {
216
+ const models = {} as Record<AuthCoreModel, ResolvedAuthModel>;
217
+ const tableNames = new Set<string>();
218
+
219
+ for (const model of Object.keys(AUTH_MODEL_DEFAULT_TABLES) as AuthCoreModel[]) {
220
+ const opts = options[model] ?? {};
221
+ const defaults = AUTH_MODEL_DEFAULT_FIELDS[model];
222
+ const fields: Record<string, string> = { ...defaults, ...(opts.fields ?? {}) };
223
+ const additional = opts.additionalFields ?? {};
224
+ const tableName = opts.modelName ?? AUTH_MODEL_DEFAULT_TABLES[model];
225
+ tableNames.add(tableName);
226
+
227
+ const columns: ResolvedAuthColumn[] = [];
228
+ for (const [logical, sqlName] of Object.entries(fields)) {
229
+ columns.push({
230
+ logical,
231
+ sqlName,
232
+ sqlType: CORE_SQL_TYPES[logical] ?? "TEXT",
233
+ primary: logical === "id",
234
+ required: logical === "id" || logical === "email" || logical === "principalId",
235
+ });
236
+ }
237
+ for (const [key, field] of Object.entries(additional)) {
238
+ columns.push({
239
+ logical: key,
240
+ sqlName: field.fieldName ?? fields[key] ?? key,
241
+ sqlType: authFieldSqlType(field.type),
242
+ required: field.required === true,
243
+ defaultValue: field.defaultValue,
244
+ });
245
+ }
246
+
247
+ models[model] = {
248
+ model,
249
+ tableName,
250
+ fields,
251
+ columns,
252
+ additionalFields: additional,
253
+ };
254
+ }
255
+
256
+ return {
257
+ models,
258
+ tableNames: [...tableNames].sort(),
259
+ };
260
+ }