@kanzo-tech/auth 0.2.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 (90) hide show
  1. package/README.md +103 -0
  2. package/dist/auth-context.d.ts +13 -0
  3. package/dist/auth-context.d.ts.map +1 -0
  4. package/dist/auth-context.js +7 -0
  5. package/dist/auth-context.js.map +1 -0
  6. package/dist/auth-fetch.d.ts +32 -0
  7. package/dist/auth-fetch.d.ts.map +1 -0
  8. package/dist/auth-fetch.js +20 -0
  9. package/dist/auth-fetch.js.map +1 -0
  10. package/dist/auth-provider.d.ts +12 -0
  11. package/dist/auth-provider.d.ts.map +1 -0
  12. package/dist/auth-provider.js +35 -0
  13. package/dist/auth-provider.js.map +1 -0
  14. package/dist/bff-auth.d.ts +32 -0
  15. package/dist/bff-auth.d.ts.map +1 -0
  16. package/dist/bff-auth.js +70 -0
  17. package/dist/bff-auth.js.map +1 -0
  18. package/dist/browser.d.ts +61 -0
  19. package/dist/browser.d.ts.map +1 -0
  20. package/dist/browser.js +107 -0
  21. package/dist/browser.js.map +1 -0
  22. package/dist/can.d.ts +25 -0
  23. package/dist/can.d.ts.map +1 -0
  24. package/dist/can.js +12 -0
  25. package/dist/can.js.map +1 -0
  26. package/dist/claims.d.ts +50 -0
  27. package/dist/claims.d.ts.map +1 -0
  28. package/dist/claims.js +56 -0
  29. package/dist/claims.js.map +1 -0
  30. package/dist/cookie-session.d.ts +28 -0
  31. package/dist/cookie-session.d.ts.map +1 -0
  32. package/dist/cookie-session.js +51 -0
  33. package/dist/cookie-session.js.map +1 -0
  34. package/dist/gate.d.ts +16 -0
  35. package/dist/gate.d.ts.map +1 -0
  36. package/dist/gate.js +17 -0
  37. package/dist/gate.js.map +1 -0
  38. package/dist/host.d.ts +22 -0
  39. package/dist/host.d.ts.map +1 -0
  40. package/dist/host.js +10 -0
  41. package/dist/host.js.map +1 -0
  42. package/dist/index.d.ts +64 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +28 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/issuer.d.ts +92 -0
  47. package/dist/issuer.d.ts.map +1 -0
  48. package/dist/issuer.js +42 -0
  49. package/dist/issuer.js.map +1 -0
  50. package/dist/next-middleware.d.ts +17 -0
  51. package/dist/next-middleware.d.ts.map +1 -0
  52. package/dist/next-middleware.js +21 -0
  53. package/dist/next-middleware.js.map +1 -0
  54. package/dist/next-routes.d.ts +19 -0
  55. package/dist/next-routes.d.ts.map +1 -0
  56. package/dist/next-routes.js +65 -0
  57. package/dist/next-routes.js.map +1 -0
  58. package/dist/next-session.d.ts +44 -0
  59. package/dist/next-session.d.ts.map +1 -0
  60. package/dist/next-session.js +11 -0
  61. package/dist/next-session.js.map +1 -0
  62. package/dist/next.d.ts +42 -0
  63. package/dist/next.d.ts.map +1 -0
  64. package/dist/next.js +9 -0
  65. package/dist/next.js.map +1 -0
  66. package/dist/server.d.ts +53 -0
  67. package/dist/server.d.ts.map +1 -0
  68. package/dist/server.js +157 -0
  69. package/dist/server.js.map +1 -0
  70. package/dist/single-flight.d.ts +15 -0
  71. package/dist/single-flight.d.ts.map +1 -0
  72. package/dist/single-flight.js +10 -0
  73. package/dist/single-flight.js.map +1 -0
  74. package/dist/store.d.ts +52 -0
  75. package/dist/store.d.ts.map +1 -0
  76. package/dist/store.js +20 -0
  77. package/dist/store.js.map +1 -0
  78. package/dist/types.d.ts +118 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +9 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/use-organization.d.ts +22 -0
  83. package/dist/use-organization.d.ts.map +1 -0
  84. package/dist/use-organization.js +20 -0
  85. package/dist/use-organization.js.map +1 -0
  86. package/dist/use-session.d.ts +13 -0
  87. package/dist/use-session.d.ts.map +1 -0
  88. package/dist/use-session.js +19 -0
  89. package/dist/use-session.js.map +1 -0
  90. package/package.json +135 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"can.d.ts","sourceRoot":"","sources":["../src/can.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD;;;;;;;GAOG;AAEH,6FAA6F;AAC7F,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GACZ,YAAY,GAAG,SAAS,CAE1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,GAAG,CACjB,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAIT"}
package/dist/can.js ADDED
@@ -0,0 +1,12 @@
1
+ function u(r, f) {
2
+ return r == null ? void 0 : r.organizations.find((n) => n.alias === f);
3
+ }
4
+ function a(r, f, n) {
5
+ var t;
6
+ return r ? n === void 0 ? r.roles.includes(f) : ((t = u(r, n)) == null ? void 0 : t.roles.includes(f)) ?? !1 : !1;
7
+ }
8
+ export {
9
+ a as can,
10
+ u as organizationOf
11
+ };
12
+ //# sourceMappingURL=can.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"can.js","sources":["../src/can.ts"],"sourcesContent":["import type { Organization, Session } from \"./types\";\n\n/**\n * The role predicate, and the lookup underneath it.\n *\n * **What this decides is what to draw, never what to allow.** The roles a client holds are a copy,\n * and a copy is something an attacker controls the moment it reaches the browser: `can` hides a\n * button, and the resource server — validating the access token it was sent — is what actually\n * refuses the request behind it. A product that gates only here has not gated anything.\n */\n\n/** The organization by that alias, or `undefined` for one this person does not belong to. */\nexport function organizationOf(\n session: Session | null | undefined,\n alias: string,\n): Organization | undefined {\n return session?.organizations.find((org) => org.alias === alias);\n}\n\n/**\n * Does this session hold `role`?\n *\n * With an `organization`, the question is asked *inside* it — the person's roles there, which is a\n * different set from their realm and client roles and is deliberately not merged with them. A role\n * held in one organization says nothing about another, and the day those two sets are unioned for\n * convenience is the day one organization's owner is every organization's owner.\n *\n * Closed by default: no session, or no membership of the named organization, is `false` rather than\n * an error. There is no hierarchy here either — that `owner` outranks `member` is a fact about a\n * product, so a product spells it out: `can(s, \"owner\", org) || can(s, \"member\", org)`.\n */\nexport function can(\n session: Session | null | undefined,\n role: string,\n organization?: string,\n): boolean {\n if (!session) return false;\n if (organization === undefined) return session.roles.includes(role);\n return organizationOf(session, organization)?.roles.includes(role) ?? false;\n}\n"],"names":["organizationOf","session","alias","org","can","role","organization","_a"],"mappings":"AAYO,SAASA,EACdC,GACAC,GAC0B;AAC1B,SAAOD,KAAA,gBAAAA,EAAS,cAAc,KAAK,CAACE,MAAQA,EAAI,UAAUD;AAC5D;AAcO,SAASE,EACdH,GACAI,GACAC,GACS;AAvBJ,MAAAC;AAwBL,SAAKN,IACDK,MAAiB,SAAkBL,EAAQ,MAAM,SAASI,CAAI,MAC3DE,IAAAP,EAAeC,GAASK,CAAY,MAApC,gBAAAC,EAAuC,MAAM,SAASF,OAAS,KAFjD;AAGvB;"}
@@ -0,0 +1,50 @@
1
+ import { Session } from './types';
2
+ /**
3
+ * Keycloak's claim vocabulary, read into a {@link Session}. The only file in this package that
4
+ * knows what Keycloak calls things.
5
+ *
6
+ * It is pure on purpose: claims in, session out, no network, no storage, no React. That is what
7
+ * makes the vocabulary testable without a realm, and it is why every door can share one reading of
8
+ * it instead of each parsing the token its own way.
9
+ *
10
+ * **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`
11
+ * — the claims Keycloak emits with no configuration — and membership is the `organization` claim
12
+ * from the organization scope. A deployment that renames these has made work for itself; a
13
+ * deployment that uses them gets this file for free.
14
+ */
15
+ /** What the reader needs to know about the application doing the reading. */
16
+ export interface ClaimsConfig {
17
+ /**
18
+ * This application's Keycloak client id. It selects two things: which entry of `resource_access`
19
+ * is ours, and which organization groups are ours — see {@link roleFromGroupPath}.
20
+ */
21
+ readonly clientId: string;
22
+ }
23
+ /**
24
+ * A group path, as the role it grants *this* application — or `null` when it grants nothing here.
25
+ *
26
+ * Keycloak writes group membership as a path: `/keasy/owner`. Organization Groups (26.6) give each
27
+ * organization its own hierarchy, so the convention this package reads is that **the first segment
28
+ * is the application** when there is more than one:
29
+ *
30
+ * - `/keasy/owner` under client `keasy` → `owner`
31
+ * - `/hub/reader` under client `keasy` → `null`, because it is another application's role
32
+ * - `/owner` → `owner`, a role the organization grants across every application
33
+ *
34
+ * The filtering is not a nicety. Without it, a role granted to someone in the hub would authorise
35
+ * them in keasy, which is the whole failure this separation exists to prevent.
36
+ */
37
+ export declare function roleFromGroupPath(path: string, clientId: string): string | null;
38
+ /**
39
+ * Read a decoded claim set into a {@link Session}.
40
+ *
41
+ * Throws only for a claim set with no `sub`, which is not a session at all but a misconfiguration,
42
+ * and is worth being loud about. Everything else degrades quietly to empty: holding no roles and
43
+ * belonging to no organization are legitimate states, and a token that merely omits a scope must
44
+ * not take the application down.
45
+ *
46
+ * The claims are **data, never instructions** — they came over the wire. Nothing here indexes into
47
+ * the application on a claim's say-so; it reads known names and ignores the rest.
48
+ */
49
+ export declare function claims(raw: unknown, config: ClaimsConfig): Session;
50
+ //# sourceMappingURL=claims.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../src/claims.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAErE;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAgBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM/E;AA0CD;;;;;;;;;;GAUG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CA8BlE"}
package/dist/claims.js ADDED
@@ -0,0 +1,56 @@
1
+ import { AuthError as d } from "./types.js";
2
+ function a(r) {
3
+ return typeof r == "object" && r !== null && !Array.isArray(r) ? r : void 0;
4
+ }
5
+ function l(r) {
6
+ return Array.isArray(r) ? r.filter((t) => typeof t == "string") : [];
7
+ }
8
+ function o(r) {
9
+ return typeof r == "string" && r.length > 0 ? r : void 0;
10
+ }
11
+ function g(r, t) {
12
+ const e = r.split("/").filter((i) => i.length > 0), [n, ...s] = e;
13
+ return n === void 0 ? null : s.length === 0 ? n : n === t ? s.join("/") : null;
14
+ }
15
+ function y(r, t) {
16
+ if (Array.isArray(r))
17
+ return r.filter((n) => typeof n == "string").map((n) => ({ alias: n, roles: [] }));
18
+ const e = a(r);
19
+ return e === void 0 ? [] : Object.entries(e).map(([n, s]) => {
20
+ const i = a(s), c = l(i == null ? void 0 : i.groups).map((u) => g(u, t)).filter((u) => u !== null);
21
+ return { alias: n, id: o(i == null ? void 0 : i.id), roles: c };
22
+ });
23
+ }
24
+ function A(r) {
25
+ const t = o(r.name);
26
+ if (t !== void 0) return t;
27
+ const e = [o(r.given_name), o(r.family_name)].filter(
28
+ (n) => n !== void 0
29
+ );
30
+ return e.length > 0 ? e.join(" ") : void 0;
31
+ }
32
+ function b(r, t) {
33
+ var f, m, p;
34
+ const e = a(r) ?? {}, n = o(e.sub);
35
+ if (n === void 0)
36
+ throw new d("claims.no-subject", "the claim set carries no `sub`, so it names nobody");
37
+ const s = l((f = a(e.realm_access)) == null ? void 0 : f.roles), i = l(
38
+ (p = a((m = a(e.resource_access)) == null ? void 0 : m[t.clientId])) == null ? void 0 : p.roles
39
+ ), c = e.exp, u = typeof c == "number" && Number.isFinite(c) ? c * 1e3 : 0;
40
+ return {
41
+ user: {
42
+ id: n,
43
+ email: o(e.email),
44
+ name: A(e),
45
+ username: o(e.preferred_username)
46
+ },
47
+ roles: [.../* @__PURE__ */ new Set([...s, ...i])],
48
+ organizations: y(e.organization, t.clientId),
49
+ expiresAt: u
50
+ };
51
+ }
52
+ export {
53
+ b as claims,
54
+ g as roleFromGroupPath
55
+ };
56
+ //# sourceMappingURL=claims.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.js","sources":["../src/claims.ts"],"sourcesContent":["import { AuthError, type Organization, type Session } from \"./types\";\n\n/**\n * Keycloak's claim vocabulary, read into a {@link Session}. The only file in this package that\n * knows what Keycloak calls things.\n *\n * It is pure on purpose: claims in, session out, no network, no storage, no React. That is what\n * makes the vocabulary testable without a realm, and it is why every door can share one reading of\n * it instead of each parsing the token its own way.\n *\n * **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`\n * — the claims Keycloak emits with no configuration — and membership is the `organization` claim\n * from the organization scope. A deployment that renames these has made work for itself; a\n * deployment that uses them gets this file for free.\n */\n\n/** What the reader needs to know about the application doing the reading. */\nexport interface ClaimsConfig {\n /**\n * This application's Keycloak client id. It selects two things: which entry of `resource_access`\n * is ours, and which organization groups are ours — see {@link roleFromGroupPath}.\n */\n readonly clientId: string;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | undefined {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : undefined;\n}\n\nfunction asStrings(value: unknown): string[] {\n return Array.isArray(value) ? value.filter((v): v is string => typeof v === \"string\") : [];\n}\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n}\n\n/**\n * A group path, as the role it grants *this* application — or `null` when it grants nothing here.\n *\n * Keycloak writes group membership as a path: `/keasy/owner`. Organization Groups (26.6) give each\n * organization its own hierarchy, so the convention this package reads is that **the first segment\n * is the application** when there is more than one:\n *\n * - `/keasy/owner` under client `keasy` → `owner`\n * - `/hub/reader` under client `keasy` → `null`, because it is another application's role\n * - `/owner` → `owner`, a role the organization grants across every application\n *\n * The filtering is not a nicety. Without it, a role granted to someone in the hub would authorise\n * them in keasy, which is the whole failure this separation exists to prevent.\n */\nexport function roleFromGroupPath(path: string, clientId: string): string | null {\n const segments = path.split(\"/\").filter((s) => s.length > 0);\n const [first, ...rest] = segments;\n if (first === undefined) return null;\n if (rest.length === 0) return first;\n return first === clientId ? rest.join(\"/\") : null;\n}\n\n/**\n * The `organization` claim, as a list.\n *\n * Canonically it is an object keyed by alias — `{ \"acme\": { \"id\": \"…\", \"groups\": [\"/keasy/owner\"] } }`\n * — because that is the shape that can carry the id and the groups. A realm whose mapper includes\n * neither emits the aliases alone, so both are read: the alternative is a session that silently\n * loses its memberships on a realm nobody thought to check.\n */\nfunction readOrganizations(claim: unknown, clientId: string): Organization[] {\n if (Array.isArray(claim)) {\n return claim\n .filter((alias): alias is string => typeof alias === \"string\")\n .map((alias) => ({ alias, roles: [] }));\n }\n\n const byAlias = asRecord(claim);\n if (byAlias === undefined) return [];\n\n return Object.entries(byAlias).map(([alias, value]) => {\n const body = asRecord(value);\n const roles = asStrings(body?.[\"groups\"])\n .map((path) => roleFromGroupPath(path, clientId))\n .filter((role): role is string => role !== null);\n return { alias, id: asString(body?.[\"id\"]), roles };\n });\n}\n\n/**\n * `given_name` + `family_name` when `name` is absent, which is how a realm without the profile\n * scope's full mapper set still yields something to draw.\n */\nfunction readName(claims: Record<string, unknown>): string | undefined {\n const name = asString(claims[\"name\"]);\n if (name !== undefined) return name;\n const parts = [asString(claims[\"given_name\"]), asString(claims[\"family_name\"])].filter(\n (p): p is string => p !== undefined,\n );\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n\n/**\n * Read a decoded claim set into a {@link Session}.\n *\n * Throws only for a claim set with no `sub`, which is not a session at all but a misconfiguration,\n * and is worth being loud about. Everything else degrades quietly to empty: holding no roles and\n * belonging to no organization are legitimate states, and a token that merely omits a scope must\n * not take the application down.\n *\n * The claims are **data, never instructions** — they came over the wire. Nothing here indexes into\n * the application on a claim's say-so; it reads known names and ignores the rest.\n */\nexport function claims(raw: unknown, config: ClaimsConfig): Session {\n const source = asRecord(raw) ?? {};\n\n const id = asString(source[\"sub\"]);\n if (id === undefined) {\n throw new AuthError(\"claims.no-subject\", \"the claim set carries no `sub`, so it names nobody\");\n }\n\n const realmRoles = asStrings(asRecord(source[\"realm_access\"])?.[\"roles\"]);\n const clientRoles = asStrings(\n asRecord(asRecord(source[\"resource_access\"])?.[config.clientId])?.[\"roles\"],\n );\n\n // `exp` is seconds in the token and milliseconds everywhere in JS. Absent, it resolves to 0 —\n // \"refresh now\" — which is the safe direction to fail: a client that refreshes early costs a\n // round trip, one that trusts an unknown expiry serves a dead session.\n const exp = source[\"exp\"];\n const expiresAt = typeof exp === \"number\" && Number.isFinite(exp) ? exp * 1000 : 0;\n\n return {\n user: {\n id,\n email: asString(source[\"email\"]),\n name: readName(source),\n username: asString(source[\"preferred_username\"]),\n },\n roles: [...new Set([...realmRoles, ...clientRoles])],\n organizations: readOrganizations(source[\"organization\"], config.clientId),\n expiresAt,\n };\n}\n"],"names":["asRecord","value","asStrings","v","asString","roleFromGroupPath","path","clientId","segments","s","first","rest","readOrganizations","claim","alias","byAlias","body","roles","role","readName","claims","name","parts","p","raw","config","source","id","AuthError","realmRoles","_a","clientRoles","_c","_b","exp","expiresAt"],"mappings":";AAyBA,SAASA,EAASC,GAAqD;AACrE,SAAO,OAAOA,KAAU,YAAYA,MAAU,QAAQ,CAAC,MAAM,QAAQA,CAAK,IACrEA,IACD;AACN;AAEA,SAASC,EAAUD,GAA0B;AAC3C,SAAO,MAAM,QAAQA,CAAK,IAAIA,EAAM,OAAO,CAACE,MAAmB,OAAOA,KAAM,QAAQ,IAAI,CAAA;AAC1F;AAEA,SAASC,EAASH,GAAoC;AACpD,SAAO,OAAOA,KAAU,YAAYA,EAAM,SAAS,IAAIA,IAAQ;AACjE;AAgBO,SAASI,EAAkBC,GAAcC,GAAiC;AAC/E,QAAMC,IAAWF,EAAK,MAAM,GAAG,EAAE,OAAO,CAACG,MAAMA,EAAE,SAAS,CAAC,GACrD,CAACC,GAAO,GAAGC,CAAI,IAAIH;AACzB,SAAIE,MAAU,SAAkB,OAC5BC,EAAK,WAAW,IAAUD,IACvBA,MAAUH,IAAWI,EAAK,KAAK,GAAG,IAAI;AAC/C;AAUA,SAASC,EAAkBC,GAAgBN,GAAkC;AAC3E,MAAI,MAAM,QAAQM,CAAK;AACrB,WAAOA,EACJ,OAAO,CAACC,MAA2B,OAAOA,KAAU,QAAQ,EAC5D,IAAI,CAACA,OAAW,EAAE,OAAAA,GAAO,OAAO,CAAA,IAAK;AAG1C,QAAMC,IAAUf,EAASa,CAAK;AAC9B,SAAIE,MAAY,SAAkB,CAAA,IAE3B,OAAO,QAAQA,CAAO,EAAE,IAAI,CAAC,CAACD,GAAOb,CAAK,MAAM;AACrD,UAAMe,IAAOhB,EAASC,CAAK,GACrBgB,IAAQf,EAAUc,KAAA,gBAAAA,EAAO,MAAS,EACrC,IAAI,CAACV,MAASD,EAAkBC,GAAMC,CAAQ,CAAC,EAC/C,OAAO,CAACW,MAAyBA,MAAS,IAAI;AACjD,WAAO,EAAE,OAAAJ,GAAO,IAAIV,EAASY,KAAA,gBAAAA,EAAO,EAAK,GAAG,OAAAC,EAAA;AAAA,EAC9C,CAAC;AACH;AAMA,SAASE,EAASC,GAAqD;AACrE,QAAMC,IAAOjB,EAASgB,EAAO,IAAO;AACpC,MAAIC,MAAS,OAAW,QAAOA;AAC/B,QAAMC,IAAQ,CAAClB,EAASgB,EAAO,UAAa,GAAGhB,EAASgB,EAAO,WAAc,CAAC,EAAE;AAAA,IAC9E,CAACG,MAAmBA,MAAM;AAAA,EAAA;AAE5B,SAAOD,EAAM,SAAS,IAAIA,EAAM,KAAK,GAAG,IAAI;AAC9C;AAaO,SAASF,EAAOI,GAAcC,GAA+B;;AAClE,QAAMC,IAAS1B,EAASwB,CAAG,KAAK,CAAA,GAE1BG,IAAKvB,EAASsB,EAAO,GAAM;AACjC,MAAIC,MAAO;AACT,UAAM,IAAIC,EAAU,qBAAqB,oDAAoD;AAG/F,QAAMC,IAAa3B,GAAU4B,IAAA9B,EAAS0B,EAAO,YAAe,MAA/B,gBAAAI,EAAmC,KAAQ,GAClEC,IAAc7B;AAAA,KAClB8B,IAAAhC,GAASiC,IAAAjC,EAAS0B,EAAO,eAAkB,MAAlC,gBAAAO,EAAsCR,EAAO,SAAS,MAA/D,gBAAAO,EAAmE;AAAA,EAAO,GAMtEE,IAAMR,EAAO,KACbS,IAAY,OAAOD,KAAQ,YAAY,OAAO,SAASA,CAAG,IAAIA,IAAM,MAAO;AAEjF,SAAO;AAAA,IACL,MAAM;AAAA,MACJ,IAAAP;AAAA,MACA,OAAOvB,EAASsB,EAAO,KAAQ;AAAA,MAC/B,MAAMP,EAASO,CAAM;AAAA,MACrB,UAAUtB,EAASsB,EAAO,kBAAqB;AAAA,IAAA;AAAA,IAEjD,OAAO,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAGG,GAAY,GAAGE,CAAW,CAAC,CAAC;AAAA,IACnD,eAAenB,EAAkBc,EAAO,cAAiBD,EAAO,QAAQ;AAAA,IACxE,WAAAU;AAAA,EAAA;AAEJ;"}
@@ -0,0 +1,28 @@
1
+ export interface SealedCookieConfig {
2
+ /**
3
+ * The name **after** the `__Host-` prefix, which this module adds. A caller cannot decline it:
4
+ * the prefix is the only cookie attribute a browser enforces on our behalf.
5
+ */
6
+ readonly name: string;
7
+ /**
8
+ * The sealing secret. Any length — it is hashed to the 256-bit key — but it is a *secret*, not a
9
+ * password: generate it, do not choose it.
10
+ */
11
+ readonly secret: string | Uint8Array;
12
+ /** Seconds. It is both the cookie's `Max-Age` and the JWE's `exp`, so neither can outlive the other. */
13
+ readonly maxAge: number;
14
+ }
15
+ export interface SealedCookie<T> {
16
+ /** The full cookie name, prefix included. */
17
+ readonly name: string;
18
+ /** The value of a `Set-Cookie` header carrying `value`. */
19
+ seal(value: T): Promise<string>;
20
+ /** Read from a request's `Cookie` header. `null` for absent, tampered, or expired — all one answer. */
21
+ read(header: string | null | undefined): Promise<T | null>;
22
+ /** The value of a `Set-Cookie` header that removes it. */
23
+ clear(): string;
24
+ }
25
+ /** One cookie value out of a request's `Cookie` header, or `undefined`. */
26
+ export declare function cookieValue(header: string | null | undefined, name: string): string | undefined;
27
+ export declare function sealedCookie<T>(config: SealedCookieConfig): SealedCookie<T>;
28
+ //# sourceMappingURL=cookie-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie-session.d.ts","sourceRoot":"","sources":["../src/cookie-session.ts"],"names":[],"mappings":"AAoCA,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IACrC,wGAAwG;IACxG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,6CAA6C;IAC7C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChC,uGAAuG;IACvG,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC3D,0DAA0D;IAC1D,KAAK,IAAI,MAAM,CAAC;CACjB;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAQpB;AAaD,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAAC,CAAC,CAAC,CAgD3E"}
@@ -0,0 +1,51 @@
1
+ import { jwtDecrypt as l, EncryptJWT as u } from "jose";
2
+ const s = 4096;
3
+ function y(e, t) {
4
+ if (!(e == null || e.length === 0))
5
+ for (const n of e.split(";")) {
6
+ const r = n.indexOf("=");
7
+ if (r !== -1 && n.slice(0, r).trim() === t)
8
+ return n.slice(r + 1).trim();
9
+ }
10
+ }
11
+ async function d(e) {
12
+ const t = typeof e == "string" ? new TextEncoder().encode(e) : e;
13
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", t));
14
+ }
15
+ function c(e, t, n) {
16
+ return `${e}=${t}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${n}`;
17
+ }
18
+ function m(e) {
19
+ const t = `__Host-${e.name}`;
20
+ let n;
21
+ const r = () => n ?? (n = d(e.secret));
22
+ return {
23
+ name: t,
24
+ async seal(a) {
25
+ const i = await new u({ v: a }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${e.maxAge}s`).encrypt(await r()), o = c(t, i, e.maxAge);
26
+ if (o.length > s)
27
+ throw new Error(
28
+ `${t} is ${o.length} bytes and a browser is only required to keep ${s}; a cookie over the limit is dropped silently and the session simply never appears. Give relyingParty a SessionStore so the cookie carries a ticket instead of the tokens.`
29
+ );
30
+ return o;
31
+ },
32
+ async read(a) {
33
+ const i = y(a, t);
34
+ if (i === void 0) return null;
35
+ try {
36
+ const { payload: o } = await l(i, await r());
37
+ return o.v ?? null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ },
42
+ clear() {
43
+ return c(t, "", 0);
44
+ }
45
+ };
46
+ }
47
+ export {
48
+ y as cookieValue,
49
+ m as sealedCookie
50
+ };
51
+ //# sourceMappingURL=cookie-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie-session.js","sources":["../src/cookie-session.ts"],"sourcesContent":["import { EncryptJWT, jwtDecrypt } from \"jose\";\n\n/**\n * A value sealed into a cookie, and read back out of one.\n *\n * There is **one** mechanism here and it is used twice: for the session that outlives a request,\n * and for the short-lived transaction that carries `state`, `nonce` and the PKCE verifier between\n * the two legs of the authorization code flow. Holding the transaction in a cookie rather than in\n * server memory is what makes the BFF stateless by default — keasy's Rust keeps those three in a\n * SQLite-backed server session, and pays for a session store before anyone has signed in.\n *\n * ## The attributes, and why each one\n *\n * - **`__Host-` prefix**, added here and not declinable. It forces `Secure` and `Path=/`, forbids\n * `Domain`, and — the part that matters — a page on a sibling subdomain cannot write it. Without\n * the prefix, anything that can serve `evil.example.test` can set a cookie that arrives at\n * `app.example.test` looking exactly like ours.\n * - **`HttpOnly`**, so script cannot read it. In a BFF the browser is not supposed to hold the\n * credential at all; this is the enforcement of that sentence.\n * - **`SameSite=Lax`**, not `Strict`. `Strict` withholds the cookie on the top-level navigation\n * *back* from the identity provider, so the callback arrives without the transaction it needs\n * and every sign-in fails. `Lax` sends it on exactly that navigation and on nothing else risky.\n * - **JWE, not a signature.** The payload is a refresh token: signing would authenticate it and\n * leave it readable to anyone who can see the cookie. `dir` + `A256GCM` is authenticated\n * encryption, so a tampered byte fails to decrypt rather than decrypting to something else.\n */\n\n/**\n * The 4 KB a browser is required to keep, and the reason this module has a size guard.\n *\n * A cookie over the limit is not rejected loudly — it is *dropped*, and the symptom is a sign-in\n * that appears to work and a session that is never there. A `SessionStore` is the way out, and the\n * error says so.\n */\nconst COOKIE_LIMIT = 4096;\n\nexport interface SealedCookieConfig {\n /**\n * The name **after** the `__Host-` prefix, which this module adds. A caller cannot decline it:\n * the prefix is the only cookie attribute a browser enforces on our behalf.\n */\n readonly name: string;\n /**\n * The sealing secret. Any length — it is hashed to the 256-bit key — but it is a *secret*, not a\n * password: generate it, do not choose it.\n */\n readonly secret: string | Uint8Array;\n /** Seconds. It is both the cookie's `Max-Age` and the JWE's `exp`, so neither can outlive the other. */\n readonly maxAge: number;\n}\n\nexport interface SealedCookie<T> {\n /** The full cookie name, prefix included. */\n readonly name: string;\n /** The value of a `Set-Cookie` header carrying `value`. */\n seal(value: T): Promise<string>;\n /** Read from a request's `Cookie` header. `null` for absent, tampered, or expired — all one answer. */\n read(header: string | null | undefined): Promise<T | null>;\n /** The value of a `Set-Cookie` header that removes it. */\n clear(): string;\n}\n\n/** One cookie value out of a request's `Cookie` header, or `undefined`. */\nexport function cookieValue(\n header: string | null | undefined,\n name: string,\n): string | undefined {\n if (header === null || header === undefined || header.length === 0) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();\n }\n return undefined;\n}\n\nasync function keyFrom(secret: string | Uint8Array): Promise<Uint8Array> {\n const input = typeof secret === \"string\" ? new TextEncoder().encode(secret) : secret;\n return new Uint8Array(await crypto.subtle.digest(\"SHA-256\", input as BufferSource));\n}\n\nfunction setCookie(name: string, value: string, maxAge: number): string {\n // No `Domain`: `__Host-` forbids it, and forbidding it is the point — a cookie without a domain\n // is the one a sibling host cannot reach.\n return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;\n}\n\nexport function sealedCookie<T>(config: SealedCookieConfig): SealedCookie<T> {\n const name = `__Host-${config.name}`;\n // Derived once, lazily: the digest is cheap but a request path should not pay for it per call.\n let key: Promise<Uint8Array> | undefined;\n const material = () => (key ??= keyFrom(config.secret));\n\n return {\n name,\n\n async seal(value) {\n // The payload is wrapped rather than spread, so a field named `exp` or `iss` in a `Session`\n // could never come to mean the JWT claim of the same name.\n const token = await new EncryptJWT({ v: value })\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(`${config.maxAge}s`)\n .encrypt(await material());\n\n const header = setCookie(name, token, config.maxAge);\n if (header.length > COOKIE_LIMIT) {\n throw new Error(\n `${name} is ${header.length} bytes and a browser is only required to keep ${COOKIE_LIMIT}; ` +\n \"a cookie over the limit is dropped silently and the session simply never appears. \" +\n \"Give relyingParty a SessionStore so the cookie carries a ticket instead of the tokens.\",\n );\n }\n return header;\n },\n\n async read(header) {\n const token = cookieValue(header, name);\n if (token === undefined) return null;\n try {\n const { payload } = await jwtDecrypt(token, await material());\n return (payload[\"v\"] ?? null) as T | null;\n } catch {\n // Forged, re-keyed, truncated by a proxy, or simply expired. None of them is a session,\n // and none of them is worth a different answer to the caller — `bff-auth` reads the same\n // endpoint the same way. A thrown error here would only ever be caught and turned into\n // this.\n return null;\n }\n },\n\n clear() {\n return setCookie(name, \"\", 0);\n },\n };\n}\n"],"names":["COOKIE_LIMIT","cookieValue","header","name","part","eq","keyFrom","secret","input","setCookie","value","maxAge","sealedCookie","config","key","material","token","EncryptJWT","payload","jwtDecrypt"],"mappings":";AAkCA,MAAMA,IAAe;AA6Bd,SAASC,EACdC,GACAC,GACoB;AACpB,MAAI,EAAAD,KAAW,QAAgCA,EAAO,WAAW;AACjE,eAAWE,KAAQF,EAAO,MAAM,GAAG,GAAG;AACpC,YAAMG,IAAKD,EAAK,QAAQ,GAAG;AAC3B,UAAIC,MAAO,MACPD,EAAK,MAAM,GAAGC,CAAE,EAAE,KAAA,MAAWF;AAAM,eAAOC,EAAK,MAAMC,IAAK,CAAC,EAAE,KAAA;AAAA,IACnE;AAEF;AAEA,eAAeC,EAAQC,GAAkD;AACvE,QAAMC,IAAQ,OAAOD,KAAW,WAAW,IAAI,cAAc,OAAOA,CAAM,IAAIA;AAC9E,SAAO,IAAI,WAAW,MAAM,OAAO,OAAO,OAAO,WAAWC,CAAqB,CAAC;AACpF;AAEA,SAASC,EAAUN,GAAcO,GAAeC,GAAwB;AAGtE,SAAO,GAAGR,CAAI,IAAIO,CAAK,qDAAqDC,CAAM;AACpF;AAEO,SAASC,EAAgBC,GAA6C;AAC3E,QAAMV,IAAO,UAAUU,EAAO,IAAI;AAElC,MAAIC;AACJ,QAAMC,IAAW,MAAOD,UAAQR,EAAQO,EAAO,MAAM;AAErD,SAAO;AAAA,IACL,MAAAV;AAAA,IAEA,MAAM,KAAKO,GAAO;AAGhB,YAAMM,IAAQ,MAAM,IAAIC,EAAW,EAAE,GAAGP,EAAA,CAAO,EAC5C,mBAAmB,EAAE,KAAK,OAAO,KAAK,UAAA,CAAW,EACjD,YAAA,EACA,kBAAkB,GAAGG,EAAO,MAAM,GAAG,EACrC,QAAQ,MAAME,EAAA,CAAU,GAErBb,IAASO,EAAUN,GAAMa,GAAOH,EAAO,MAAM;AACnD,UAAIX,EAAO,SAASF;AAClB,cAAM,IAAI;AAAA,UACR,GAAGG,CAAI,OAAOD,EAAO,MAAM,iDAAiDF,CAAY;AAAA,QAAA;AAK5F,aAAOE;AAAA,IACT;AAAA,IAEA,MAAM,KAAKA,GAAQ;AACjB,YAAMc,IAAQf,EAAYC,GAAQC,CAAI;AACtC,UAAIa,MAAU,OAAW,QAAO;AAChC,UAAI;AACF,cAAM,EAAE,SAAAE,MAAY,MAAMC,EAAWH,GAAO,MAAMD,GAAU;AAC5D,eAAQG,EAAQ,KAAQ;AAAA,MAC1B,QAAQ;AAKN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,QAAQ;AACN,aAAOT,EAAUN,GAAM,IAAI,CAAC;AAAA,IAC9B;AAAA,EAAA;AAEJ;"}
package/dist/gate.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Show `children` to someone who holds `role`, and `fallback` to everyone else.
3
+ *
4
+ * **This hides UI and protects nothing**, for the reason {@link can} carries: a product gated only
5
+ * here is ungated.
6
+ *
7
+ * `organization` asks the question inside that organization rather than against the realm roles,
8
+ * and {@link can} does not merge the two.
9
+ */
10
+ export declare function Gate({ role, organization, fallback, children, }: {
11
+ readonly role: string;
12
+ readonly organization?: string;
13
+ readonly fallback?: React.ReactNode;
14
+ readonly children?: React.ReactNode;
15
+ }): import("react").JSX.Element | null;
16
+ //# sourceMappingURL=gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.tsx"],"names":[],"mappings":"AAKA;;;;;;;;GAQG;AACH,wBAAgB,IAAI,CAAC,EACnB,IAAI,EACJ,YAAY,EACZ,QAAe,EACf,QAAQ,GACT,EAAE;IACD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACrC,sCAUA"}
package/dist/gate.js ADDED
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as i, Fragment as m } from "react/jsx-runtime";
3
+ import { can as u } from "./can.js";
4
+ import { useSession as l } from "./use-session.js";
5
+ function p({
6
+ role: n,
7
+ organization: r,
8
+ fallback: o = null,
9
+ children: t
10
+ }) {
11
+ const { session: s, status: e } = l();
12
+ return e === "loading" ? null : /* @__PURE__ */ i(m, { children: u(s, n, r) ? t : o });
13
+ }
14
+ export {
15
+ p as Gate
16
+ };
17
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport { can } from \"./can\";\nimport { useSession } from \"./use-session\";\n\n/**\n * Show `children` to someone who holds `role`, and `fallback` to everyone else.\n *\n * **This hides UI and protects nothing**, for the reason {@link can} carries: a product gated only\n * here is ungated.\n *\n * `organization` asks the question inside that organization rather than against the realm roles,\n * and {@link can} does not merge the two.\n */\nexport function Gate({\n role,\n organization,\n fallback = null,\n children,\n}: {\n readonly role: string;\n readonly organization?: string;\n readonly fallback?: React.ReactNode;\n readonly children?: React.ReactNode;\n}) {\n const { session, status } = useSession();\n\n // While the session is still being read, neither answer is known to be true, so neither is\n // drawn. Rendering the fallback here is the flicker worth avoiding — \"you cannot do this\" shown\n // to someone who can, for as long as the session endpoint takes — and rendering the children is\n // worse, because it flashes a control and then retracts it.\n if (status === \"loading\") return null;\n\n return <>{can(session, role, organization) ? children : fallback}</>;\n}\n"],"names":[],"mappings":";;;;AAcO;AAAc;AACnB;AACA;AACW;AAEb;AAME;AAMA;AAGF;;;;"}
package/dist/host.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The organization a hostname addresses — a **hint**, never an authority.
3
+ *
4
+ * This file has no `"use client"` and takes no globals, and both are deliberate: it is a string
5
+ * function, so it is testable without a browser and readable on a server.
6
+ *
7
+ * The rule it must not be mistaken for: **the host is chosen by whoever made the request.** Anyone
8
+ * can send `Host: acme.kanzo.tech`. What makes an organization real for a session is the
9
+ * `organization` claim in a token Keycloak signed, so every consumer of this resolves the answer
10
+ * against `session.organizations` and treats a miss as "not a member" — which is what
11
+ * `useOrganization` does and what the server's own scope extractor must do before it opens a
12
+ * database.
13
+ */
14
+ /**
15
+ * The leading label, when the hostname has one to spare.
16
+ *
17
+ * `acme.kanzo.tech` → `acme`, and `kanzo.tech` → `undefined`, because a two-label host is the site
18
+ * itself rather than a tenant of it. `acme.localhost` → `acme` as the one exception, since that is
19
+ * how a developer reaches a tenant without editing DNS. An IP address yields nothing.
20
+ */
21
+ export declare function organizationFromHost(hostname: string | undefined): string | undefined;
22
+ //# sourceMappingURL=host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAYrF"}
package/dist/host.js ADDED
@@ -0,0 +1,10 @@
1
+ function o(t) {
2
+ if (t === void 0 || t.length === 0 || /^\[|^\d+\.\d+\.\d+\.\d+$/.test(t)) return;
3
+ const r = t.split("."), [i, n] = r;
4
+ if (!(i === void 0 || i.length === 0) && (r.length >= 3 || r.length === 2 && n === "localhost"))
5
+ return i;
6
+ }
7
+ export {
8
+ o as organizationFromHost
9
+ };
10
+ //# sourceMappingURL=host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.js","sources":["../src/host.ts"],"sourcesContent":["/**\n * The organization a hostname addresses — a **hint**, never an authority.\n *\n * This file has no `\"use client\"` and takes no globals, and both are deliberate: it is a string\n * function, so it is testable without a browser and readable on a server.\n *\n * The rule it must not be mistaken for: **the host is chosen by whoever made the request.** Anyone\n * can send `Host: acme.kanzo.tech`. What makes an organization real for a session is the\n * `organization` claim in a token Keycloak signed, so every consumer of this resolves the answer\n * against `session.organizations` and treats a miss as \"not a member\" — which is what\n * `useOrganization` does and what the server's own scope extractor must do before it opens a\n * database.\n */\n\n/**\n * The leading label, when the hostname has one to spare.\n *\n * `acme.kanzo.tech` → `acme`, and `kanzo.tech` → `undefined`, because a two-label host is the site\n * itself rather than a tenant of it. `acme.localhost` → `acme` as the one exception, since that is\n * how a developer reaches a tenant without editing DNS. An IP address yields nothing.\n */\nexport function organizationFromHost(hostname: string | undefined): string | undefined {\n if (hostname === undefined || hostname.length === 0) return undefined;\n // An IPv4 literal or a bracketed IPv6 has no labels to read.\n if (/^\\[|^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(hostname)) return undefined;\n\n const labels = hostname.split(\".\");\n const [first, second] = labels;\n if (first === undefined || first.length === 0) return undefined;\n\n if (labels.length >= 3) return first;\n if (labels.length === 2 && second === \"localhost\") return first;\n return undefined;\n}\n"],"names":["organizationFromHost","hostname","labels","first","second"],"mappings":"AAqBO,SAASA,EAAqBC,GAAkD;AAGrF,MAFIA,MAAa,UAAaA,EAAS,WAAW,KAE9C,2BAA2B,KAAKA,CAAQ,EAAG;AAE/C,QAAMC,IAASD,EAAS,MAAM,GAAG,GAC3B,CAACE,GAAOC,CAAM,IAAIF;AACxB,MAAI,EAAAC,MAAU,UAAaA,EAAM,WAAW,OAExCD,EAAO,UAAU,KACjBA,EAAO,WAAW,KAAKE,MAAW;AAAa,WAAOD;AAE5D;"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `@kanzo-tech/auth` — authentication over Keycloak.
3
+ *
4
+ * ## Why a package, and not `@kanzo-tech/ui`
5
+ *
6
+ * The [first admission rule](/docs/philosophy#admission) is *domain-free — nothing about RDF /
7
+ * SHACL / fossil / graphs / **auth***. Auth is excluded by name, deliberately: `ui` is the generic
8
+ * vocabulary every product shares, and a library that knew about sessions once shipped a hard-coded
9
+ * log-out flow inside a sidebar composite. A sibling package is where this belongs.
10
+ *
11
+ * ## What it is not
12
+ *
13
+ * **There is no sign-in screen.** That is the shape of the package rather than a gap in it: a
14
+ * sign-in screen is a logo, a legal line, a privacy notice and a button, and every product answers
15
+ * those differently. What is genuinely shared sits underneath — reading Keycloak's claims into one
16
+ * session, evaluating a role inside an organization, and keeping a `fetch` authenticated.
17
+ *
18
+ * **There is no protocol in it either.** PKCE, silent renewal, storage and cross-tab coordination
19
+ * are `oidc-client-ts`'s, behind `./browser`; the confidential client is `openid-client`'s, behind
20
+ * `./server`. Writing either by hand is where mistakes become vulnerabilities.
21
+ *
22
+ * ## The one rule to read before using it
23
+ *
24
+ * **What a client knows about its roles is for drawing, never for deciding.** The resource server,
25
+ * validating the access token, is what refuses a request. `can` and `Gate` hide controls; they
26
+ * protect nothing.
27
+ *
28
+ * ## What a name means here
29
+ *
30
+ * Three families, and the shape of the name says which one a thing is. Written down because four
31
+ * of these were added in parallel and drifted into two conventions and one exception:
32
+ *
33
+ * - **`<pattern>Auth`** returns an {@link Auth} — `browserAuth`, `bffAuth`. Both patterns RFC 10017
34
+ * names, one interface, which is what lets `useSession` and `Gate` be written once.
35
+ * - **`auth<Thing>`** returns an auth-flavoured *thing* — `authFetch` a `fetch`, and on `./next`
36
+ * `authRoutes`, `authSession`, `authMiddleware`.
37
+ * - **Everything else is named for what it is**: `relyingParty`, `issuer`, `sealedCookie`, `claims`,
38
+ * `can`. The first of those used to be `serverAuth`, which wore the suffix without returning an
39
+ * `Auth` — the server half's job is `begin`/`complete`/`read`/`refresh`/`end`, not
40
+ * `getSession`/`signIn`/`fetch`. A name that promises an interface it does not return is worse
41
+ * than a long one, and *relying party* is the term the specification already uses for it.
42
+ *
43
+ * *What would reverse it:* a fourth family. Two exist because two patterns exist; a third convention
44
+ * would mean the vocabulary has outgrown the rule rather than that the rule needs an exception.
45
+ *
46
+ * ## This door carries no engine
47
+ *
48
+ * Everything here runs on `react` alone, which is why a SPA installs the package and nothing else.
49
+ * `bffAuth` lives here rather than behind a subpath for the same reason: with the token on the
50
+ * server there is no protocol left in the browser, only a `fetch` to a session endpoint.
51
+ */
52
+ export { authFetch, type TokenSource } from './auth-fetch';
53
+ export type { AuthContextValue, AuthStatus } from './auth-context';
54
+ export { AuthProvider } from './auth-provider';
55
+ export { bffAuth, readSession, type BffAuthConfig } from './bff-auth';
56
+ export { can, organizationOf } from './can';
57
+ export { claims, roleFromGroupPath, type ClaimsConfig } from './claims';
58
+ export { Gate } from './gate';
59
+ export { organizationFromHost } from './host';
60
+ export { singleFlight } from './single-flight';
61
+ export { AuthError, type Auth, type AuthErrorCode, type AuthUser, type Organization, type Session, type SignInOptions, } from './types';
62
+ export { useOrganization } from './use-organization';
63
+ export { useSession } from './use-session';
64
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACxE,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EACL,SAAS,EACT,KAAK,IAAI,EACT,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,OAAO,EACZ,KAAK,aAAa,GACnB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import { authFetch as t } from "./auth-fetch.js";
2
+ import { AuthProvider as f } from "./auth-provider.js";
3
+ import { bffAuth as a, readSession as i } from "./bff-auth.js";
4
+ import { can as x, organizationOf as n } from "./can.js";
5
+ import { claims as h, roleFromGroupPath as u } from "./claims.js";
6
+ import { Gate as l } from "./gate.js";
7
+ import { organizationFromHost as c } from "./host.js";
8
+ import { singleFlight as A } from "./single-flight.js";
9
+ import { AuthError as G } from "./types.js";
10
+ import { useOrganization as P } from "./use-organization.js";
11
+ import { useSession as b } from "./use-session.js";
12
+ export {
13
+ G as AuthError,
14
+ f as AuthProvider,
15
+ l as Gate,
16
+ t as authFetch,
17
+ a as bffAuth,
18
+ x as can,
19
+ h as claims,
20
+ c as organizationFromHost,
21
+ n as organizationOf,
22
+ i as readSession,
23
+ u as roleFromGroupPath,
24
+ A as singleFlight,
25
+ P as useOrganization,
26
+ b as useSession
27
+ };
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;"}
@@ -0,0 +1,92 @@
1
+ import { Configuration, CryptoKey, CustomFetch, PrivateKey } from 'openid-client';
2
+ /**
3
+ * Discovery and client configuration for the confidential client behind `./server`.
4
+ *
5
+ * Two things live here that a plain `discovery()` call does not give you, and both come from
6
+ * running this against Keycloak in anger:
7
+ *
8
+ * 1. **The issuer's origin can be rewritten for server→Keycloak traffic.** The browser must be
9
+ * sent to the public issuer, and the server usually cannot reach it — in a cluster it reaches
10
+ * `http://keycloak:8080`. Rewriting the *transport* rather than the issuer keeps the two
11
+ * truthful at once: discovery still validates the `issuer` in the document against the public
12
+ * URL, because that is what Keycloak puts there.
13
+ * 2. **Discovery is allowed to fail.** Keycloak is frequently not up when the application is, and
14
+ * a library that hides a retry loop inside itself takes that decision away from the caller. A
15
+ * failed discovery is simply not cached, so the *next* call tries again — the retry is the
16
+ * caller's `await`, on the caller's schedule.
17
+ */
18
+ export interface IssuerConfig {
19
+ /** The **public** issuer URL, exactly as Keycloak reports it: `https://id.example/realms/kanzo`. */
20
+ readonly issuer: string;
21
+ readonly clientId: string;
22
+ /** Client secret authentication. Used when {@link IssuerConfig.privateKey} is absent. */
23
+ readonly clientSecret?: string;
24
+ /**
25
+ * `private_key_jwt`, which is the stronger of the two: the secret never travels. Preferred
26
+ * wherever the deployment can hold a key, and it wins over `clientSecret` when both are given.
27
+ */
28
+ readonly privateKey?: CryptoKey | PrivateKey;
29
+ /**
30
+ * Where *this process* reaches Keycloak, when that is not where the browser reaches it —
31
+ * `http://keycloak:8080`. Only the origin is replaced; the path is the issuer's own.
32
+ */
33
+ readonly internalOrigin?: string;
34
+ /** Injectable for tests, and the seam the origin rewrite is built on. Defaults to the global. */
35
+ readonly fetch?: typeof globalThis.fetch;
36
+ /**
37
+ * Allow a plain-HTTP issuer. A compose file on a laptop serves `http://localhost:8080`, and
38
+ * without this nothing local can be configured at all. It is not needed for an HTTP
39
+ * {@link IssuerConfig.internalOrigin}: the rewrite happens below the protocol check.
40
+ */
41
+ readonly allowInsecureHttp?: boolean;
42
+ /**
43
+ * Verify the ID token's signature, and not only its claims.
44
+ *
45
+ * **Defaults to whether the token endpoint is actually reached over TLS**, rather than to a flat
46
+ * `false`. The specification lets a code grant skip this — an ID token arriving over a TLS
47
+ * connection to the token endpoint, from a request authenticated as this client, is vouched for
48
+ * by the channel (OpenID Connect Core §3.1.3.7 step 6) — and `openid-client` leaves it off for
49
+ * that reason, which is why a sign-in fetches no JWKS at all.
50
+ *
51
+ * But the exemption is a claim *about the channel*, and an {@link IssuerConfig.internalOrigin} of
52
+ * `http://keycloak:8080` withdraws it: the hop TLS was supposed to protect is plaintext inside
53
+ * the cluster, so nothing is vouching for anything. Inheriting the reference's default there
54
+ * would be inheriting its conclusion without its premise. So the default is derived from the
55
+ * effective origin's protocol, and it can still be set explicitly either way.
56
+ *
57
+ * On, it costs one JWKS fetch, cached — and it is what makes {@link Issuer.rediscover} reachable
58
+ * on a key rotation.
59
+ */
60
+ readonly verifySignatures?: boolean;
61
+ /** Seconds. Applies to discovery and to every request the resulting configuration makes. */
62
+ readonly timeout?: number;
63
+ }
64
+ /**
65
+ * A configuration that discovers on demand.
66
+ *
67
+ * Holding the handle rather than the {@link Configuration} is what makes rotation expressible:
68
+ * `rediscover()` throws the current one away, and with it the JWKS that openid-client cached
69
+ * inside it.
70
+ */
71
+ export interface Issuer {
72
+ /** The configuration, discovering once and reusing it. Rejects — and caches nothing — on failure. */
73
+ configuration(): Promise<Configuration>;
74
+ /**
75
+ * Discard what was discovered and fetch it again.
76
+ *
77
+ * This is the answer to signing-key rotation, and it is deliberately *reactive*: the trigger is
78
+ * a verification that failed, never a timer. A timer refreshes when nothing is wrong and is
79
+ * still stale at the moment something is.
80
+ */
81
+ rediscover(): Promise<Configuration>;
82
+ }
83
+ /**
84
+ * A `fetch` that replaces one origin with another before sending.
85
+ *
86
+ * `from` may be any URL — its origin is what is taken — so the issuer URL itself can be passed
87
+ * without the caller splitting it first. When the two origins are equal this is a pass-through,
88
+ * which is why there is no second code path for "no rewrite configured".
89
+ */
90
+ export declare function rewriteOrigin(from: string, to: string, base?: typeof globalThis.fetch): CustomFetch;
91
+ export declare function issuer(config: IssuerConfig): Issuer;
92
+ //# sourceMappingURL=issuer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issuer.d.ts","sourceRoot":"","sources":["../src/issuer.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,WAAW,EAEhB,KAAK,UAAU,EAChB,MAAM,eAAe,CAAC;AAGvB;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,YAAY;IAC3B,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,GAAG,UAAU,CAAC;IAC7C;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,iGAAiG;IACjG,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,qGAAqG;IACrG,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,UAAU,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,EACV,IAAI,GAAE,OAAO,UAAU,CAAC,KAAwB,GAC/C,WAAW,CAQb;AAED,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAuDnD"}
package/dist/issuer.js ADDED
@@ -0,0 +1,42 @@
1
+ import { customFetch as d, allowInsecureRequests as p, enableNonRepudiationChecks as m, PrivateKeyJwt as w, ClientSecretPost as y, discovery as R } from "openid-client";
2
+ import { singleFlight as S } from "./single-flight.js";
3
+ function b(e, n, o = globalThis.fetch) {
4
+ const t = new URL(e).origin, r = n.replace(/\/+$/, "");
5
+ return (s, i) => o(
6
+ s.startsWith(t) ? `${r}${s.slice(t.length)}` : s,
7
+ i
8
+ );
9
+ }
10
+ function K(e) {
11
+ const n = new URL(e.issuer), o = e.fetch ?? ((c, v) => globalThis.fetch(c, v)), t = e.internalOrigin ?? n.origin, r = {
12
+ [d]: b(e.issuer, t, o)
13
+ };
14
+ e.timeout !== void 0 && (r.timeout = e.timeout);
15
+ const s = new URL(t).protocol === "https:", i = [
16
+ ...e.allowInsecureHttp === !0 ? [p] : [],
17
+ ...e.verifySignatures ?? !s ? [m] : []
18
+ ];
19
+ i.length > 0 && (r.execute = i);
20
+ const u = e.privateKey !== void 0 ? w(e.privateKey) : y(e.clientSecret), h = e.clientSecret !== void 0 ? { client_secret: e.clientSecret } : {};
21
+ let a;
22
+ const l = S(async () => {
23
+ const c = await R(n, e.clientId, h, u, r);
24
+ return a = c, c;
25
+ });
26
+ return {
27
+ async configuration() {
28
+ return a ?? l();
29
+ },
30
+ // A `rediscover()` that lands while a discovery is already running joins that one rather than
31
+ // starting a newer one. It is the narrow price of single-flight, and it is bounded: the call
32
+ // it joins is at most one request old, and a second failure rediscovers again.
33
+ async rediscover() {
34
+ return a = void 0, l();
35
+ }
36
+ };
37
+ }
38
+ export {
39
+ K as issuer,
40
+ b as rewriteOrigin
41
+ };
42
+ //# sourceMappingURL=issuer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issuer.js","sources":["../src/issuer.ts"],"sourcesContent":["import {\n ClientSecretPost,\n PrivateKeyJwt,\n allowInsecureRequests,\n customFetch,\n enableNonRepudiationChecks,\n discovery,\n type ClientMetadata,\n type Configuration,\n type CryptoKey,\n type CustomFetch,\n type DiscoveryRequestOptions,\n type PrivateKey,\n} from \"openid-client\";\nimport { singleFlight } from \"./single-flight\";\n\n/**\n * Discovery and client configuration for the confidential client behind `./server`.\n *\n * Two things live here that a plain `discovery()` call does not give you, and both come from\n * running this against Keycloak in anger:\n *\n * 1. **The issuer's origin can be rewritten for server→Keycloak traffic.** The browser must be\n * sent to the public issuer, and the server usually cannot reach it — in a cluster it reaches\n * `http://keycloak:8080`. Rewriting the *transport* rather than the issuer keeps the two\n * truthful at once: discovery still validates the `issuer` in the document against the public\n * URL, because that is what Keycloak puts there.\n * 2. **Discovery is allowed to fail.** Keycloak is frequently not up when the application is, and\n * a library that hides a retry loop inside itself takes that decision away from the caller. A\n * failed discovery is simply not cached, so the *next* call tries again — the retry is the\n * caller's `await`, on the caller's schedule.\n */\n\nexport interface IssuerConfig {\n /** The **public** issuer URL, exactly as Keycloak reports it: `https://id.example/realms/kanzo`. */\n readonly issuer: string;\n readonly clientId: string;\n /** Client secret authentication. Used when {@link IssuerConfig.privateKey} is absent. */\n readonly clientSecret?: string;\n /**\n * `private_key_jwt`, which is the stronger of the two: the secret never travels. Preferred\n * wherever the deployment can hold a key, and it wins over `clientSecret` when both are given.\n */\n readonly privateKey?: CryptoKey | PrivateKey;\n /**\n * Where *this process* reaches Keycloak, when that is not where the browser reaches it —\n * `http://keycloak:8080`. Only the origin is replaced; the path is the issuer's own.\n */\n readonly internalOrigin?: string;\n /** Injectable for tests, and the seam the origin rewrite is built on. Defaults to the global. */\n readonly fetch?: typeof globalThis.fetch;\n /**\n * Allow a plain-HTTP issuer. A compose file on a laptop serves `http://localhost:8080`, and\n * without this nothing local can be configured at all. It is not needed for an HTTP\n * {@link IssuerConfig.internalOrigin}: the rewrite happens below the protocol check.\n */\n readonly allowInsecureHttp?: boolean;\n /**\n * Verify the ID token's signature, and not only its claims.\n *\n * **Defaults to whether the token endpoint is actually reached over TLS**, rather than to a flat\n * `false`. The specification lets a code grant skip this — an ID token arriving over a TLS\n * connection to the token endpoint, from a request authenticated as this client, is vouched for\n * by the channel (OpenID Connect Core §3.1.3.7 step 6) — and `openid-client` leaves it off for\n * that reason, which is why a sign-in fetches no JWKS at all.\n *\n * But the exemption is a claim *about the channel*, and an {@link IssuerConfig.internalOrigin} of\n * `http://keycloak:8080` withdraws it: the hop TLS was supposed to protect is plaintext inside\n * the cluster, so nothing is vouching for anything. Inheriting the reference's default there\n * would be inheriting its conclusion without its premise. So the default is derived from the\n * effective origin's protocol, and it can still be set explicitly either way.\n *\n * On, it costs one JWKS fetch, cached — and it is what makes {@link Issuer.rediscover} reachable\n * on a key rotation.\n */\n readonly verifySignatures?: boolean;\n /** Seconds. Applies to discovery and to every request the resulting configuration makes. */\n readonly timeout?: number;\n}\n\n/**\n * A configuration that discovers on demand.\n *\n * Holding the handle rather than the {@link Configuration} is what makes rotation expressible:\n * `rediscover()` throws the current one away, and with it the JWKS that openid-client cached\n * inside it.\n */\nexport interface Issuer {\n /** The configuration, discovering once and reusing it. Rejects — and caches nothing — on failure. */\n configuration(): Promise<Configuration>;\n /**\n * Discard what was discovered and fetch it again.\n *\n * This is the answer to signing-key rotation, and it is deliberately *reactive*: the trigger is\n * a verification that failed, never a timer. A timer refreshes when nothing is wrong and is\n * still stale at the moment something is.\n */\n rediscover(): Promise<Configuration>;\n}\n\n/**\n * A `fetch` that replaces one origin with another before sending.\n *\n * `from` may be any URL — its origin is what is taken — so the issuer URL itself can be passed\n * without the caller splitting it first. When the two origins are equal this is a pass-through,\n * which is why there is no second code path for \"no rewrite configured\".\n */\nexport function rewriteOrigin(\n from: string,\n to: string,\n base: typeof globalThis.fetch = globalThis.fetch,\n): CustomFetch {\n const source = new URL(from).origin;\n const target = to.replace(/\\/+$/, \"\");\n return (url, options) =>\n base(\n url.startsWith(source) ? `${target}${url.slice(source.length)}` : url,\n options as unknown as RequestInit,\n );\n}\n\nexport function issuer(config: IssuerConfig): Issuer {\n const server = new URL(config.issuer);\n const base: typeof globalThis.fetch =\n config.fetch ?? ((input, init) => globalThis.fetch(input, init));\n\n const reachedAt = config.internalOrigin ?? server.origin;\n\n const options: DiscoveryRequestOptions = {\n [customFetch]: rewriteOrigin(config.issuer, reachedAt, base),\n };\n if (config.timeout !== undefined) options.timeout = config.timeout;\n\n // The channel is what the specification's exemption rests on, so the default asks whether there\n // is one rather than assuming it. Explicit beats derived in both directions.\n const overTls = new URL(reachedAt).protocol === \"https:\";\n const execute = [\n ...(config.allowInsecureHttp === true ? [allowInsecureRequests] : []),\n ...((config.verifySignatures ?? !overTls) ? [enableNonRepudiationChecks] : []),\n ];\n if (execute.length > 0) options.execute = execute;\n\n // `private_key_jwt` over a shared secret wherever the deployment can hold a key. `None()` is\n // absent on purpose: this door is the confidential client, and a public one belongs behind\n // `./browser` where PKCE alone is the protection.\n const clientAuth =\n config.privateKey !== undefined\n ? PrivateKeyJwt(config.privateKey)\n : ClientSecretPost(config.clientSecret);\n\n const metadata: Partial<ClientMetadata> =\n config.clientSecret !== undefined ? { client_secret: config.clientSecret } : {};\n\n let current: Configuration | undefined;\n\n // Single-flight for the reason it exists everywhere in this package: six requests arriving\n // during a cold start would otherwise each fetch the discovery document. Here the slot is\n // cleared on failure, which *is* the caller-driven retry — the next `await` starts a new attempt.\n const fetchOnce = singleFlight(async () => {\n const discovered = await discovery(server, config.clientId, metadata, clientAuth, options);\n current = discovered;\n return discovered;\n });\n\n return {\n async configuration() {\n return current ?? fetchOnce();\n },\n // A `rediscover()` that lands while a discovery is already running joins that one rather than\n // starting a newer one. It is the narrow price of single-flight, and it is bounded: the call\n // it joins is at most one request old, and a second failure rediscovers again.\n async rediscover() {\n current = undefined;\n return fetchOnce();\n },\n };\n}\n"],"names":["rewriteOrigin","from","to","base","source","target","url","options","issuer","config","server","input","init","reachedAt","customFetch","overTls","execute","allowInsecureRequests","enableNonRepudiationChecks","clientAuth","PrivateKeyJwt","ClientSecretPost","metadata","current","fetchOnce","singleFlight","discovered","discovery"],"mappings":";;AA2GO,SAASA,EACdC,GACAC,GACAC,IAAgC,WAAW,OAC9B;AACb,QAAMC,IAAS,IAAI,IAAIH,CAAI,EAAE,QACvBI,IAASH,EAAG,QAAQ,QAAQ,EAAE;AACpC,SAAO,CAACI,GAAKC,MACXJ;AAAA,IACEG,EAAI,WAAWF,CAAM,IAAI,GAAGC,CAAM,GAAGC,EAAI,MAAMF,EAAO,MAAM,CAAC,KAAKE;AAAA,IAClEC;AAAA,EAAA;AAEN;AAEO,SAASC,EAAOC,GAA8B;AACnD,QAAMC,IAAS,IAAI,IAAID,EAAO,MAAM,GAC9BN,IACJM,EAAO,UAAU,CAACE,GAAOC,MAAS,WAAW,MAAMD,GAAOC,CAAI,IAE1DC,IAAYJ,EAAO,kBAAkBC,EAAO,QAE5CH,IAAmC;AAAA,IACvC,CAACO,CAAW,GAAGd,EAAcS,EAAO,QAAQI,GAAWV,CAAI;AAAA,EAAA;AAE7D,EAAIM,EAAO,YAAY,WAAWF,EAAQ,UAAUE,EAAO;AAI3D,QAAMM,IAAU,IAAI,IAAIF,CAAS,EAAE,aAAa,UAC1CG,IAAU;AAAA,IACd,GAAIP,EAAO,sBAAsB,KAAO,CAACQ,CAAqB,IAAI,CAAA;AAAA,IAClE,GAAKR,EAAO,oBAAoB,CAACM,IAAW,CAACG,CAA0B,IAAI,CAAA;AAAA,EAAC;AAE9E,EAAIF,EAAQ,SAAS,MAAGT,EAAQ,UAAUS;AAK1C,QAAMG,IACJV,EAAO,eAAe,SAClBW,EAAcX,EAAO,UAAU,IAC/BY,EAAiBZ,EAAO,YAAY,GAEpCa,IACJb,EAAO,iBAAiB,SAAY,EAAE,eAAeA,EAAO,aAAA,IAAiB,CAAA;AAE/E,MAAIc;AAKJ,QAAMC,IAAYC,EAAa,YAAY;AACzC,UAAMC,IAAa,MAAMC,EAAUjB,GAAQD,EAAO,UAAUa,GAAUH,GAAYZ,CAAO;AACzF,WAAAgB,IAAUG,GACHA;AAAA,EACT,CAAC;AAED,SAAO;AAAA,IACL,MAAM,gBAAgB;AACpB,aAAOH,KAAWC,EAAA;AAAA,IACpB;AAAA;AAAA;AAAA;AAAA,IAIA,MAAM,aAAa;AACjB,aAAAD,IAAU,QACHC,EAAA;AAAA,IACT;AAAA,EAAA;AAEJ;"}