@incorta/auth 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,46 @@ use it when the public `incortaUrl` is not routable from the app — e.g.
45
45
  `http://host.docker.internal:8080/incorta` from a local container; the
46
46
  authorize redirect and issuer validation stay on the public URL).
47
47
 
48
+ ## App access policy (`appAccess` / `INCORTA_APP_ACCESS`)
49
+
50
+ Who may use the app, enforced by the whole-app gate:
51
+
52
+ - `public` — anyone with the URL, signed in or not. The gate is inert for app
53
+ routes; the auth routes stay mounted, so signing in remains possible and
54
+ sessions become optional (handle `session == null`).
55
+ - `authenticated` (default) — any signed-in Incorta user. Exactly the
56
+ pre-policy behavior, and what an absent/unknown `INCORTA_APP_ACCESS` means
57
+ (unknown values warn and fall back here — never to `public`).
58
+ - `catalog` — signed in AND at least VIEW access to this app in the Incorta
59
+ catalog. The SDK asks `GET {incortaUrl}/apps-catalog/check` with the
60
+ session's own access token; Incorta resolves which app the token belongs to
61
+ from the OAuth client registration's `(incorta-app:<id>)` name suffix
62
+ (written once, server-side, at registration), so the endpoint can
63
+ only ever answer about this app. Verdicts (allow and deny) are cached 30s
64
+ per user; apps not registered in the catalog stay open to authenticated
65
+ users (legacy-open). When Incorta cannot answer, the gate fails CLOSED with
66
+ a distinct 503 "temporarily unavailable" page (failures are not cached).
67
+
68
+ Enforcement surfaces:
69
+
70
+ - `auth.gate(request): Promise<Response | null>` — the whole-app gate: serve
71
+ the `Response` when non-null (auth routes, login redirects, the 403
72
+ access-denied page — which names the signed-in user and offers a
73
+ switch-account logout — or the 503 page), continue to your app on `null`.
74
+ - `@incorta/auth/express`: `appGate(auth)` middleware is `auth.gate` for
75
+ Express/Connect. `requireAuth(auth)` always demands a session regardless of
76
+ policy (an explicit route guard is the author's promise), and under
77
+ `catalog` additionally requires VIEW, attaching the verdict as
78
+ `req.incortaAuth.appAccess`.
79
+ - `auth.getAppAccess(session): Promise<AppAccess>` — the cached verdict
80
+ (`registered`, `access` bitmask, `canView`, `canEdit`, `allowed`) for
81
+ permission-aware UX in any mode.
82
+
83
+ `GET {basePath}/refresh?redirect_to=` supports the catalog gate: it refreshes
84
+ an expired access token through a browser round trip (persisting the rotated
85
+ refresh-token cookie) and bounces back — the gate sends navigations there
86
+ instead of calling Incorta with a token it knows is expired.
87
+
48
88
  ## Session model
49
89
 
50
90
  The cookie is an encrypted JWT (JWE `dir`/`A256GCM`, key derived from
@@ -112,6 +112,23 @@ interface IncortaAuthConfig {
112
112
  * @default false
113
113
  */
114
114
  exposeAccessToken?: boolean;
115
+ /**
116
+ * Who may use the app — enforced by the whole-app gate (`auth.gate`, and
117
+ * the framework middlewares built on it):
118
+ *
119
+ * - `"public"`: anyone with the URL, signed in or not (login stays
120
+ * available, sessions become optional);
121
+ * - `"authenticated"` (default): any signed-in Incorta user;
122
+ * - `"catalog"`: signed-in AND at least VIEW access to this app in the
123
+ * Incorta catalog, verified against `/apps-catalog/check` with a 30s
124
+ * per-user cache. Unregistered apps stay open to authenticated users
125
+ * (legacy-open); an unanswerable check fails CLOSED (503).
126
+ *
127
+ * Normally stamped by the Incorta Apps platform via `INCORTA_APP_ACCESS`.
128
+ * An unrecognized env value falls back to `"authenticated"` (never to
129
+ * `"public"` — a typo must not open the app) with a console warning.
130
+ */
131
+ appAccess?: AppAccessPolicy;
115
132
  /**
116
133
  * How to sign in when the app runs inside a cross-site iframe (the Incorta
117
134
  * builder preview). `"redirect"` (default) bounces the frame to Incorta —
@@ -152,6 +169,7 @@ interface ResolvedConfig {
152
169
  cookieSameSite: "lax" | "none";
153
170
  cookiePartitioned: boolean;
154
171
  exposeAccessToken: boolean;
172
+ appAccess: AppAccessPolicy;
155
173
  embeddedSignIn: "redirect" | "new-tab";
156
174
  /** `{incortaUrl}/oauth/{tenant}` — the OIDC issuer for this tenant. */
157
175
  issuer: string;
@@ -160,4 +178,43 @@ interface ResolvedConfig {
160
178
  fetch: typeof fetch;
161
179
  }
162
180
 
163
- export type { IncortaAuthConfig as I, ResolvedConfig as R, SessionInfo as S, IncortaUser as a, SessionData as b };
181
+ /**
182
+ * Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):
183
+ *
184
+ * - `"public"` — anyone with the URL; the whole-app gate is inert
185
+ * (login routes stay mounted, sessions are optional);
186
+ * - `"authenticated"`— any signed-in Incorta user (the pre-policy behavior,
187
+ * and the default when the platform stamped nothing);
188
+ * - `"catalog"` — signed-in AND at least VIEW access to this app in the
189
+ * Incorta catalog (`/apps-catalog/check`).
190
+ */
191
+ type AppAccessPolicy = "public" | "authenticated" | "catalog";
192
+ /** Incorta permission bits (com.incorta.api.Action). */
193
+ declare const VIEW_BIT = 1;
194
+ declare const SHARE_BIT = 2;
195
+ declare const EDIT_BIT = 4;
196
+ /** The catalog's verdict about the session user's access to THIS app. */
197
+ interface AppAccess {
198
+ /**
199
+ * The app is registered in the Incorta catalog. `false` means it predates
200
+ * the catalog integration — the gate treats it as legacy-open (any
201
+ * authenticated tenant user may enter).
202
+ */
203
+ registered: boolean;
204
+ /** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */
205
+ access: number;
206
+ canView: boolean;
207
+ canEdit: boolean;
208
+ /** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */
209
+ allowed: boolean;
210
+ }
211
+ /** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */
212
+ declare class AppAccessUnavailableError extends Error {
213
+ constructor(cause: string);
214
+ }
215
+ /** Incorta rejected the session's access token (expired/revoked) — re-login. */
216
+ declare class AppAccessTokenRejectedError extends Error {
217
+ constructor();
218
+ }
219
+
220
+ export { type AppAccess as A, EDIT_BIT as E, type IncortaAuthConfig as I, type ResolvedConfig as R, type SessionInfo as S, VIEW_BIT as V, type AppAccessPolicy as a, AppAccessTokenRejectedError as b, AppAccessUnavailableError as c, type IncortaUser as d, SHARE_BIT as e, type SessionData as f };
@@ -112,6 +112,23 @@ interface IncortaAuthConfig {
112
112
  * @default false
113
113
  */
114
114
  exposeAccessToken?: boolean;
115
+ /**
116
+ * Who may use the app — enforced by the whole-app gate (`auth.gate`, and
117
+ * the framework middlewares built on it):
118
+ *
119
+ * - `"public"`: anyone with the URL, signed in or not (login stays
120
+ * available, sessions become optional);
121
+ * - `"authenticated"` (default): any signed-in Incorta user;
122
+ * - `"catalog"`: signed-in AND at least VIEW access to this app in the
123
+ * Incorta catalog, verified against `/apps-catalog/check` with a 30s
124
+ * per-user cache. Unregistered apps stay open to authenticated users
125
+ * (legacy-open); an unanswerable check fails CLOSED (503).
126
+ *
127
+ * Normally stamped by the Incorta Apps platform via `INCORTA_APP_ACCESS`.
128
+ * An unrecognized env value falls back to `"authenticated"` (never to
129
+ * `"public"` — a typo must not open the app) with a console warning.
130
+ */
131
+ appAccess?: AppAccessPolicy;
115
132
  /**
116
133
  * How to sign in when the app runs inside a cross-site iframe (the Incorta
117
134
  * builder preview). `"redirect"` (default) bounces the frame to Incorta —
@@ -152,6 +169,7 @@ interface ResolvedConfig {
152
169
  cookieSameSite: "lax" | "none";
153
170
  cookiePartitioned: boolean;
154
171
  exposeAccessToken: boolean;
172
+ appAccess: AppAccessPolicy;
155
173
  embeddedSignIn: "redirect" | "new-tab";
156
174
  /** `{incortaUrl}/oauth/{tenant}` — the OIDC issuer for this tenant. */
157
175
  issuer: string;
@@ -160,4 +178,43 @@ interface ResolvedConfig {
160
178
  fetch: typeof fetch;
161
179
  }
162
180
 
163
- export type { IncortaAuthConfig as I, ResolvedConfig as R, SessionInfo as S, IncortaUser as a, SessionData as b };
181
+ /**
182
+ * Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):
183
+ *
184
+ * - `"public"` — anyone with the URL; the whole-app gate is inert
185
+ * (login routes stay mounted, sessions are optional);
186
+ * - `"authenticated"`— any signed-in Incorta user (the pre-policy behavior,
187
+ * and the default when the platform stamped nothing);
188
+ * - `"catalog"` — signed-in AND at least VIEW access to this app in the
189
+ * Incorta catalog (`/apps-catalog/check`).
190
+ */
191
+ type AppAccessPolicy = "public" | "authenticated" | "catalog";
192
+ /** Incorta permission bits (com.incorta.api.Action). */
193
+ declare const VIEW_BIT = 1;
194
+ declare const SHARE_BIT = 2;
195
+ declare const EDIT_BIT = 4;
196
+ /** The catalog's verdict about the session user's access to THIS app. */
197
+ interface AppAccess {
198
+ /**
199
+ * The app is registered in the Incorta catalog. `false` means it predates
200
+ * the catalog integration — the gate treats it as legacy-open (any
201
+ * authenticated tenant user may enter).
202
+ */
203
+ registered: boolean;
204
+ /** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */
205
+ access: number;
206
+ canView: boolean;
207
+ canEdit: boolean;
208
+ /** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */
209
+ allowed: boolean;
210
+ }
211
+ /** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */
212
+ declare class AppAccessUnavailableError extends Error {
213
+ constructor(cause: string);
214
+ }
215
+ /** Incorta rejected the session's access token (expired/revoked) — re-login. */
216
+ declare class AppAccessTokenRejectedError extends Error {
217
+ constructor();
218
+ }
219
+
220
+ export { type AppAccess as A, EDIT_BIT as E, type IncortaAuthConfig as I, type ResolvedConfig as R, type SessionInfo as S, VIEW_BIT as V, type AppAccessPolicy as a, AppAccessTokenRejectedError as b, AppAccessUnavailableError as c, type IncortaUser as d, SHARE_BIT as e, type SessionData as f };
@@ -0,0 +1,102 @@
1
+ // src/core/app-access.ts
2
+ var VIEW_BIT = 1;
3
+ var SHARE_BIT = 2;
4
+ var EDIT_BIT = 4;
5
+ var AppAccessUnavailableError = class extends Error {
6
+ constructor(cause) {
7
+ super(`[incorta-auth] App access check unavailable: ${cause}`);
8
+ this.name = "AppAccessUnavailableError";
9
+ }
10
+ };
11
+ var AppAccessTokenRejectedError = class extends Error {
12
+ constructor() {
13
+ super("[incorta-auth] Incorta rejected the session token during the app access check");
14
+ this.name = "AppAccessTokenRejectedError";
15
+ }
16
+ };
17
+ var APP_ACCESS_CACHE_TTL_MS = 3e4;
18
+ var CHECK_TIMEOUT_MS = 5e3;
19
+ var CACHE_MAX_ENTRIES = 5e3;
20
+ function toVerdict(registered, access) {
21
+ const canView = (access & VIEW_BIT) === VIEW_BIT;
22
+ return {
23
+ registered,
24
+ access,
25
+ canView,
26
+ canEdit: (access & EDIT_BIT) === EDIT_BIT,
27
+ allowed: !registered || canView
28
+ };
29
+ }
30
+ function createAppAccessChecker(config) {
31
+ const base = config.internalIncortaUrl ?? config.incortaUrl;
32
+ const checkUrl = `${base}/apps-catalog/check`;
33
+ const cache = /* @__PURE__ */ new Map();
34
+ const inFlight = /* @__PURE__ */ new Map();
35
+ function prune(now) {
36
+ if (cache.size < CACHE_MAX_ENTRIES) return;
37
+ for (const [key, entry] of cache) {
38
+ if (entry.expiresAt <= now) cache.delete(key);
39
+ }
40
+ }
41
+ async function fetchVerdict(accessToken) {
42
+ let response;
43
+ try {
44
+ response = await config.fetch(checkUrl, {
45
+ headers: {
46
+ accept: "application/json",
47
+ authorization: `Bearer ${accessToken}`
48
+ },
49
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
50
+ });
51
+ } catch (error) {
52
+ throw new AppAccessUnavailableError(error.message);
53
+ }
54
+ if (response.status === 401) throw new AppAccessTokenRejectedError();
55
+ if (!response.ok) {
56
+ throw new AppAccessUnavailableError(`HTTP ${response.status}`);
57
+ }
58
+ let body;
59
+ try {
60
+ body = await response.json();
61
+ } catch {
62
+ throw new AppAccessUnavailableError("invalid JSON response");
63
+ }
64
+ if (typeof body.registered !== "boolean" || typeof body.access !== "number") {
65
+ throw new AppAccessUnavailableError("malformed check response");
66
+ }
67
+ return toVerdict(body.registered, body.access);
68
+ }
69
+ async function check(accessToken, userKey) {
70
+ const now = Date.now();
71
+ const cached = cache.get(userKey);
72
+ if (cached && cached.expiresAt > now) return cached.verdict;
73
+ const pending = inFlight.get(userKey);
74
+ if (pending) return pending;
75
+ const promise = fetchVerdict(accessToken).then((verdict) => {
76
+ prune(now);
77
+ cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });
78
+ return verdict;
79
+ }).finally(() => {
80
+ inFlight.delete(userKey);
81
+ });
82
+ inFlight.set(userKey, promise);
83
+ return promise;
84
+ }
85
+ return {
86
+ check,
87
+ clearCache() {
88
+ cache.clear();
89
+ inFlight.clear();
90
+ }
91
+ };
92
+ }
93
+
94
+ export {
95
+ VIEW_BIT,
96
+ SHARE_BIT,
97
+ EDIT_BIT,
98
+ AppAccessUnavailableError,
99
+ AppAccessTokenRejectedError,
100
+ createAppAccessChecker
101
+ };
102
+ //# sourceMappingURL=chunk-TMXOEIA2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/app-access.ts"],"sourcesContent":["import type { ResolvedConfig } from \"./config.js\";\n\n/**\n * Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):\n *\n * - `\"public\"` — anyone with the URL; the whole-app gate is inert\n * (login routes stay mounted, sessions are optional);\n * - `\"authenticated\"`— any signed-in Incorta user (the pre-policy behavior,\n * and the default when the platform stamped nothing);\n * - `\"catalog\"` — signed-in AND at least VIEW access to this app in the\n * Incorta catalog (`/apps-catalog/check`).\n */\nexport type AppAccessPolicy = \"public\" | \"authenticated\" | \"catalog\";\n\n/** Incorta permission bits (com.incorta.api.Action). */\nexport const VIEW_BIT = 1;\nexport const SHARE_BIT = 2;\nexport const EDIT_BIT = 4;\n\n/** The catalog's verdict about the session user's access to THIS app. */\nexport interface AppAccess {\n /**\n * The app is registered in the Incorta catalog. `false` means it predates\n * the catalog integration — the gate treats it as legacy-open (any\n * authenticated tenant user may enter).\n */\n registered: boolean;\n /** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */\n access: number;\n canView: boolean;\n canEdit: boolean;\n /** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */\n allowed: boolean;\n}\n\n/** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */\nexport class AppAccessUnavailableError extends Error {\n constructor(cause: string) {\n super(`[incorta-auth] App access check unavailable: ${cause}`);\n this.name = \"AppAccessUnavailableError\";\n }\n}\n\n/** Incorta rejected the session's access token (expired/revoked) — re-login. */\nexport class AppAccessTokenRejectedError extends Error {\n constructor() {\n super(\"[incorta-auth] Incorta rejected the session token during the app access check\");\n this.name = \"AppAccessTokenRejectedError\";\n }\n}\n\n/** Verdicts are cached (allow AND deny) this long per user. */\nexport const APP_ACCESS_CACHE_TTL_MS = 30_000;\nconst CHECK_TIMEOUT_MS = 5_000;\n/** Lazy prune threshold — entries are (user × 30s), so this is generous. */\nconst CACHE_MAX_ENTRIES = 5_000;\n\ninterface CacheEntry {\n verdict: AppAccess;\n expiresAt: number;\n}\n\nexport interface AppAccessChecker {\n /**\n * The catalog verdict for the user behind `accessToken`, served from the\n * per-user cache when fresh. Throws {@link AppAccessUnavailableError} when\n * Incorta cannot answer and {@link AppAccessTokenRejectedError} when it\n * rejects the token itself.\n */\n check(accessToken: string, userKey: string): Promise<AppAccess>;\n /** Test hook: drops all cached verdicts. */\n clearCache(): void;\n}\n\nfunction toVerdict(registered: boolean, access: number): AppAccess {\n const canView = (access & VIEW_BIT) === VIEW_BIT;\n return {\n registered,\n access,\n canView,\n canEdit: (access & EDIT_BIT) === EDIT_BIT,\n allowed: !registered || canView,\n };\n}\n\n/**\n * Client for `GET {incortaUrl}/apps-catalog/check` — the backend resolves\n * which app the token belongs to from its OAuth client (`software_id`), so\n * the request carries nothing but the user's own Bearer token. Server-side\n * call, so the split-horizon `internalIncortaUrl` wins when set.\n *\n * Verdicts (allow and deny alike) are cached per user for\n * {@link APP_ACCESS_CACHE_TTL_MS}; failures are NOT cached — the next\n * request retries, and callers fail closed in the meantime. Concurrent\n * checks for the same user share one in-flight request.\n */\nexport function createAppAccessChecker(config: ResolvedConfig): AppAccessChecker {\n const base = config.internalIncortaUrl ?? config.incortaUrl;\n const checkUrl = `${base}/apps-catalog/check`;\n const cache = new Map<string, CacheEntry>();\n const inFlight = new Map<string, Promise<AppAccess>>();\n\n function prune(now: number): void {\n if (cache.size < CACHE_MAX_ENTRIES) return;\n for (const [key, entry] of cache) {\n if (entry.expiresAt <= now) cache.delete(key);\n }\n }\n\n async function fetchVerdict(accessToken: string): Promise<AppAccess> {\n let response: Response;\n try {\n response = await config.fetch(checkUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${accessToken}`,\n },\n signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),\n });\n } catch (error) {\n throw new AppAccessUnavailableError((error as Error).message);\n }\n\n if (response.status === 401) throw new AppAccessTokenRejectedError();\n if (!response.ok) {\n // 404 covers both \"Incorta Apps disabled for the tenant\" and an older\n // Incorta without the /check command — verdicts are unavailable either\n // way, and gates never fail open.\n throw new AppAccessUnavailableError(`HTTP ${response.status}`);\n }\n\n let body: { registered?: unknown; access?: unknown };\n try {\n body = (await response.json()) as { registered?: unknown; access?: unknown };\n } catch {\n throw new AppAccessUnavailableError(\"invalid JSON response\");\n }\n if (typeof body.registered !== \"boolean\" || typeof body.access !== \"number\") {\n throw new AppAccessUnavailableError(\"malformed check response\");\n }\n return toVerdict(body.registered, body.access);\n }\n\n async function check(accessToken: string, userKey: string): Promise<AppAccess> {\n const now = Date.now();\n const cached = cache.get(userKey);\n if (cached && cached.expiresAt > now) return cached.verdict;\n\n const pending = inFlight.get(userKey);\n if (pending) return pending;\n\n const promise = fetchVerdict(accessToken)\n .then((verdict) => {\n prune(now);\n cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });\n return verdict;\n })\n .finally(() => {\n inFlight.delete(userKey);\n });\n inFlight.set(userKey, promise);\n return promise;\n }\n\n return {\n check,\n clearCache() {\n cache.clear();\n inFlight.clear();\n },\n };\n}\n"],"mappings":";AAeO,IAAM,WAAW;AACjB,IAAM,YAAY;AAClB,IAAM,WAAW;AAmBjB,IAAM,4BAAN,cAAwC,MAAM;AAAA,EACnD,YAAY,OAAe;AACzB,UAAM,gDAAgD,KAAK,EAAE;AAC7D,SAAK,OAAO;AAAA,EACd;AACF;AAGO,IAAM,8BAAN,cAA0C,MAAM;AAAA,EACrD,cAAc;AACZ,UAAM,+EAA+E;AACrF,SAAK,OAAO;AAAA,EACd;AACF;AAGO,IAAM,0BAA0B;AACvC,IAAM,mBAAmB;AAEzB,IAAM,oBAAoB;AAmB1B,SAAS,UAAU,YAAqB,QAA2B;AACjE,QAAM,WAAW,SAAS,cAAc;AACxC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,UAAU,SAAS,cAAc;AAAA,IACjC,SAAS,CAAC,cAAc;AAAA,EAC1B;AACF;AAaO,SAAS,uBAAuB,QAA0C;AAC/E,QAAM,OAAO,OAAO,sBAAsB,OAAO;AACjD,QAAM,WAAW,GAAG,IAAI;AACxB,QAAM,QAAQ,oBAAI,IAAwB;AAC1C,QAAM,WAAW,oBAAI,IAAgC;AAErD,WAAS,MAAM,KAAmB;AAChC,QAAI,MAAM,OAAO,kBAAmB;AACpC,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO;AAChC,UAAI,MAAM,aAAa,IAAK,OAAM,OAAO,GAAG;AAAA,IAC9C;AAAA,EACF;AAEA,iBAAe,aAAa,aAAyC;AACnE,QAAI;AACJ,QAAI;AACF,iBAAW,MAAM,OAAO,MAAM,UAAU;AAAA,QACtC,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,eAAe,UAAU,WAAW;AAAA,QACtC;AAAA,QACA,QAAQ,YAAY,QAAQ,gBAAgB;AAAA,MAC9C,CAAC;AAAA,IACH,SAAS,OAAO;AACd,YAAM,IAAI,0BAA2B,MAAgB,OAAO;AAAA,IAC9D;AAEA,QAAI,SAAS,WAAW,IAAK,OAAM,IAAI,4BAA4B;AACnE,QAAI,CAAC,SAAS,IAAI;AAIhB,YAAM,IAAI,0BAA0B,QAAQ,SAAS,MAAM,EAAE;AAAA,IAC/D;AAEA,QAAI;AACJ,QAAI;AACF,aAAQ,MAAM,SAAS,KAAK;AAAA,IAC9B,QAAQ;AACN,YAAM,IAAI,0BAA0B,uBAAuB;AAAA,IAC7D;AACA,QAAI,OAAO,KAAK,eAAe,aAAa,OAAO,KAAK,WAAW,UAAU;AAC3E,YAAM,IAAI,0BAA0B,0BAA0B;AAAA,IAChE;AACA,WAAO,UAAU,KAAK,YAAY,KAAK,MAAM;AAAA,EAC/C;AAEA,iBAAe,MAAM,aAAqB,SAAqC;AAC7E,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,SAAS,MAAM,IAAI,OAAO;AAChC,QAAI,UAAU,OAAO,YAAY,IAAK,QAAO,OAAO;AAEpD,UAAM,UAAU,SAAS,IAAI,OAAO;AACpC,QAAI,QAAS,QAAO;AAEpB,UAAM,UAAU,aAAa,WAAW,EACrC,KAAK,CAAC,YAAY;AACjB,YAAM,GAAG;AACT,YAAM,IAAI,SAAS,EAAE,SAAS,WAAW,KAAK,IAAI,IAAI,wBAAwB,CAAC;AAC/E,aAAO;AAAA,IACT,CAAC,EACA,QAAQ,MAAM;AACb,eAAS,OAAO,OAAO;AAAA,IACzB,CAAC;AACH,aAAS,IAAI,SAAS,OAAO;AAC7B,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AACX,YAAM,MAAM;AACZ,eAAS,MAAM;AAAA,IACjB;AAAA,EACF;AACF;","names":[]}
package/dist/express.cjs CHANGED
@@ -20,11 +20,20 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/express.ts
21
21
  var express_exports = {};
22
22
  __export(express_exports, {
23
+ appGate: () => appGate,
23
24
  incortaAuth: () => incortaAuth,
24
25
  requireAuth: () => requireAuth
25
26
  });
26
27
  module.exports = __toCommonJS(express_exports);
27
28
 
29
+ // src/core/app-access.ts
30
+ var AppAccessTokenRejectedError = class extends Error {
31
+ constructor() {
32
+ super("[incorta-auth] Incorta rejected the session token during the app access check");
33
+ this.name = "AppAccessTokenRejectedError";
34
+ }
35
+ };
36
+
28
37
  // src/node.ts
29
38
  async function toWebRequest(req) {
30
39
  const proto = req.headers["x-forwarded-proto"]?.split(",")[0]?.trim() ?? (req.socket.encrypted ? "https" : "http");
@@ -104,8 +113,32 @@ function requireAuth(auth, options = {}) {
104
113
  req.incortaAuth = await auth.getSession(await toWebRequest(req));
105
114
  }
106
115
  if (req.incortaAuth) {
107
- next();
108
- return;
116
+ if (auth.config.appAccess !== "catalog") {
117
+ next();
118
+ return;
119
+ }
120
+ const session = req.incortaAuth;
121
+ try {
122
+ const verdict = await auth.getAppAccess(session);
123
+ if (verdict.allowed) {
124
+ session.appAccess = verdict;
125
+ next();
126
+ return;
127
+ }
128
+ res.statusCode = 403;
129
+ res.setHeader("content-type", "application/json; charset=utf-8");
130
+ res.end(JSON.stringify({ error: "app_access_denied" }));
131
+ return;
132
+ } catch (error) {
133
+ if (!(error instanceof AppAccessTokenRejectedError)) {
134
+ res.statusCode = 503;
135
+ res.setHeader("content-type", "application/json; charset=utf-8");
136
+ res.setHeader("retry-after", "30");
137
+ res.end(JSON.stringify({ error: "app_access_unavailable" }));
138
+ return;
139
+ }
140
+ req.incortaAuth = null;
141
+ }
109
142
  }
110
143
  const wantsHtml = (req.headers.accept ?? "").includes("text/html");
111
144
  if (mode === "redirect" || mode === "auto" && wantsHtml) {
@@ -126,8 +159,25 @@ function requireAuth(auth, options = {}) {
126
159
  }
127
160
  };
128
161
  }
162
+ function appGate(auth) {
163
+ return async (req, res, next) => {
164
+ try {
165
+ const request = await toWebRequest(req);
166
+ const response = await auth.gate(request);
167
+ if (response) {
168
+ await sendWebResponse(res, response);
169
+ return;
170
+ }
171
+ req.incortaAuth = await auth.getSession(request);
172
+ next();
173
+ } catch (error) {
174
+ next(error);
175
+ }
176
+ };
177
+ }
129
178
  // Annotate the CommonJS export names for ESM import in node:
130
179
  0 && (module.exports = {
180
+ appGate,
131
181
  incortaAuth,
132
182
  requireAuth
133
183
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/express.ts","../src/node.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/** Guards a route: only signed-in Incorta users get through. */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n next();\n return;\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { IncortaAuth } from \"./index.js\";\n\nexport type { IncortaAuth } from \"./index.js\";\n\n/**\n * Builds a web-standard `Request` from a Node request. Buffers the body (the\n * auth routes are GET/POST-without-body, so this stays tiny).\n */\nexport async function toWebRequest(req: IncomingMessage): Promise<Request> {\n const proto =\n (req.headers[\"x-forwarded-proto\"] as string | undefined)?.split(\",\")[0]?.trim() ??\n ((req.socket as { encrypted?: boolean }).encrypted ? \"https\" : \"http\");\n const host = req.headers.host ?? \"localhost\";\n // Express rewrites req.url when routers are mounted — originalUrl keeps the full path.\n const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? \"/\";\n const url = `${proto}://${host}${path}`;\n\n const headers = new Headers();\n for (let i = 0; i < req.rawHeaders.length; i += 2) {\n headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);\n }\n\n const method = req.method ?? \"GET\";\n let body: BodyInit | undefined;\n if (method !== \"GET\" && method !== \"HEAD\") {\n const chunks: Buffer[] = [];\n for await (const chunk of req) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));\n }\n if (chunks.length) {\n const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.length;\n }\n body = bytes;\n }\n }\n\n return new Request(url, { method, headers, body });\n}\n\nexport async function sendWebResponse(\n res: ServerResponse,\n response: Response,\n): Promise<void> {\n res.statusCode = response.status;\n response.headers.forEach((value, name) => {\n if (name.toLowerCase() === \"set-cookie\") return; // handled below, needs the un-joined list\n res.setHeader(name, value);\n });\n const setCookies = response.headers.getSetCookie();\n if (setCookies.length) res.setHeader(\"set-cookie\", setCookies);\n\n if (response.body) {\n const buffer = Buffer.from(await response.arrayBuffer());\n res.end(buffer);\n } else {\n res.end();\n }\n}\n\n/**\n * Adapts the auth handler to a plain Node `(req, res)` listener:\n *\n * ```ts\n * import http from \"node:http\";\n * const authListener = toNodeHandler(auth);\n * http.createServer((req, res) => {\n * if (req.url?.startsWith(\"/auth\")) return authListener(req, res);\n * // ... your app\n * });\n * ```\n */\nexport function toNodeHandler(auth: IncortaAuth) {\n return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n const request = await toWebRequest(req);\n const response = await auth.handler(request);\n await sendWebResponse(res, response);\n };\n}\n\n/** Reads the current session from a Node request (returns null when signed out). */\nexport async function getNodeSession(auth: IncortaAuth, req: IncomingMessage) {\n return auth.getSession(await toWebRequest(req));\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/express.ts","../src/core/app-access.ts","../src/node.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { AppAccessTokenRejectedError } from \"./core/app-access.js\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { sendWebResponse, toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AppAccess, AppAccessPolicy, AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/**\n * Guards a route: only signed-in Incorta users get through — regardless of\n * the app's `appAccess` policy (an explicit `requireAuth` is the author's\n * promise that this route needs a user, so `\"public\"` mode does not neuter\n * it). Under the `\"catalog\"` policy the signed-in user must additionally\n * hold at least VIEW access to this app; the resolved verdict is attached as\n * `req.incortaAuth.appAccess`.\n */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n if (auth.config.appAccess !== \"catalog\") {\n next();\n return;\n }\n const session = req.incortaAuth;\n try {\n const verdict = await auth.getAppAccess(session);\n if (verdict.allowed) {\n session.appAccess = verdict;\n next();\n return;\n }\n res.statusCode = 403;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"app_access_denied\" }));\n return;\n } catch (error) {\n if (!(error instanceof AppAccessTokenRejectedError)) {\n // Verdicts unavailable — NEVER fail open.\n res.statusCode = 503;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.setHeader(\"retry-after\", \"30\");\n res.end(JSON.stringify({ error: \"app_access_unavailable\" }));\n return;\n }\n // Incorta rejected the session's token — treat as signed out.\n req.incortaAuth = null;\n }\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Whole-app gate middleware: `auth.gate` as Express/Connect middleware.\n * Mount it FIRST (before your routes, instead of `incortaAuth()` — it also\n * serves the auth routes) to give the entire app the platform-stamped\n * `appAccess` policy: `\"public\"` apps let everyone through, `\"authenticated\"`\n * apps require login, `\"catalog\"` apps require catalog VIEW access (with the\n * SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`\n * populated like `incortaAuth()` does.\n */\nexport function appGate(auth: IncortaAuth): Middleware {\n return async (req, res, next) => {\n try {\n const request = await toWebRequest(req);\n const response = await auth.gate(request);\n if (response) {\n await sendWebResponse(res, response);\n return;\n }\n req.incortaAuth = await auth.getSession(request);\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n","import type { ResolvedConfig } from \"./config.js\";\n\n/**\n * Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):\n *\n * - `\"public\"` — anyone with the URL; the whole-app gate is inert\n * (login routes stay mounted, sessions are optional);\n * - `\"authenticated\"`— any signed-in Incorta user (the pre-policy behavior,\n * and the default when the platform stamped nothing);\n * - `\"catalog\"` — signed-in AND at least VIEW access to this app in the\n * Incorta catalog (`/apps-catalog/check`).\n */\nexport type AppAccessPolicy = \"public\" | \"authenticated\" | \"catalog\";\n\n/** Incorta permission bits (com.incorta.api.Action). */\nexport const VIEW_BIT = 1;\nexport const SHARE_BIT = 2;\nexport const EDIT_BIT = 4;\n\n/** The catalog's verdict about the session user's access to THIS app. */\nexport interface AppAccess {\n /**\n * The app is registered in the Incorta catalog. `false` means it predates\n * the catalog integration — the gate treats it as legacy-open (any\n * authenticated tenant user may enter).\n */\n registered: boolean;\n /** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */\n access: number;\n canView: boolean;\n canEdit: boolean;\n /** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */\n allowed: boolean;\n}\n\n/** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */\nexport class AppAccessUnavailableError extends Error {\n constructor(cause: string) {\n super(`[incorta-auth] App access check unavailable: ${cause}`);\n this.name = \"AppAccessUnavailableError\";\n }\n}\n\n/** Incorta rejected the session's access token (expired/revoked) — re-login. */\nexport class AppAccessTokenRejectedError extends Error {\n constructor() {\n super(\"[incorta-auth] Incorta rejected the session token during the app access check\");\n this.name = \"AppAccessTokenRejectedError\";\n }\n}\n\n/** Verdicts are cached (allow AND deny) this long per user. */\nexport const APP_ACCESS_CACHE_TTL_MS = 30_000;\nconst CHECK_TIMEOUT_MS = 5_000;\n/** Lazy prune threshold — entries are (user × 30s), so this is generous. */\nconst CACHE_MAX_ENTRIES = 5_000;\n\ninterface CacheEntry {\n verdict: AppAccess;\n expiresAt: number;\n}\n\nexport interface AppAccessChecker {\n /**\n * The catalog verdict for the user behind `accessToken`, served from the\n * per-user cache when fresh. Throws {@link AppAccessUnavailableError} when\n * Incorta cannot answer and {@link AppAccessTokenRejectedError} when it\n * rejects the token itself.\n */\n check(accessToken: string, userKey: string): Promise<AppAccess>;\n /** Test hook: drops all cached verdicts. */\n clearCache(): void;\n}\n\nfunction toVerdict(registered: boolean, access: number): AppAccess {\n const canView = (access & VIEW_BIT) === VIEW_BIT;\n return {\n registered,\n access,\n canView,\n canEdit: (access & EDIT_BIT) === EDIT_BIT,\n allowed: !registered || canView,\n };\n}\n\n/**\n * Client for `GET {incortaUrl}/apps-catalog/check` — the backend resolves\n * which app the token belongs to from its OAuth client (`software_id`), so\n * the request carries nothing but the user's own Bearer token. Server-side\n * call, so the split-horizon `internalIncortaUrl` wins when set.\n *\n * Verdicts (allow and deny alike) are cached per user for\n * {@link APP_ACCESS_CACHE_TTL_MS}; failures are NOT cached — the next\n * request retries, and callers fail closed in the meantime. Concurrent\n * checks for the same user share one in-flight request.\n */\nexport function createAppAccessChecker(config: ResolvedConfig): AppAccessChecker {\n const base = config.internalIncortaUrl ?? config.incortaUrl;\n const checkUrl = `${base}/apps-catalog/check`;\n const cache = new Map<string, CacheEntry>();\n const inFlight = new Map<string, Promise<AppAccess>>();\n\n function prune(now: number): void {\n if (cache.size < CACHE_MAX_ENTRIES) return;\n for (const [key, entry] of cache) {\n if (entry.expiresAt <= now) cache.delete(key);\n }\n }\n\n async function fetchVerdict(accessToken: string): Promise<AppAccess> {\n let response: Response;\n try {\n response = await config.fetch(checkUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${accessToken}`,\n },\n signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),\n });\n } catch (error) {\n throw new AppAccessUnavailableError((error as Error).message);\n }\n\n if (response.status === 401) throw new AppAccessTokenRejectedError();\n if (!response.ok) {\n // 404 covers both \"Incorta Apps disabled for the tenant\" and an older\n // Incorta without the /check command — verdicts are unavailable either\n // way, and gates never fail open.\n throw new AppAccessUnavailableError(`HTTP ${response.status}`);\n }\n\n let body: { registered?: unknown; access?: unknown };\n try {\n body = (await response.json()) as { registered?: unknown; access?: unknown };\n } catch {\n throw new AppAccessUnavailableError(\"invalid JSON response\");\n }\n if (typeof body.registered !== \"boolean\" || typeof body.access !== \"number\") {\n throw new AppAccessUnavailableError(\"malformed check response\");\n }\n return toVerdict(body.registered, body.access);\n }\n\n async function check(accessToken: string, userKey: string): Promise<AppAccess> {\n const now = Date.now();\n const cached = cache.get(userKey);\n if (cached && cached.expiresAt > now) return cached.verdict;\n\n const pending = inFlight.get(userKey);\n if (pending) return pending;\n\n const promise = fetchVerdict(accessToken)\n .then((verdict) => {\n prune(now);\n cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });\n return verdict;\n })\n .finally(() => {\n inFlight.delete(userKey);\n });\n inFlight.set(userKey, promise);\n return promise;\n }\n\n return {\n check,\n clearCache() {\n cache.clear();\n inFlight.clear();\n },\n };\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { IncortaAuth } from \"./index.js\";\n\nexport type { IncortaAuth } from \"./index.js\";\n\n/**\n * Builds a web-standard `Request` from a Node request. Buffers the body (the\n * auth routes are GET/POST-without-body, so this stays tiny).\n */\nexport async function toWebRequest(req: IncomingMessage): Promise<Request> {\n const proto =\n (req.headers[\"x-forwarded-proto\"] as string | undefined)?.split(\",\")[0]?.trim() ??\n ((req.socket as { encrypted?: boolean }).encrypted ? \"https\" : \"http\");\n const host = req.headers.host ?? \"localhost\";\n // Express rewrites req.url when routers are mounted — originalUrl keeps the full path.\n const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? \"/\";\n const url = `${proto}://${host}${path}`;\n\n const headers = new Headers();\n for (let i = 0; i < req.rawHeaders.length; i += 2) {\n headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);\n }\n\n const method = req.method ?? \"GET\";\n let body: BodyInit | undefined;\n if (method !== \"GET\" && method !== \"HEAD\") {\n const chunks: Buffer[] = [];\n for await (const chunk of req) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));\n }\n if (chunks.length) {\n const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.length;\n }\n body = bytes;\n }\n }\n\n return new Request(url, { method, headers, body });\n}\n\nexport async function sendWebResponse(\n res: ServerResponse,\n response: Response,\n): Promise<void> {\n res.statusCode = response.status;\n response.headers.forEach((value, name) => {\n if (name.toLowerCase() === \"set-cookie\") return; // handled below, needs the un-joined list\n res.setHeader(name, value);\n });\n const setCookies = response.headers.getSetCookie();\n if (setCookies.length) res.setHeader(\"set-cookie\", setCookies);\n\n if (response.body) {\n const buffer = Buffer.from(await response.arrayBuffer());\n res.end(buffer);\n } else {\n res.end();\n }\n}\n\n/**\n * Adapts the auth handler to a plain Node `(req, res)` listener:\n *\n * ```ts\n * import http from \"node:http\";\n * const authListener = toNodeHandler(auth);\n * http.createServer((req, res) => {\n * if (req.url?.startsWith(\"/auth\")) return authListener(req, res);\n * // ... your app\n * });\n * ```\n */\nexport function toNodeHandler(auth: IncortaAuth) {\n return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n const request = await toWebRequest(req);\n const response = await auth.handler(request);\n await sendWebResponse(res, response);\n };\n}\n\n/** Reads the current session from a Node request (returns null when signed out). */\nexport async function getNodeSession(auth: IncortaAuth, req: IncomingMessage) {\n return auth.getSession(await toWebRequest(req));\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { AuthSession, IncortaAuth } from './index.cjs';
3
- import './config-DmeXKgtO.cjs';
3
+ export { A as AppAccess, a as AppAccessPolicy } from './app-access-BSsKtvbi.cjs';
4
4
  import 'jose';
5
5
 
6
6
  /**
@@ -36,7 +36,24 @@ interface RequireAuthOptions {
36
36
  */
37
37
  unauthenticated?: "redirect" | "json" | "auto";
38
38
  }
39
- /** Guards a route: only signed-in Incorta users get through. */
39
+ /**
40
+ * Guards a route: only signed-in Incorta users get through — regardless of
41
+ * the app's `appAccess` policy (an explicit `requireAuth` is the author's
42
+ * promise that this route needs a user, so `"public"` mode does not neuter
43
+ * it). Under the `"catalog"` policy the signed-in user must additionally
44
+ * hold at least VIEW access to this app; the resolved verdict is attached as
45
+ * `req.incortaAuth.appAccess`.
46
+ */
40
47
  declare function requireAuth(auth: IncortaAuth, options?: RequireAuthOptions): Middleware;
48
+ /**
49
+ * Whole-app gate middleware: `auth.gate` as Express/Connect middleware.
50
+ * Mount it FIRST (before your routes, instead of `incortaAuth()` — it also
51
+ * serves the auth routes) to give the entire app the platform-stamped
52
+ * `appAccess` policy: `"public"` apps let everyone through, `"authenticated"`
53
+ * apps require login, `"catalog"` apps require catalog VIEW access (with the
54
+ * SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`
55
+ * populated like `incortaAuth()` does.
56
+ */
57
+ declare function appGate(auth: IncortaAuth): Middleware;
41
58
 
42
- export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, incortaAuth, requireAuth };
59
+ export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, appGate, incortaAuth, requireAuth };
package/dist/express.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { AuthSession, IncortaAuth } from './index.js';
3
- import './config-DmeXKgtO.js';
3
+ export { A as AppAccess, a as AppAccessPolicy } from './app-access-BSsKtvbi.js';
4
4
  import 'jose';
5
5
 
6
6
  /**
@@ -36,7 +36,24 @@ interface RequireAuthOptions {
36
36
  */
37
37
  unauthenticated?: "redirect" | "json" | "auto";
38
38
  }
39
- /** Guards a route: only signed-in Incorta users get through. */
39
+ /**
40
+ * Guards a route: only signed-in Incorta users get through — regardless of
41
+ * the app's `appAccess` policy (an explicit `requireAuth` is the author's
42
+ * promise that this route needs a user, so `"public"` mode does not neuter
43
+ * it). Under the `"catalog"` policy the signed-in user must additionally
44
+ * hold at least VIEW access to this app; the resolved verdict is attached as
45
+ * `req.incortaAuth.appAccess`.
46
+ */
40
47
  declare function requireAuth(auth: IncortaAuth, options?: RequireAuthOptions): Middleware;
48
+ /**
49
+ * Whole-app gate middleware: `auth.gate` as Express/Connect middleware.
50
+ * Mount it FIRST (before your routes, instead of `incortaAuth()` — it also
51
+ * serves the auth routes) to give the entire app the platform-stamped
52
+ * `appAccess` policy: `"public"` apps let everyone through, `"authenticated"`
53
+ * apps require login, `"catalog"` apps require catalog VIEW access (with the
54
+ * SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`
55
+ * populated like `incortaAuth()` does.
56
+ */
57
+ declare function appGate(auth: IncortaAuth): Middleware;
41
58
 
42
- export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, incortaAuth, requireAuth };
59
+ export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, appGate, incortaAuth, requireAuth };
package/dist/express.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import {
2
+ AppAccessTokenRejectedError
3
+ } from "./chunk-TMXOEIA2.js";
4
+ import {
5
+ sendWebResponse,
2
6
  toNodeHandler,
3
7
  toWebRequest
4
8
  } from "./chunk-LNPECE7W.js";
@@ -30,8 +34,32 @@ function requireAuth(auth, options = {}) {
30
34
  req.incortaAuth = await auth.getSession(await toWebRequest(req));
31
35
  }
32
36
  if (req.incortaAuth) {
33
- next();
34
- return;
37
+ if (auth.config.appAccess !== "catalog") {
38
+ next();
39
+ return;
40
+ }
41
+ const session = req.incortaAuth;
42
+ try {
43
+ const verdict = await auth.getAppAccess(session);
44
+ if (verdict.allowed) {
45
+ session.appAccess = verdict;
46
+ next();
47
+ return;
48
+ }
49
+ res.statusCode = 403;
50
+ res.setHeader("content-type", "application/json; charset=utf-8");
51
+ res.end(JSON.stringify({ error: "app_access_denied" }));
52
+ return;
53
+ } catch (error) {
54
+ if (!(error instanceof AppAccessTokenRejectedError)) {
55
+ res.statusCode = 503;
56
+ res.setHeader("content-type", "application/json; charset=utf-8");
57
+ res.setHeader("retry-after", "30");
58
+ res.end(JSON.stringify({ error: "app_access_unavailable" }));
59
+ return;
60
+ }
61
+ req.incortaAuth = null;
62
+ }
35
63
  }
36
64
  const wantsHtml = (req.headers.accept ?? "").includes("text/html");
37
65
  if (mode === "redirect" || mode === "auto" && wantsHtml) {
@@ -52,7 +80,24 @@ function requireAuth(auth, options = {}) {
52
80
  }
53
81
  };
54
82
  }
83
+ function appGate(auth) {
84
+ return async (req, res, next) => {
85
+ try {
86
+ const request = await toWebRequest(req);
87
+ const response = await auth.gate(request);
88
+ if (response) {
89
+ await sendWebResponse(res, response);
90
+ return;
91
+ }
92
+ req.incortaAuth = await auth.getSession(request);
93
+ next();
94
+ } catch (error) {
95
+ next(error);
96
+ }
97
+ };
98
+ }
55
99
  export {
100
+ appGate,
56
101
  incortaAuth,
57
102
  requireAuth
58
103
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/express.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/** Guards a route: only signed-in Incorta users get through. */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n next();\n return;\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n"],"mappings":";;;;;;AAiCO,SAAS,YAAY,MAA+B;AACzD,QAAM,cAAc,cAAc,IAAI;AACtC,QAAM,EAAE,SAAS,IAAI,KAAK;AAE1B,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,YAAM,QAAQ,IAAI,eAAe,IAAI,OAAO,KAAK,MAAM,GAAG,EAAE,CAAC;AAC7D,UAAI,SAAS,YAAY,KAAK,WAAW,GAAG,QAAQ,GAAG,GAAG;AACxD,cAAM,YAAY,KAAK,GAAG;AAC1B;AAAA,MACF;AACA,UAAI,cAAc,MAAM,KAAK,WAAW,MAAM,aAAa,GAAG,CAAC;AAC/D,WAAK;AAAA,IACP,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAcO,SAAS,YAAY,MAAmB,UAA8B,CAAC,GAAe;AAC3F,QAAM,OAAO,QAAQ,mBAAmB;AACxC,QAAM,EAAE,SAAS,IAAI,KAAK;AAE1B,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,UAAI,IAAI,gBAAgB,QAAW;AACjC,YAAI,cAAc,MAAM,KAAK,WAAW,MAAM,aAAa,GAAG,CAAC;AAAA,MACjE;AACA,UAAI,IAAI,aAAa;AACnB,aAAK;AACL;AAAA,MACF;AAEA,YAAM,aAAa,IAAI,QAAQ,UAAU,IAAI,SAAS,WAAW;AACjE,UAAI,SAAS,cAAe,SAAS,UAAU,WAAY;AACzD,cAAM,WAAW,IAAI,eAAe,IAAI,OAAO;AAC/C,YAAI,aAAa;AACjB,YAAI;AAAA,UACF;AAAA,UACA,GAAG,QAAQ,sBAAsB,mBAAmB,QAAQ,CAAC;AAAA,QAC/D;AACA,YAAI,IAAI;AACR;AAAA,MACF;AAEA,UAAI,aAAa;AACjB,UAAI,UAAU,gBAAgB,iCAAiC;AAC/D,UAAI,IAAI,KAAK,UAAU,EAAE,OAAO,kBAAkB,CAAC,CAAC;AAAA,IACtD,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/express.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { AppAccessTokenRejectedError } from \"./core/app-access.js\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { sendWebResponse, toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AppAccess, AppAccessPolicy, AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/**\n * Guards a route: only signed-in Incorta users get through — regardless of\n * the app's `appAccess` policy (an explicit `requireAuth` is the author's\n * promise that this route needs a user, so `\"public\"` mode does not neuter\n * it). Under the `\"catalog\"` policy the signed-in user must additionally\n * hold at least VIEW access to this app; the resolved verdict is attached as\n * `req.incortaAuth.appAccess`.\n */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n if (auth.config.appAccess !== \"catalog\") {\n next();\n return;\n }\n const session = req.incortaAuth;\n try {\n const verdict = await auth.getAppAccess(session);\n if (verdict.allowed) {\n session.appAccess = verdict;\n next();\n return;\n }\n res.statusCode = 403;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"app_access_denied\" }));\n return;\n } catch (error) {\n if (!(error instanceof AppAccessTokenRejectedError)) {\n // Verdicts unavailable — NEVER fail open.\n res.statusCode = 503;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.setHeader(\"retry-after\", \"30\");\n res.end(JSON.stringify({ error: \"app_access_unavailable\" }));\n return;\n }\n // Incorta rejected the session's token — treat as signed out.\n req.incortaAuth = null;\n }\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Whole-app gate middleware: `auth.gate` as Express/Connect middleware.\n * Mount it FIRST (before your routes, instead of `incortaAuth()` — it also\n * serves the auth routes) to give the entire app the platform-stamped\n * `appAccess` policy: `\"public\"` apps let everyone through, `\"authenticated\"`\n * apps require login, `\"catalog\"` apps require catalog VIEW access (with the\n * SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`\n * populated like `incortaAuth()` does.\n */\nexport function appGate(auth: IncortaAuth): Middleware {\n return async (req, res, next) => {\n try {\n const request = await toWebRequest(req);\n const response = await auth.gate(request);\n if (response) {\n await sendWebResponse(res, response);\n return;\n }\n req.incortaAuth = await auth.getSession(request);\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n"],"mappings":";;;;;;;;;;AAkCO,SAAS,YAAY,MAA+B;AACzD,QAAM,cAAc,cAAc,IAAI;AACtC,QAAM,EAAE,SAAS,IAAI,KAAK;AAE1B,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,YAAM,QAAQ,IAAI,eAAe,IAAI,OAAO,KAAK,MAAM,GAAG,EAAE,CAAC;AAC7D,UAAI,SAAS,YAAY,KAAK,WAAW,GAAG,QAAQ,GAAG,GAAG;AACxD,cAAM,YAAY,KAAK,GAAG;AAC1B;AAAA,MACF;AACA,UAAI,cAAc,MAAM,KAAK,WAAW,MAAM,aAAa,GAAG,CAAC;AAC/D,WAAK;AAAA,IACP,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAqBO,SAAS,YAAY,MAAmB,UAA8B,CAAC,GAAe;AAC3F,QAAM,OAAO,QAAQ,mBAAmB;AACxC,QAAM,EAAE,SAAS,IAAI,KAAK;AAE1B,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,UAAI,IAAI,gBAAgB,QAAW;AACjC,YAAI,cAAc,MAAM,KAAK,WAAW,MAAM,aAAa,GAAG,CAAC;AAAA,MACjE;AACA,UAAI,IAAI,aAAa;AACnB,YAAI,KAAK,OAAO,cAAc,WAAW;AACvC,eAAK;AACL;AAAA,QACF;AACA,cAAM,UAAU,IAAI;AACpB,YAAI;AACF,gBAAM,UAAU,MAAM,KAAK,aAAa,OAAO;AAC/C,cAAI,QAAQ,SAAS;AACnB,oBAAQ,YAAY;AACpB,iBAAK;AACL;AAAA,UACF;AACA,cAAI,aAAa;AACjB,cAAI,UAAU,gBAAgB,iCAAiC;AAC/D,cAAI,IAAI,KAAK,UAAU,EAAE,OAAO,oBAAoB,CAAC,CAAC;AACtD;AAAA,QACF,SAAS,OAAO;AACd,cAAI,EAAE,iBAAiB,8BAA8B;AAEnD,gBAAI,aAAa;AACjB,gBAAI,UAAU,gBAAgB,iCAAiC;AAC/D,gBAAI,UAAU,eAAe,IAAI;AACjC,gBAAI,IAAI,KAAK,UAAU,EAAE,OAAO,yBAAyB,CAAC,CAAC;AAC3D;AAAA,UACF;AAEA,cAAI,cAAc;AAAA,QACpB;AAAA,MACF;AAEA,YAAM,aAAa,IAAI,QAAQ,UAAU,IAAI,SAAS,WAAW;AACjE,UAAI,SAAS,cAAe,SAAS,UAAU,WAAY;AACzD,cAAM,WAAW,IAAI,eAAe,IAAI,OAAO;AAC/C,YAAI,aAAa;AACjB,YAAI;AAAA,UACF;AAAA,UACA,GAAG,QAAQ,sBAAsB,mBAAmB,QAAQ,CAAC;AAAA,QAC/D;AACA,YAAI,IAAI;AACR;AAAA,MACF;AAEA,UAAI,aAAa;AACjB,UAAI,UAAU,gBAAgB,iCAAiC;AAC/D,UAAI,IAAI,KAAK,UAAU,EAAE,OAAO,kBAAkB,CAAC,CAAC;AAAA,IACtD,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAWO,SAAS,QAAQ,MAA+B;AACrD,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,YAAM,UAAU,MAAM,aAAa,GAAG;AACtC,YAAM,WAAW,MAAM,KAAK,KAAK,OAAO;AACxC,UAAI,UAAU;AACZ,cAAM,gBAAgB,KAAK,QAAQ;AACnC;AAAA,MACF;AACA,UAAI,cAAc,MAAM,KAAK,WAAW,OAAO;AAC/C,WAAK;AAAA,IACP,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;","names":[]}