okengine 0.7.0 → 0.9.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 (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -13,14 +13,15 @@ OKE reads environment variables at boot for connection detail and secrets — ne
13
13
 
14
14
  ## SQL store
15
15
 
16
- | Variable | Used for | Default when unset |
17
- | ------------------- | ---------------------------------------------------- | ------------------------------- |
18
- | `DATABASE_URL` | Postgres connection (`postgres` driver, drizzle-kit) | `postgres://localhost:5432/oke` |
19
- | `OKE_STORE_SQL_URL` | Explicit SQL URL override | — |
20
- | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
21
- | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
22
- | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
23
- | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
16
+ | Variable | Used for | Default when unset |
17
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
18
+ | `DATABASE_URL` | Postgres connection (`postgres` store / clock / journal drivers, drizzle-kit). When PgDog is in the stack, this points at the pooler (`:6432`). | `postgres://localhost:5432/oke` |
19
+ | `OKE_STORE_SQL_URL` | Direct SQL URL (Postgres host, even when PgDog fronts the app) | — |
20
+ | `OKE_PGDOG_URL` | PgDog pooler URL (same value as `DATABASE_URL` when both are present) | |
21
+ | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
22
+ | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
23
+ | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
24
+ | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
24
25
 
25
26
  ## Index store
26
27
 
@@ -74,6 +75,8 @@ Read when `drivers.channel.email` resolves to that driver id.
74
75
  | `RESEND_API_KEY` | `resend` API key |
75
76
  | `SNDR_API_KEY` | `sndr` API key |
76
77
  | `SNDR_BASE_URL` | Optional SNDR API origin (default `api.sndr.sh`) |
78
+ | `TAQNYAT_MAIL_TOKEN` | `taqnyat-mail` bearer token (Email-enabled) |
79
+ | `TAQNYAT_CAMPAIGN` | `taqnyat-mail` campaign name |
77
80
 
78
81
  ## Channel (SMS) — boot binder
79
82
 
@@ -94,6 +97,16 @@ Read when `drivers.channel.sms` resolves to that driver id (`console` opens noth
94
97
  WhatsApp (`wa-cloud`) and push (`webpush` / `fcm`) are not opened from env at
95
98
  boot — pass them on `BootOptions.channel.drivers` with their open options.
96
99
 
100
+ ## Live test gates (opt-in)
101
+
102
+ Provider-quota-burning live suites are double-gated: the medium flag **plus** that
103
+ provider's real credentials — credentials alone never send.
104
+
105
+ | Variable | Used for |
106
+ | ---------------- | --------------------------------------------------------- |
107
+ | `OKE_SMS_LIVE` | `=1` allows live SMS provider tests (e.g. Taqnyat OTP) |
108
+ | `OKE_EMAIL_LIVE` | `=1` allows live email provider tests (e.g. Taqnyat Mail) |
109
+
97
110
  ## AI providers
98
111
 
99
112
  | Variable | Used for | Default when unset |
@@ -243,7 +243,7 @@ Extending an existing **app-owned** table with plugin columns is not supported i
243
243
 
244
244
  ## Learn more
245
245
 
246
- - [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [email OTP](/docs/plugins/email-otp) · [phone](/docs/plugins/phone-number) · [two-factor](/docs/plugins/two-factor) · [passkey](/docs/plugins/passkey) · [Headers](/docs/plugins/headers) · [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) · [Compression](/docs/plugins/compression) · [Maintenance Mode](/docs/plugins/maintenance-mode) · [IP Allowlist](/docs/plugins/ip-allowlist)
246
+ - [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [OTP](/docs/plugins/otp) · [two-factor](/docs/plugins/two-factor) · [passkey](/docs/plugins/passkey) · [Headers](/docs/plugins/headers) · [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) · [Compression](/docs/plugins/compression) · [Maintenance Mode](/docs/plugins/maintenance-mode) · [IP Allowlist](/docs/plugins/ip-allowlist)
247
247
  - [Flow](/docs/elements/flow) — what plugin flows and hooks plug into
248
248
  - [Store](/docs/elements/store) — `field.*` builders and schema sync
249
249
  - [Configuration](/docs/reference/configuration) — where plugin config is declared
@@ -231,6 +231,42 @@ describe("hybrid sessions", () => {
231
231
  SessionError,
232
232
  );
233
233
  });
234
+
235
+ test("scopes and audience survive on the session row across store instances", async () => {
236
+ const store = createSessionStore();
237
+ let now = 2_000_000;
238
+ const crypto = {
239
+ secret: "test-secret",
240
+ now: () => now,
241
+ audience: "oke-app",
242
+ };
243
+ const issued = await issueSessionWithScopes(store, crypto, {
244
+ id: "user-2",
245
+ plane: "user",
246
+ scopes: ["notes:write", "notes:read"],
247
+ });
248
+ expect(issued.session.scopes).toEqual(["notes:write", "notes:read"]);
249
+ expect(issued.session.audience).toBe("oke-app");
250
+
251
+ // Simulate process restart: copy rows into a fresh SessionStore (no module Maps).
252
+ const restored = createSessionStore();
253
+ for (const [id, row] of store.sessions) {
254
+ restored.sessions.set(id, { ...row, scopes: [...row.scopes] });
255
+ }
256
+ for (const [id, row] of store.refresh) {
257
+ restored.refresh.set(id, { ...row });
258
+ }
259
+
260
+ now += 1_000;
261
+ const rotated = await rotateRefresh(restored, crypto, issued.refreshToken);
262
+ const { verifyAccess } = await import("./sessions.ts");
263
+ const claims = await verifyAccess(restored, crypto.secret, rotated.accessToken, {
264
+ now: () => now,
265
+ audience: "oke-app",
266
+ });
267
+ expect(claims.scopes).toEqual(["notes:write", "notes:read"]);
268
+ expect(claims.aud).toBe("oke-app");
269
+ });
234
270
  });
235
271
 
236
272
  describe("operator plane", () => {
@@ -32,7 +32,7 @@ import {
32
32
  type SessionCrypto,
33
33
  type SessionStore,
34
34
  } from "./sessions.ts";
35
- import { touchRateLimit, type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
35
+ import { type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
36
36
 
37
37
  /** Built-in policy: verified user session (for `/auth/me` and step-up surfaces). */
38
38
  export const AUTH_SESSION_GATE: PolicyGateDecl = gate.policy(
@@ -297,17 +297,8 @@ export function createAuthHttpBindings(
297
297
  out: SessionTokensOut,
298
298
  errors: { AuthFailed, AuthRateLimited },
299
299
  do: async (input) => {
300
- const key = input.email.trim().toLowerCase();
301
- let bag = loginAttempts.get(key);
302
- if (!bag) {
303
- bag = [];
304
- loginAttempts.set(key, bag);
305
- }
306
- if (touchRateLimit(bag, ctx.now()) === "rate_limited") {
307
- return fail("AuthRateLimited", {
308
- reason: "rate_limited",
309
- });
310
- }
300
+ // Rate limit is Gate KV (`signInRate` on the binding) — shared across
301
+ // instances when `drivers.store.kv` is redis. No process-local email bag.
311
302
  const user = await authenticateUser(identities, input.email, input.password);
312
303
  // Enumeration hygiene: identical failure for unknown email / bad password.
313
304
  if (!user) return fail("AuthFailed", { reason: "invalid_credentials" });
@@ -77,6 +77,39 @@ export function normalizeEmail(email: string): string {
77
77
  return email.trim().toLowerCase();
78
78
  }
79
79
 
80
+ /**
81
+ * Find or create a user row keyed by email (magic-link / OTP sign-in).
82
+ *
83
+ * @param store - Identity store
84
+ * @param email - Normalized email
85
+ * @param now - Clock
86
+ */
87
+ export function ensureUserByEmail(
88
+ store: IdentityStore,
89
+ email: string,
90
+ now: number,
91
+ ): UserIdentityRow {
92
+ const existingId = store.byEmail.get(email);
93
+ if (existingId) {
94
+ const existing = store.users.get(existingId);
95
+ if (existing) return existing;
96
+ }
97
+ const id = crypto.randomUUID();
98
+ const user: UserIdentityRow = {
99
+ id,
100
+ email,
101
+ name: email.split("@")[0] || "user",
102
+ emailVerified: true,
103
+ status: "active",
104
+ createdAt: now,
105
+ updatedAt: now,
106
+ extra: {},
107
+ };
108
+ store.users.set(id, user);
109
+ store.byEmail.set(email, id);
110
+ return user;
111
+ }
112
+
80
113
  /**
81
114
  * Create a user with a credential account (provider `credential`).
82
115
  *
package/src/auth/index.ts CHANGED
@@ -69,10 +69,14 @@ export {
69
69
  createVerificationStore,
70
70
  putVerification,
71
71
  findActiveVerification,
72
+ consumeVerification,
73
+ invalidateVerifications,
74
+ wipeSealedOtp,
72
75
  hashChallenge,
73
76
  generateOtp,
74
77
  type VerificationStore,
75
78
  type VerificationRow,
79
+ type OtpChannel,
76
80
  } from "./verification.ts";
77
81
 
78
82
  export { constantTimeEqual } from "./constant-time.ts";
@@ -83,6 +87,7 @@ export {
83
87
  authenticateUser,
84
88
  getUserById,
85
89
  normalizeEmail,
90
+ ensureUserByEmail,
86
91
  IdentityError,
87
92
  type IdentityStore,
88
93
  type UserIdentityRow,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Fail-loud capability checks for the otp() plugin (Tier 1 / Tier 2).
3
+ */
4
+
5
+ import type { ChannelDriver, SmsOtpTransport } from "../drivers/channel-types.ts";
6
+ import type { OtpChannel } from "./verification.ts";
7
+
8
+ /** Config snapshot stored on the otp plugin for boot-time assertion. */
9
+ export interface OtpPluginConfig {
10
+ readonly method: "otp";
11
+ readonly tier: 1 | 2;
12
+ readonly channels?: readonly OtpChannel[];
13
+ }
14
+
15
+ /**
16
+ * Whether an SMS transport exposes provider-managed OTP (structural).
17
+ *
18
+ * @param t - Candidate transport
19
+ */
20
+ export function isSmsOtpTransport(t: unknown): t is SmsOtpTransport {
21
+ if (!t || typeof t !== "object") return false;
22
+ const o = t as Partial<SmsOtpTransport>;
23
+ return typeof o.sendOtp === "function" && typeof o.verifyOtp === "function";
24
+ }
25
+
26
+ /**
27
+ * Find a bound SMS driver with real sendOtp/verifyOtp, or undefined.
28
+ *
29
+ * @param drivers - Bound channel drivers
30
+ */
31
+ export function findOtpSmsDriver(
32
+ drivers: readonly ChannelDriver[],
33
+ ): { readonly driver: ChannelDriver; readonly otp: SmsOtpTransport } | undefined {
34
+ for (const d of drivers) {
35
+ if (isSmsOtpTransport(d.smsTransport)) {
36
+ return { driver: d, otp: d.smsTransport };
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * Whether any bound driver can deliver on `medium`.
44
+ *
45
+ * @param drivers - Bound channel drivers
46
+ * @param medium - OTP channel
47
+ */
48
+ export function driverCoversMedium(drivers: readonly ChannelDriver[], medium: OtpChannel): boolean {
49
+ for (const d of drivers) {
50
+ if (medium === "email" && d.transport) return true;
51
+ if (medium === "sms" && (d.smsTransport || d.channel?.mediums.includes("sms"))) return true;
52
+ if (medium === "whatsapp" && (d.whatsappTransport || d.channel?.mediums.includes("whatsapp"))) {
53
+ return true;
54
+ }
55
+ if (d.channel?.mediums.includes(medium) || d.channel?.mediums.includes("any")) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Assert Tier 1: a Verify-capable SMS driver must be bound.
62
+ *
63
+ * @param drivers - Bound channel drivers
64
+ */
65
+ export function assertOtpTier1Capability(drivers: readonly ChannelDriver[]): void {
66
+ if (findOtpSmsDriver(drivers)) return;
67
+ const sms = drivers.filter((d) => d.smsTransport);
68
+ if (sms.length === 0) {
69
+ throw new Error(
70
+ 'otp({ tier: 1 }): no SMS driver with sendOtp/verifyOtp bound — set drivers.channel.sms to a Verify-capable driver (e.g. "taqnyat"), or switch to otp({ tier: 2, channels: [...] })',
71
+ );
72
+ }
73
+ const id = sms[0]?.id ?? "unknown";
74
+ throw new Error(
75
+ `otp({ tier: 1 }): SMS driver "${id}" does not support provider-managed OTP — bind a Verify-capable driver (e.g. taqnyat), or switch to otp({ tier: 2, channels: [...] })`,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Assert Tier 2: every declared channel has a deliverable driver.
81
+ *
82
+ * @param drivers - Bound channel drivers
83
+ * @param channels - Declared channel order
84
+ */
85
+ export function assertOtpTier2Channels(
86
+ drivers: readonly ChannelDriver[],
87
+ channels: readonly OtpChannel[],
88
+ ): void {
89
+ for (const ch of channels) {
90
+ if (!driverCoversMedium(drivers, ch)) {
91
+ throw new Error(
92
+ `otp({ tier: 2 }): no channel driver covers "${ch}" — configure drivers.channel.${ch === "email" ? "email" : ch === "sms" ? "sms" : "whatsapp"}`,
93
+ );
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Run the matching boot assertion for an otp plugin config snapshot.
100
+ *
101
+ * @param config - Plugin configSnapshot
102
+ * @param drivers - Bound channel drivers
103
+ */
104
+ export function assertOtpPluginCapability(
105
+ config: unknown,
106
+ drivers: readonly ChannelDriver[],
107
+ ): void {
108
+ if (!config || typeof config !== "object") return;
109
+ const c = config as Partial<OtpPluginConfig>;
110
+ if (c.method !== "otp") return;
111
+ if (c.tier === 1) {
112
+ assertOtpTier1Capability(drivers);
113
+ return;
114
+ }
115
+ if (c.tier === 2) {
116
+ const channels = c.channels ?? [];
117
+ assertOtpTier2Channels(drivers, channels);
118
+ }
119
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * OTP seal — HKDF domain separation + AES-GCM round-trip.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { deriveOtpSealKey, OTP_SEAL_HKDF_INFO, sealOtp, unsealOtp } from "./otp-seal.ts";
7
+
8
+ describe("otp-seal", () => {
9
+ test("info constant is the literal oke-otp-seal-v1", () => {
10
+ expect(OTP_SEAL_HKDF_INFO).toBe("oke-otp-seal-v1");
11
+ });
12
+
13
+ test("round-trip seal / unseal", async () => {
14
+ const secret = "test-secret-at-least-16";
15
+ const otp = "482913";
16
+ const blob = await sealOtp(secret, otp);
17
+ expect(blob).not.toContain(otp);
18
+ expect(await unsealOtp(secret, blob)).toBe(otp);
19
+ });
20
+
21
+ test("secret A cannot unseal secret B", async () => {
22
+ const blob = await sealOtp("secret-aaaaaaaaaaaa", "123456");
23
+ await expect(unsealOtp("secret-bbbbbbbbbbbb", blob)).rejects.toThrow();
24
+ });
25
+
26
+ test("deriveOtpSealKey produces an AES-GCM key (not raw secret)", async () => {
27
+ const key = await deriveOtpSealKey("test-secret-at-least-16");
28
+ expect(key.type).toBe("secret");
29
+ expect(key.algorithm).toMatchObject({ name: "AES-GCM" });
30
+ // Non-extractable — raw secret bytes never leave HKDF as exportable AES material.
31
+ expect(key.extractable).toBe(false);
32
+ });
33
+
34
+ test("HKDF info bytes pin domain separation (known key length)", async () => {
35
+ const ikm = new TextEncoder().encode("fixed-secret-for-vector");
36
+ const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
37
+ const bits = await crypto.subtle.deriveBits(
38
+ {
39
+ name: "HKDF",
40
+ hash: "SHA-256",
41
+ salt: new Uint8Array(0),
42
+ info: new TextEncoder().encode(OTP_SEAL_HKDF_INFO),
43
+ },
44
+ baseKey,
45
+ 256,
46
+ );
47
+ expect(new Uint8Array(bits).byteLength).toBe(32);
48
+ // Different info → different bits
49
+ const other = await crypto.subtle.deriveBits(
50
+ {
51
+ name: "HKDF",
52
+ hash: "SHA-256",
53
+ salt: new Uint8Array(0),
54
+ info: new TextEncoder().encode("session-hmac-v1"),
55
+ },
56
+ baseKey,
57
+ 256,
58
+ );
59
+ expect(Buffer.from(bits).equals(Buffer.from(other))).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Domain-separated sealed OTP copy for Tier-2 redelivery.
3
+ *
4
+ * AES-GCM key is **never** the raw auth secret. It is derived via
5
+ * HKDF-SHA-256 with info `oke-otp-seal-v1` so session HMAC material stays
6
+ * cryptographically isolated from seal encryption.
7
+ */
8
+
9
+ /** HKDF `info` — versioned; bump when the seal format changes. */
10
+ export const OTP_SEAL_HKDF_INFO = "oke-otp-seal-v1";
11
+
12
+ const INFO_BYTES = new TextEncoder().encode(OTP_SEAL_HKDF_INFO);
13
+ const EMPTY_SALT = new Uint8Array(0);
14
+
15
+ /**
16
+ * Derive the AES-256-GCM key for OTP sealing from the method secret.
17
+ *
18
+ * @param secret - Root auth secret (`resolveMethodSecret`)
19
+ */
20
+ export async function deriveOtpSealKey(secret: string): Promise<CryptoKey> {
21
+ const ikm = new TextEncoder().encode(secret);
22
+ const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
23
+ return crypto.subtle.deriveKey(
24
+ {
25
+ name: "HKDF",
26
+ hash: "SHA-256",
27
+ salt: EMPTY_SALT,
28
+ info: INFO_BYTES,
29
+ },
30
+ baseKey,
31
+ { name: "AES-GCM", length: 256 },
32
+ false,
33
+ ["encrypt", "decrypt"],
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Seal a plaintext OTP for later redelivery (base64 `IV || ciphertext+tag`).
39
+ *
40
+ * @param secret - Root auth secret
41
+ * @param otp - Raw OTP digits
42
+ */
43
+ export async function sealOtp(secret: string, otp: string): Promise<string> {
44
+ const key = await deriveOtpSealKey(secret);
45
+ const iv = crypto.getRandomValues(new Uint8Array(12));
46
+ const cipher = await crypto.subtle.encrypt(
47
+ { name: "AES-GCM", iv },
48
+ key,
49
+ new TextEncoder().encode(otp),
50
+ );
51
+ const packed = new Uint8Array(iv.length + cipher.byteLength);
52
+ packed.set(iv, 0);
53
+ packed.set(new Uint8Array(cipher), iv.length);
54
+ return Buffer.from(packed).toString("base64");
55
+ }
56
+
57
+ /**
58
+ * Unseal a previously sealed OTP. Call only at the deliberate delivery boundary.
59
+ *
60
+ * @param secret - Root auth secret
61
+ * @param blob - Base64 sealed blob from {@link sealOtp}
62
+ */
63
+ export async function unsealOtp(secret: string, blob: string): Promise<string> {
64
+ const key = await deriveOtpSealKey(secret);
65
+ const packed = Buffer.from(blob, "base64");
66
+ if (packed.byteLength < 13) {
67
+ throw new Error("otp-seal: malformed sealed blob");
68
+ }
69
+ const iv = packed.subarray(0, 12);
70
+ const data = packed.subarray(12);
71
+ const plain = await crypto.subtle.decrypt(
72
+ { name: "AES-GCM", iv: new Uint8Array(iv) },
73
+ key,
74
+ toArrayBuffer(data),
75
+ );
76
+ return new TextDecoder().decode(plain);
77
+ }
78
+
79
+ /** Copy into a real ArrayBuffer for Web Crypto BufferSource typing. */
80
+ function toArrayBuffer(view: Uint8Array): ArrayBuffer {
81
+ const copy = new Uint8Array(view.byteLength);
82
+ copy.set(view);
83
+ return copy.buffer;
84
+ }
@@ -73,6 +73,8 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
73
73
  createdAt: "created_at",
74
74
  expiresAt: "expires_at",
75
75
  lastActiveAt: "last_active_at",
76
+ scopes: "scopes",
77
+ audience: "audience",
76
78
  },
77
79
  refreshToken: {
78
80
  id: "id",
@@ -186,6 +188,7 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
186
188
  value: "TEXT",
187
189
  description: "TEXT",
188
190
  scopes: "TEXT",
191
+ audience: "TEXT",
189
192
  lastUsedAt: "INTEGER",
190
193
  };
191
194
 
@@ -130,11 +130,10 @@ export async function issueSession(
130
130
  createdAt: t,
131
131
  expiresAt: t + absoluteCap,
132
132
  lastActiveAt: t,
133
+ scopes: [...principal.scopes],
134
+ ...(crypto.audience !== undefined ? { audience: crypto.audience } : {}),
133
135
  };
134
136
  store.sessions.set(sessionId, session);
135
- if (crypto.audience !== undefined) {
136
- sessionAudiences.set(sessionId, crypto.audience);
137
- }
138
137
 
139
138
  const refreshRaw = `rt_${cryptoRandomId()}`;
140
139
  const refreshRow: RefreshTokenRow = {
@@ -223,20 +222,15 @@ export async function rotateRefresh(
223
222
  };
224
223
  store.refresh.set(newRefresh.id, newRefresh);
225
224
 
226
- // Recover scopes from the previous access path — stored on session via
227
- // a side map would be ideal; for builtin we re-sign with empty and let
228
- // callers pass scopes through verify. We keep scopes on a claim cache:
229
- const priorScopes = sessionScopes.get(session.id) ?? [];
230
- const priorAud = sessionAudiences.get(session.id);
231
225
  const accessExpiresAt = t + accessTtl;
232
226
  const accessToken = await signAccess(crypto.secret, {
233
227
  sub: session.principalId,
234
228
  plane: session.plane,
235
229
  sid: session.id,
236
- scopes: priorScopes,
230
+ scopes: session.scopes,
237
231
  iat: t,
238
232
  exp: accessExpiresAt,
239
- ...(priorAud !== undefined ? { aud: priorAud } : {}),
233
+ ...(session.audience !== undefined ? { aud: session.audience } : {}),
240
234
  });
241
235
 
242
236
  return {
@@ -247,34 +241,44 @@ export async function rotateRefresh(
247
241
  };
248
242
  }
249
243
 
250
- /** Session id → scopes (access-token material). */
251
- const sessionScopes = new Map<string, string[]>();
252
-
253
- /** Session id → audience stamped at issue time. */
254
- const sessionAudiences = new Map<string, string>();
255
-
256
244
  /**
257
245
  * Remember scopes for refresh rotation (call after {@link issueSession}).
246
+ * Scopes live on the session row — shared with any store hydrate/persist path.
258
247
  *
248
+ * @param store - Session store
259
249
  * @param sessionId - Session id
260
250
  * @param scopes - Scopes
261
251
  */
262
- export function bindSessionScopes(sessionId: string, scopes: Iterable<string>): void {
263
- sessionScopes.set(sessionId, [...scopes]);
252
+ export function bindSessionScopes(
253
+ store: SessionStore,
254
+ sessionId: string,
255
+ scopes: Iterable<string>,
256
+ ): void {
257
+ const session = store.sessions.get(sessionId);
258
+ if (!session) return;
259
+ // Mutate in place — SessionRow is a store-owned mutable row.
260
+ (session as { scopes: string[] }).scopes = [...scopes];
264
261
  }
265
262
 
266
263
  /**
267
264
  * Remember audience for refresh rotation (call after {@link issueSession}).
268
265
  *
266
+ * @param store - Session store
269
267
  * @param sessionId - Session id
270
268
  * @param audience - Audience claim
271
269
  */
272
- export function bindSessionAudience(sessionId: string, audience: string): void {
273
- sessionAudiences.set(sessionId, audience);
270
+ export function bindSessionAudience(
271
+ store: SessionStore,
272
+ sessionId: string,
273
+ audience: string,
274
+ ): void {
275
+ const session = store.sessions.get(sessionId);
276
+ if (!session) return;
277
+ (session as { audience?: string }).audience = audience;
274
278
  }
275
279
 
276
280
  /**
277
- * Issue a session and bind scopes for later rotation.
281
+ * Issue a session with scopes (and optional audience) on the session row.
278
282
  *
279
283
  * @param store - Session store
280
284
  * @param crypto - Crypto
@@ -289,12 +293,7 @@ export async function issueSessionWithScopes(
289
293
  readonly scopes: Iterable<string>;
290
294
  },
291
295
  ): Promise<IssuedSession> {
292
- const issued = await issueSession(store, crypto, principal);
293
- bindSessionScopes(issued.session.id, principal.scopes);
294
- if (crypto.audience !== undefined) {
295
- bindSessionAudience(issued.session.id, crypto.audience);
296
- }
297
- return issued;
296
+ return issueSession(store, crypto, principal);
298
297
  }
299
298
 
300
299
  /**
@@ -121,6 +121,10 @@ export interface SessionRow {
121
121
  expiresAt: number;
122
122
  /** Last activity epoch-ms (idle timeout). Updated on refresh / access touch. */
123
123
  lastActiveAt: number;
124
+ /** Access-token scopes — survives refresh / process restart with the session row. */
125
+ scopes: string[];
126
+ /** Audience stamped at issue (`oke-console` · `oke-mcp` · `oke-app`). */
127
+ audience?: string;
124
128
  }
125
129
 
126
130
  /** Refresh token (hashed at rest); rotation with reuse detection. */