okengine 0.15.2 → 0.17.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 (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Kernel edge profile budget — AGENTS.md / unified-theory §24.
3
- * Limit: < 15 kB gzipped (minified bundle).
3
+ * Limit: < 16 kB gzipped (minified bundle).
4
4
  */
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Durable runner — lazy chunk off Store-only `oke()` construction.
3
+ *
4
+ * `runDurable` statically imports `createFx`; keeping that edge off graphs
5
+ * that never run a durable Flow.
6
+ */
7
+
8
+ export { runDurable } from "../elements/clock/durable.ts";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Parse `{template}#{tenantId}` — lazy chunk off Store-only `oke()` graphs.
3
+ */
4
+
5
+ export { parsePerTenantCronName } from "../elements/clock/declare.ts";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Clock per-tenant row hooks — lazy chunk.
3
+ *
4
+ * A static import from `app.ts` would pin Clock reconcile on every `oke()`
5
+ * graph, including Store-only apps that never enable `gate.auth.tenant`.
6
+ */
7
+
8
+ export { orphanPerTenantCronRows, putPerTenantCronRows } from "../elements/clock/reconcile.ts";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * OKE1014 — kept off the Store-only `oke()` graph.
3
+ *
4
+ * Signal drivers throw this when a Last-Event-ID is missing from the tape.
5
+ * Loaded via computed `import.meta.require` from {@link lookupOkeError}.
6
+ */
7
+
8
+ import type { OkeErrorDefinition } from "./errors.ts";
9
+
10
+ /** Last-Event-ID is missing from the retained live tape. */
11
+ export const LIVE_RESUME_GAP: OkeErrorDefinition = {
12
+ code: 1014,
13
+ cause: 'Cursor "{afterId}" missing on "{signal}".',
14
+ fix: "Reconnect without Last-Event-ID.",
15
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * OKE1015–1017 — kept off the Store-only `oke()` graph.
3
+ *
4
+ * Tenant KV/vault and membership failures. Loaded via computed
5
+ * `import.meta.require` from {@link throwOke} / {@link lookupOkeError}.
6
+ */
7
+
8
+ import type { OkeErrorDefinition } from "./errors.ts";
9
+
10
+ /** Tenant-scoped op ran with no resolved `fx.tenant.id`. */
11
+ export const TENANT_REQUIRED: OkeErrorDefinition = {
12
+ code: 1015,
13
+ cause: "This operation needs a tenant, but none is resolved for this request.",
14
+ fix: "Call fx.auth.switchTenant(id), send a signed tid claim, or pass the tenant header.",
15
+ };
16
+
17
+ /** Client-supplied tenant id is not a real membership. */
18
+ export const TENANT_NOT_MEMBER: OkeErrorDefinition = {
19
+ code: 1016,
20
+ cause: 'The caller is not a member of tenant "{tenant}".',
21
+ fix: "Pick a tenant from fx.auth.listTenants() or add the user as a member.",
22
+ };
23
+
24
+ /** Tenant role mapped an invented or operator-plane scope. */
25
+ export const TENANT_UNKNOWN_SCOPE: OkeErrorDefinition = {
26
+ code: 1017,
27
+ cause: 'Tenant role scope "{scope}" is not a declared application scope.',
28
+ fix: "Use a name from this app's Manifest catalog (never console:*).",
29
+ };
@@ -6,11 +6,23 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import { OKE_ERRORS, OkeError, type OkeErrorDefinition } from "./errors.ts";
9
+ import { OKE_ERRORS, OkeError, lookupOkeError, type OkeErrorDefinition } from "./errors.ts";
10
+ import { LIVE_RESUME_GAP } from "./errors-live-resume.ts";
11
+ import { TENANT_NOT_MEMBER, TENANT_REQUIRED, TENANT_UNKNOWN_SCOPE } from "./errors-tenant.ts";
12
+
13
+ const LAZY_DEFS: readonly OkeErrorDefinition[] = [
14
+ LIVE_RESUME_GAP,
15
+ TENANT_REQUIRED,
16
+ TENANT_NOT_MEMBER,
17
+ TENANT_UNKNOWN_SCOPE,
18
+ ];
10
19
 
11
20
  describe("OKE error-code registry", () => {
12
21
  test("every code is unique", () => {
13
- const codes = Object.values(OKE_ERRORS).map((d) => d.code);
22
+ const codes = [
23
+ ...Object.values(OKE_ERRORS).map((d) => d.code),
24
+ ...LAZY_DEFS.map((d) => d.code),
25
+ ];
14
26
  expect(new Set(codes).size).toBe(codes.length);
15
27
  });
16
28
 
@@ -21,10 +33,16 @@ describe("OKE error-code registry", () => {
21
33
  expect(Number.isInteger(def.code), `${key}.code`).toBe(true);
22
34
  expect(def.code, `${key}.code`).toBeGreaterThan(0);
23
35
  }
36
+ expect(LIVE_RESUME_GAP.cause.trim().length).toBeGreaterThan(0);
37
+ expect(LIVE_RESUME_GAP.fix.trim().length).toBeGreaterThan(0);
38
+ for (const def of LAZY_DEFS) {
39
+ expect(def.cause.trim().length, `${def.code}.cause`).toBeGreaterThan(0);
40
+ expect(def.fix.trim().length, `${def.code}.fix`).toBeGreaterThan(0);
41
+ }
24
42
  });
25
43
 
26
44
  test("OkeError docsUrl matches docs origin /e/{code}", () => {
27
- for (const def of Object.values(OKE_ERRORS) as OkeErrorDefinition[]) {
45
+ for (const def of [...Object.values(OKE_ERRORS), ...LAZY_DEFS] as OkeErrorDefinition[]) {
28
46
  const err = new OkeError(def);
29
47
  expect(err.docsUrl).toBe(`https://oke.omqkhafi.dev/e/${def.code}`);
30
48
  expect(err.message).toContain(err.docsUrl);
@@ -32,4 +50,14 @@ describe("OKE error-code registry", () => {
32
50
  expect(err.message).toContain("→");
33
51
  }
34
52
  });
53
+
54
+ test("lookupOkeError finds the lazy LIVE_RESUME_GAP entry", () => {
55
+ expect(lookupOkeError(1014)).toEqual(LIVE_RESUME_GAP);
56
+ });
57
+
58
+ test("lookupOkeError finds lazy tenant entries", () => {
59
+ expect(lookupOkeError(1015)).toEqual(TENANT_REQUIRED);
60
+ expect(lookupOkeError(1016)).toEqual(TENANT_NOT_MEMBER);
61
+ expect(lookupOkeError(1017)).toEqual(TENANT_UNKNOWN_SCOPE);
62
+ });
35
63
  });
@@ -207,6 +207,15 @@ export const OKE_ERRORS = {
207
207
  cause: "An HTTP flow on {method} {path} has no name.",
208
208
  fix: 'Use `flow("unit.export", {…})` or export it from a `src/flows/<unit>/` file so the tree can stamp `unit.export`.',
209
209
  },
210
+ /**
211
+ * Two live HTTP exposures of the same signal share gates + match shape.
212
+ */
213
+ LIVE_EXPOSURE_DUPLICATE: {
214
+ code: 1013,
215
+ cause:
216
+ 'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
217
+ fix: "Use a different gate or path-param filter, or drop the extra route.",
218
+ },
210
219
  /**
211
220
  * Emit target has no subscriber (unified-theory §21).
212
221
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -233,15 +242,37 @@ export const OKE_ERRORS = {
233
242
  },
234
243
  } as const satisfies Record<string, OkeErrorDefinition>;
235
244
 
245
+ /** Tenant error keys — definitions live in the lazy `errors-tenant` chunk. */
246
+ export type TenantOkeErrorKey = "TENANT_REQUIRED" | "TENANT_NOT_MEMBER" | "TENANT_UNKNOWN_SCOPE";
247
+
248
+ type TenantErrorChunk = {
249
+ readonly TENANT_REQUIRED: OkeErrorDefinition;
250
+ readonly TENANT_NOT_MEMBER: OkeErrorDefinition;
251
+ readonly TENANT_UNKNOWN_SCOPE: OkeErrorDefinition;
252
+ };
253
+
254
+ /**
255
+ * Load OKE1015–1017. Computed stem so Bun.build cannot inline the chunk.
256
+ */
257
+ function loadTenantErrors(): TenantErrorChunk {
258
+ return lazyRequire(import.meta.dir, ["errors", "tenant"].join("-"));
259
+ }
260
+
236
261
  /**
237
262
  * Build and throw a registered framework error.
238
263
  *
239
- * @param key - Key into {@link OKE_ERRORS}
264
+ * @param key - Key into {@link OKE_ERRORS} or a lazy tenant code
240
265
  * @param params - Interpolation params
241
266
  * @returns Never
242
267
  */
243
- export function throwOke(key: keyof typeof OKE_ERRORS, params: OkeErrorParams = {}): never {
244
- throw new OkeError(OKE_ERRORS[key], params);
268
+ export function throwOke(
269
+ key: keyof typeof OKE_ERRORS | TenantOkeErrorKey,
270
+ params: OkeErrorParams = {},
271
+ ): never {
272
+ if (typeof key === "string" && key.startsWith("TENANT_")) {
273
+ throw new OkeError(loadTenantErrors()[key as TenantOkeErrorKey], params);
274
+ }
275
+ throw new OkeError(OKE_ERRORS[key as keyof typeof OKE_ERRORS], params);
245
276
  }
246
277
 
247
278
  /**
@@ -250,6 +281,15 @@ export function throwOke(key: keyof typeof OKE_ERRORS, params: OkeErrorParams =
250
281
  * @param code - Permanent numeric code
251
282
  */
252
283
  export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefined {
284
+ if (code === 1014) {
285
+ return lazyRequire<typeof import("./errors-live-resume.ts")>(
286
+ import.meta.dir,
287
+ ["errors", "live", "resume"].join("-"),
288
+ ).LIVE_RESUME_GAP;
289
+ }
290
+ if (code === 1015) return loadTenantErrors().TENANT_REQUIRED;
291
+ if (code === 1016) return loadTenantErrors().TENANT_NOT_MEMBER;
292
+ if (code === 1017) return loadTenantErrors().TENANT_UNKNOWN_SCOPE;
253
293
  for (const def of Object.values(OKE_ERRORS)) {
254
294
  if (def.code === code) return def;
255
295
  }
@@ -48,13 +48,15 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
48
48
  readonly effects?: Effects;
49
49
  /** Journal every effect call. */
50
50
  readonly durable?: boolean;
51
+ /**
52
+ * Retry the whole `do` body on thrown errors (same journal session when
53
+ * durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
54
+ */
51
55
  /**
52
56
  * Retry the whole `do` body on thrown errors (same journal session when
53
57
  * durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
54
58
  */
55
59
  readonly retry?: FxRetryOptions;
56
- /** Live-query / push result. */
57
- readonly live?: boolean;
58
60
  /**
59
61
  * Cache policy. Read-only flows cache automatically from inferred
60
62
  * effects (`true` / omitted). `false` opts out; a duration string
@@ -89,6 +91,11 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
89
91
  },
90
92
  fx: import("./fx.ts").Fx,
91
93
  ) => unknown | Promise<unknown>;
94
+ /**
95
+ * When `false`, skip tenant-role scope union even if `fx.tenant.id` is set.
96
+ * Default `true` when `gate.auth.tenant` is on.
97
+ */
98
+ readonly tenantScoped?: boolean;
92
99
  /** The behavior. */
93
100
  readonly do: FlowHandler<I, O>;
94
101
  }
@@ -171,8 +178,15 @@ export interface FlowDef<
171
178
  readonly durable: boolean;
172
179
  /** Whole-body retry policy (runtime). */
173
180
  readonly retry: FxRetryOptions | undefined;
174
- /** Live flag. */
175
- readonly live: boolean;
181
+ /**
182
+ * Live signal name when this HTTP flow streams {@link SignalDecl} SSE.
183
+ */
184
+ readonly live: string | undefined;
185
+ /**
186
+ * True when `.live(signal)` is bound to a custom `do` (not synthesized).
187
+ * Boot uniqueness uses `custom:{flowName}` instead of path-param match.
188
+ */
189
+ readonly liveCustomMatch: boolean;
176
190
  /** Cache option. */
177
191
  readonly cache: boolean | string | undefined;
178
192
  /** SLO. */
@@ -181,6 +195,11 @@ export interface FlowDef<
181
195
  readonly plane: FlowPlane | undefined;
182
196
  /** Intentional contract-break acknowledgement for Manifest Diff. */
183
197
  readonly breaking: boolean;
198
+ /**
199
+ * When `false`, this flow is tenant-unaware (no tenant-role scope union).
200
+ * Default `true` when `gate.auth.tenant` is on.
201
+ */
202
+ readonly tenantScoped: boolean | undefined;
184
203
  /**
185
204
  * Optional compensation phase after terminal durable failure.
186
205
  * See {@link FlowOptions.compensate}.
@@ -284,11 +303,13 @@ export function flow(
284
303
  effects: options.effects,
285
304
  durable: options.durable ?? false,
286
305
  retry: options.retry,
287
- live: options.live ?? false,
306
+ live: undefined,
307
+ liveCustomMatch: false,
288
308
  cache: options.cache,
289
309
  slo: options.slo,
290
310
  plane: options.plane,
291
311
  breaking: options.breaking ?? false,
312
+ tenantScoped: options.tenantScoped,
292
313
  compensate: options.compensate as FlowDef["compensate"],
293
314
  do: options.do as FlowHandler,
294
315
  triggers,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Session-only `fx.auth` key methods — issuer derived, never from flow input.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { AttenuationError } from "../auth/attenuation.ts";
7
+ import { createApiKey, createApiKeyStore } from "../auth/api-keys.ts";
8
+ import { isFlowFailure } from "./hooks.ts";
9
+ import { createFx } from "./fx.ts";
10
+
11
+ describe("fx.auth key methods", () => {
12
+ test("createApiKey derives creator from the live session and rejects broader scopes", async () => {
13
+ const store = createApiKeyStore();
14
+ const fx = createFx({
15
+ flow: "keys.create",
16
+ effects: { writes: ["auth:api-keys"] },
17
+ apiKeyStore: store,
18
+ auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: null },
19
+ });
20
+ const created = await fx.auth.createApiKey({ name: "mine", scopes: ["member"] });
21
+ expect(created.key.scopes).toEqual(["member"]);
22
+ expect(created.secret.startsWith("oke_")).toBe(true);
23
+ expect(store.keys.get(created.key.id)?.creatorId).toBe("u1");
24
+
25
+ await expect(fx.auth.createApiKey({ name: "wide", scopes: ["admin"] })).rejects.toBeInstanceOf(
26
+ AttenuationError,
27
+ );
28
+ });
29
+
30
+ test("key-authenticated caller cannot create / list / revoke / rotate / update", async () => {
31
+ const store = createApiKeyStore();
32
+ await createApiKey(store, {
33
+ plane: "user",
34
+ name: "existing",
35
+ scopes: ["member"],
36
+ creatorId: "u1",
37
+ creatorScopes: ["member"],
38
+ id: "key_existing",
39
+ });
40
+ const fx = createFx({
41
+ flow: "keys.as-key",
42
+ effects: { reads: ["auth:api-keys"], writes: ["auth:api-keys"] },
43
+ apiKeyStore: store,
44
+ auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: "key_existing" },
45
+ });
46
+ for (const call of [
47
+ () => fx.auth.createApiKey({ name: "nested", scopes: ["member"] }),
48
+ () => fx.auth.listApiKeys(),
49
+ () => fx.auth.revokeApiKey("key_existing"),
50
+ () => fx.auth.rotateApiKey("key_existing"),
51
+ () => fx.auth.updateApiKey("key_existing", { name: "nope" }),
52
+ ]) {
53
+ try {
54
+ await call();
55
+ throw new Error("expected Forbidden");
56
+ } catch (err) {
57
+ expect(isFlowFailure(err)).toBe(true);
58
+ if (isFlowFailure(err)) expect(err.error.code).toBe("Forbidden");
59
+ }
60
+ }
61
+ });
62
+
63
+ test("list is self-only; cross-user revoke is Forbidden", async () => {
64
+ const store = createApiKeyStore();
65
+ await createApiKey(store, {
66
+ plane: "user",
67
+ name: "theirs",
68
+ scopes: ["member"],
69
+ creatorId: "u2",
70
+ creatorScopes: ["member"],
71
+ id: "key_theirs",
72
+ });
73
+ const fx = createFx({
74
+ flow: "keys.list",
75
+ effects: { reads: ["auth:api-keys"], writes: ["auth:api-keys"] },
76
+ apiKeyStore: store,
77
+ auth: { userId: "u1", scopes: new Set(["member"]), apiKeyId: null },
78
+ });
79
+ expect(await fx.auth.listApiKeys()).toEqual([]);
80
+ try {
81
+ await fx.auth.revokeApiKey("key_theirs");
82
+ throw new Error("expected Forbidden");
83
+ } catch (err) {
84
+ expect(isFlowFailure(err)).toBe(true);
85
+ if (isFlowFailure(err)) expect(err.error.code).toBe("Forbidden");
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `fx.auth` key-management methods — session-only, attenuated, one store.
3
+ */
4
+
5
+ import {
6
+ AUTH_API_KEYS_RESOURCE,
7
+ createApiKey,
8
+ getApiKey,
9
+ listApiKeys,
10
+ revokeApiKey,
11
+ rotateApiKey,
12
+ toApiKeyPublicRow,
13
+ updateApiKey,
14
+ type ApiKeyPublicRow,
15
+ type ApiKeyStore,
16
+ type CreatedApiKey,
17
+ } from "../auth/api-keys.ts";
18
+ import { parseDurationMs } from "../elements/clock/duration.ts";
19
+ import type { EffectKind } from "./effects.ts";
20
+ import { fail, type FlowFailure } from "./errors.ts";
21
+
22
+ /**
23
+ * Identity bag on {@link FxAuth} before key-management methods attach.
24
+ * Passed into {@link createFx} / {@link attach}.
25
+ */
26
+ export interface FxAuthIdentity {
27
+ /** User-plane subject id, or null when unauthenticated. */
28
+ readonly userId: string | null;
29
+ /** Granted scopes for the live principal (may include tenant-role union). */
30
+ readonly scopes: ReadonlySet<string>;
31
+ /**
32
+ * Session / JWT scopes before tenant-role union.
33
+ * `switchTenant` mints from this set so tenant grants never melt into the token.
34
+ */
35
+ readonly sessionScopes?: ReadonlySet<string>;
36
+ /** Whether the identity has completed verification (email / MFA). */
37
+ readonly verified?: boolean;
38
+ /** Authenticating API key id when Bearer was a key secret; otherwise null. */
39
+ readonly apiKeyId?: string | null;
40
+ }
41
+
42
+ /** Options for {@link FxAuth.createApiKey}. */
43
+ export interface FxCreateApiKeyInput {
44
+ readonly name: string;
45
+ readonly scopes: readonly string[];
46
+ readonly expiresIn?: string;
47
+ readonly ipAllowlist?: readonly string[];
48
+ readonly rateLimit?: { max: number; per: string } | null;
49
+ }
50
+
51
+ /** Options for {@link FxAuth.updateApiKey}. */
52
+ export interface FxUpdateApiKeyInput {
53
+ readonly name?: string;
54
+ readonly scopes?: readonly string[];
55
+ readonly expiresIn?: string;
56
+ readonly ipAllowlist?: readonly string[];
57
+ readonly rateLimit?: { max: number; per: string } | null;
58
+ }
59
+
60
+ /** Create result for `fx.auth.createApiKey` / `rotateApiKey`. */
61
+ export interface FxCreatedApiKey {
62
+ readonly key: ApiKeyPublicRow;
63
+ readonly secret: string;
64
+ }
65
+
66
+ /**
67
+ * Session-only key management attached onto {@link FxAuthIdentity}.
68
+ * Methods throw {@link FlowFailure} values (`Unauthorized` / `Forbidden`).
69
+ */
70
+ export interface FxAuthKeyMethods {
71
+ createApiKey(input: FxCreateApiKeyInput): Promise<FxCreatedApiKey>;
72
+ listApiKeys(): Promise<readonly ApiKeyPublicRow[]>;
73
+ revokeApiKey(id: string): Promise<ApiKeyPublicRow>;
74
+ rotateApiKey(id: string): Promise<FxCreatedApiKey>;
75
+ updateApiKey(id: string, input: FxUpdateApiKeyInput): Promise<ApiKeyPublicRow>;
76
+ }
77
+
78
+ /** Dependencies for {@link attach}. */
79
+ export interface AttachAuthKeyMethodsOptions {
80
+ readonly auth: FxAuthIdentity;
81
+ readonly store: ApiKeyStore | undefined;
82
+ readonly now: () => number;
83
+ readonly gated: <T>(kind: EffectKind, resource: string, body: () => T | Promise<T>) => Promise<T>;
84
+ }
85
+
86
+ function sessionOnly(auth: FxAuthIdentity): FlowFailure | null {
87
+ if (auth.apiKeyId) {
88
+ return fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "session_only" });
89
+ }
90
+ if (!auth.userId) {
91
+ return fail("Unauthorized", {});
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function requireStore(store: ApiKeyStore | undefined): ApiKeyStore {
97
+ if (!store) {
98
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "no_store" });
99
+ }
100
+ return store;
101
+ }
102
+
103
+ function requireOwned(
104
+ store: ApiKeyStore,
105
+ id: string,
106
+ userId: string,
107
+ ): ReturnType<typeof getApiKey> {
108
+ const row = getApiKey(store, id);
109
+ if (!row || row.creatorId !== userId) {
110
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
111
+ }
112
+ return row;
113
+ }
114
+
115
+ function expiresAtFrom(expiresIn: string | undefined, now: () => number): number | undefined {
116
+ if (expiresIn === undefined) return undefined;
117
+ return now() + parseDurationMs(expiresIn);
118
+ }
119
+
120
+ /**
121
+ * Attach create / list / revoke / rotate / update onto a live identity bag.
122
+ *
123
+ * @param options - Auth bag + store + capability gate
124
+ */
125
+ export function attach(options: AttachAuthKeyMethodsOptions): FxAuthIdentity & FxAuthKeyMethods {
126
+ const { auth, now, gated } = options;
127
+
128
+ const methods: FxAuthKeyMethods = {
129
+ createApiKey(input) {
130
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
131
+ const denied = sessionOnly(auth);
132
+ if (denied) throw denied;
133
+ const store = requireStore(options.store);
134
+ const created: CreatedApiKey = await createApiKey(store, {
135
+ plane: "user",
136
+ name: input.name,
137
+ scopes: input.scopes,
138
+ creatorId: auth.userId!,
139
+ creatorScopes: auth.scopes,
140
+ expiresAt: expiresAtFrom(input.expiresIn, now) ?? null,
141
+ rateLimit: input.rateLimit ?? null,
142
+ ipAllowlist: input.ipAllowlist,
143
+ now,
144
+ });
145
+ return { key: toApiKeyPublicRow(created.row), secret: created.secret };
146
+ });
147
+ },
148
+ listApiKeys() {
149
+ return gated("read", AUTH_API_KEYS_RESOURCE, async () => {
150
+ const denied = sessionOnly(auth);
151
+ if (denied) throw denied;
152
+ const store = requireStore(options.store);
153
+ return listApiKeys(store, auth.userId!).map(toApiKeyPublicRow);
154
+ });
155
+ },
156
+ revokeApiKey(id) {
157
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
158
+ const denied = sessionOnly(auth);
159
+ if (denied) throw denied;
160
+ const store = requireStore(options.store);
161
+ requireOwned(store, id, auth.userId!);
162
+ const row = revokeApiKey(store, id, now);
163
+ if (!row) throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
164
+ return toApiKeyPublicRow(row);
165
+ });
166
+ },
167
+ rotateApiKey(id) {
168
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
169
+ const denied = sessionOnly(auth);
170
+ if (denied) throw denied;
171
+ const store = requireStore(options.store);
172
+ requireOwned(store, id, auth.userId!);
173
+ const rotated = await rotateApiKey(store, id, { now });
174
+ if (!rotated)
175
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
176
+ return { key: toApiKeyPublicRow(rotated.row), secret: rotated.secret };
177
+ });
178
+ },
179
+ updateApiKey(id, input) {
180
+ return gated("write", AUTH_API_KEYS_RESOURCE, async () => {
181
+ const denied = sessionOnly(auth);
182
+ if (denied) throw denied;
183
+ const store = requireStore(options.store);
184
+ const existing = requireOwned(store, id, auth.userId!);
185
+ if (!existing)
186
+ throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
187
+ const ceiling = new Set([...existing.creatorScopes].filter((s) => auth.scopes.has(s)));
188
+ const row = updateApiKey(store, id, {
189
+ ceiling,
190
+ name: input.name,
191
+ scopes: input.scopes,
192
+ expiresAt: expiresAtFrom(input.expiresIn, now),
193
+ rateLimit: input.rateLimit,
194
+ ipAllowlist: input.ipAllowlist,
195
+ });
196
+ if (!row) throw fail("Forbidden", { gate: AUTH_API_KEYS_RESOURCE, reason: "not_owner" });
197
+ return toApiKeyPublicRow(row);
198
+ });
199
+ },
200
+ };
201
+
202
+ return Object.assign(auth, methods);
203
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Session-only tenant methods — switchTenant mints a new family; listTenants refuses keys.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createSessionStore, issueSession, verifyAccess } from "../auth/sessions.ts";
7
+ import { createTenant, createTenantStore } from "../auth/tenants.ts";
8
+ import { isFlowFailure } from "./hooks.ts";
9
+ import { createFx } from "./fx.ts";
10
+
11
+ const SECRET = "tenant-methods-test-secret";
12
+
13
+ describe("fx.auth tenant methods", () => {
14
+ test("listTenants is session-only", async () => {
15
+ const store = createTenantStore();
16
+ await createTenant(store, { name: "Acme", createdBy: "u1", id: "t1" });
17
+ const asKey = createFx({
18
+ flow: "tenants.list",
19
+ effects: { reads: ["auth:tenants"] },
20
+ tenantStore: store,
21
+ tenantEnabled: true,
22
+ auth: { userId: "u1", scopes: new Set(), apiKeyId: "key_1" },
23
+ });
24
+ try {
25
+ await asKey.auth.listTenants();
26
+ throw new Error("expected Forbidden");
27
+ } catch (err) {
28
+ expect(isFlowFailure(err)).toBe(true);
29
+ if (isFlowFailure(err)) {
30
+ expect(err.error.code).toBe("Forbidden");
31
+ expect(err.error.data).toEqual({ gate: "auth:tenants", reason: "session_only" });
32
+ }
33
+ }
34
+
35
+ const asSession = createFx({
36
+ flow: "tenants.list.session",
37
+ effects: { reads: ["auth:tenants"] },
38
+ tenantStore: store,
39
+ tenantEnabled: true,
40
+ auth: { userId: "u1", scopes: new Set(), apiKeyId: null },
41
+ });
42
+ const listed = await asSession.auth.listTenants();
43
+ expect(listed.map((t) => t.id)).toEqual(["t1"]);
44
+ });
45
+
46
+ test("switchTenant issues a new family and copies tid without melting tenant scopes", async () => {
47
+ const tenantStore = createTenantStore();
48
+ await createTenant(tenantStore, { name: "Acme", createdBy: "u1", id: "t1" });
49
+ await createTenant(tenantStore, { name: "Globex", createdBy: "u1", id: "t2" });
50
+ const sessions = createSessionStore();
51
+ const crypto = { secret: SECRET };
52
+ const first = await issueSession(sessions, crypto, {
53
+ id: "u1",
54
+ plane: "user",
55
+ scopes: ["member"],
56
+ tenantId: "t1",
57
+ });
58
+
59
+ const liveScopes = new Set(["member", "booking:create"]);
60
+ const fx = createFx({
61
+ flow: "tenants.switch",
62
+ effects: { writes: ["auth:tenants"] },
63
+ tenantStore,
64
+ tenantEnabled: true,
65
+ sessions,
66
+ sessionCrypto: crypto,
67
+ auth: {
68
+ userId: "u1",
69
+ scopes: liveScopes,
70
+ sessionScopes: new Set(["member"]),
71
+ apiKeyId: null,
72
+ },
73
+ });
74
+ const switched = await fx.auth.switchTenant("t2");
75
+ expect(switched.session.familyId).not.toBe(first.session.familyId);
76
+ expect(switched.session.id).not.toBe(first.session.id);
77
+
78
+ const claims = await verifyAccess(sessions, SECRET, switched.accessToken);
79
+ expect(claims.tid).toBe("t2");
80
+ expect(claims.scopes).toEqual(["member"]);
81
+ expect(claims.sid).toBe(switched.session.id);
82
+
83
+ const firstStill = await verifyAccess(sessions, SECRET, first.accessToken);
84
+ expect(firstStill.tid).toBe("t1");
85
+ expect(firstStill.sid).toBe(first.session.id);
86
+ });
87
+ });