@mirrorstack-ai/app-module-client 0.5.1

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 (70) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +202 -0
  3. package/README.md +488 -0
  4. package/dist/base-url.d.ts +39 -0
  5. package/dist/base-url.js +51 -0
  6. package/dist/base-url.js.map +1 -0
  7. package/dist/client.d.ts +56 -0
  8. package/dist/client.js +85 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/error.d.ts +29 -0
  11. package/dist/error.js +100 -0
  12. package/dist/error.js.map +1 -0
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.js +6 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/next/auth-routes.d.ts +82 -0
  17. package/dist/next/auth-routes.js +164 -0
  18. package/dist/next/auth-routes.js.map +1 -0
  19. package/dist/next/index.d.ts +12 -0
  20. package/dist/next/index.js +13 -0
  21. package/dist/next/index.js.map +1 -0
  22. package/dist/next/module-proxy-routes.d.ts +81 -0
  23. package/dist/next/module-proxy-routes.js +77 -0
  24. package/dist/next/module-proxy-routes.js.map +1 -0
  25. package/dist/plugin.d.ts +39 -0
  26. package/dist/plugin.js +39 -0
  27. package/dist/plugin.js.map +1 -0
  28. package/dist/response.d.ts +13 -0
  29. package/dist/response.js +80 -0
  30. package/dist/response.js.map +1 -0
  31. package/dist/server/index.d.ts +7 -0
  32. package/dist/server/index.js +8 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/member-sessions.d.ts +80 -0
  35. package/dist/server/member-sessions.js +162 -0
  36. package/dist/server/member-sessions.js.map +1 -0
  37. package/dist/transport.d.ts +98 -0
  38. package/dist/transport.js +299 -0
  39. package/dist/transport.js.map +1 -0
  40. package/dist/web/cache.d.ts +33 -0
  41. package/dist/web/cache.js +130 -0
  42. package/dist/web/cache.js.map +1 -0
  43. package/dist/web/component-mount.d.ts +23 -0
  44. package/dist/web/component-mount.js +111 -0
  45. package/dist/web/component-mount.js.map +1 -0
  46. package/dist/web/index.d.ts +6 -0
  47. package/dist/web/index.js +7 -0
  48. package/dist/web/index.js.map +1 -0
  49. package/dist/web/localized-text.d.ts +10 -0
  50. package/dist/web/localized-text.js +36 -0
  51. package/dist/web/localized-text.js.map +1 -0
  52. package/dist/web/react.d.ts +18 -0
  53. package/dist/web/react.js +111 -0
  54. package/dist/web/react.js.map +1 -0
  55. package/dist/web/runtime.d.ts +60 -0
  56. package/dist/web/runtime.js +73 -0
  57. package/dist/web/runtime.js.map +1 -0
  58. package/dist/web/subpath.d.ts +22 -0
  59. package/dist/web/subpath.js +50 -0
  60. package/dist/web/subpath.js.map +1 -0
  61. package/dist/web/types.d.ts +129 -0
  62. package/dist/web/types.js +2 -0
  63. package/dist/web/types.js.map +1 -0
  64. package/dist/web/use-now.d.ts +7 -0
  65. package/dist/web/use-now.js +22 -0
  66. package/dist/web/use-now.js.map +1 -0
  67. package/dist/web/use-platform-unsaved-state.d.ts +10 -0
  68. package/dist/web/use-platform-unsaved-state.js +35 -0
  69. package/dist/web/use-platform-unsaved-state.js.map +1 -0
  70. package/package.json +88 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The ONE server hop a browser module client talks through.
3
+ *
4
+ * 🔴 WHY THIS EXISTS AT ALL. A custom app keeps its member credential in an
5
+ * HttpOnly cookie, which is the point of an HttpOnly cookie — script cannot
6
+ * read it, so the browser cannot call the platform itself. That is the ONLY
7
+ * thing the browser is missing. This supplies exactly that and forwards
8
+ * everything else untouched.
9
+ *
10
+ * 🔴 WHY IT IS HERE RATHER THAN IN EACH APP. Every app that keeps its
11
+ * credential in a cookie needs byte-identical code, and two lines of it are
12
+ * invisible until they bite:
13
+ *
14
+ * - `duplex: "half"` is REQUIRED by undici whenever the body is a stream,
15
+ * which it is for any upload. Without it the fetch throws before a byte
16
+ * leaves, and the error names neither the upload nor the cause.
17
+ * - `content-encoding` / `content-length` MUST be dropped from the response.
18
+ * The body has already been decoded by the time it is re-sent, so a copied
19
+ * `content-length` describes bytes that no longer exist and the request
20
+ * hangs rather than failing.
21
+ *
22
+ * An app that writes this by hand gets to discover both. kaohsiung-association
23
+ * carried a hand-written copy of this file, 98 lines, containing no knowledge
24
+ * of any module — which is what made it boilerplate rather than app code.
25
+ *
26
+ * The alternative shape — a hand-written endpoint per operation — re-declares
27
+ * each module's contract inside the app: one more place to keep in step every
28
+ * time a module changes, and the generated client's types and errors are
29
+ * discarded on the way through. With this mounted the browser uses the real
30
+ * module client and this file never learns what any call means.
31
+ *
32
+ * 🔴 IT FORWARDS, IT DOES NOT DECIDE. Authorization remains the platform's and
33
+ * the modules' answer on every request. This attaches a credential the member
34
+ * already holds and grants nothing that credential does not carry.
35
+ */
36
+ /** Inputs for {@link createModuleProxyRoutes}. */
37
+ export interface ModuleProxyRoutesOptions {
38
+ /** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL`. */
39
+ readonly apiUrl: string;
40
+ /** The custom application's slug, typically `MIRRORSTACK_APP_SLUG`. */
41
+ readonly appSlug: string;
42
+ /**
43
+ * Reads the member credential for the current request. Pass the
44
+ * `readMemberCredential` from {@link createAuthRoutes}, so the proxy and
45
+ * sign-in cannot disagree about where the session lives.
46
+ */
47
+ readonly readMemberCredential: () => Promise<string | null>;
48
+ /** Fetch implementation for the upstream call. Defaults to `globalThis.fetch`. */
49
+ readonly fetch?: typeof globalThis.fetch;
50
+ }
51
+ /** A Next.js App Router route module: one handler per forwarded method. */
52
+ export interface ModuleProxyRoutes {
53
+ GET(request: Request, context: RouteContext): Promise<Response>;
54
+ POST(request: Request, context: RouteContext): Promise<Response>;
55
+ PUT(request: Request, context: RouteContext): Promise<Response>;
56
+ PATCH(request: Request, context: RouteContext): Promise<Response>;
57
+ DELETE(request: Request, context: RouteContext): Promise<Response>;
58
+ }
59
+ /** The second argument Next hands a catch-all route handler. */
60
+ export interface RouteContext {
61
+ readonly params: Promise<{
62
+ path: string[];
63
+ }>;
64
+ }
65
+ /**
66
+ * Build the catch-all route handlers for a module proxy.
67
+ *
68
+ * Mount at `app/api/mirrorstack/modules/[...path]/route.ts`:
69
+ *
70
+ * ```ts
71
+ * export const runtime = "nodejs";
72
+ * export const { GET, POST, PUT, PATCH, DELETE } = createModuleProxyRoutes({
73
+ * apiUrl: process.env.NEXT_PUBLIC_MIRRORSTACK_API!,
74
+ * appSlug: process.env.NEXT_PUBLIC_APP_ID!,
75
+ * readMemberCredential: auth.readMemberCredential,
76
+ * });
77
+ * ```
78
+ *
79
+ * The browser client's `baseUrl` is then that same mount path.
80
+ */
81
+ export declare function createModuleProxyRoutes(options: ModuleProxyRoutesOptions): ModuleProxyRoutes;
@@ -0,0 +1,77 @@
1
+ import { platformBaseUrl } from "../base-url.js";
2
+ /**
3
+ * Headers that must NOT be replayed upstream.
4
+ *
5
+ * `cookie` above all: the app's session cookie is its own, and forwarding it
6
+ * hands a second credential to a service that never asked for one. The length
7
+ * and hop-by-hop headers are re-derived by fetch, and a stale `content-length`
8
+ * copied onto a re-encoded body is a hung request. `authorization` is dropped
9
+ * because this hop sets it — a caller-supplied one must never survive.
10
+ */
11
+ const STRIPPED_REQUEST_HEADERS = new Set([
12
+ "cookie",
13
+ "host",
14
+ "connection",
15
+ "content-length",
16
+ "transfer-encoding",
17
+ "authorization",
18
+ "accept-encoding",
19
+ ]);
20
+ /**
21
+ * Build the catch-all route handlers for a module proxy.
22
+ *
23
+ * Mount at `app/api/mirrorstack/modules/[...path]/route.ts`:
24
+ *
25
+ * ```ts
26
+ * export const runtime = "nodejs";
27
+ * export const { GET, POST, PUT, PATCH, DELETE } = createModuleProxyRoutes({
28
+ * apiUrl: process.env.NEXT_PUBLIC_MIRRORSTACK_API!,
29
+ * appSlug: process.env.NEXT_PUBLIC_APP_ID!,
30
+ * readMemberCredential: auth.readMemberCredential,
31
+ * });
32
+ * ```
33
+ *
34
+ * The browser client's `baseUrl` is then that same mount path.
35
+ */
36
+ export function createModuleProxyRoutes(options) {
37
+ const doFetch = options.fetch ?? globalThis.fetch;
38
+ const base = platformBaseUrl({ apiUrl: options.apiUrl, appSlug: options.appSlug });
39
+ async function forward(request, context) {
40
+ const credential = await options.readMemberCredential();
41
+ if (!credential)
42
+ return new Response(null, { status: 401 });
43
+ const { path } = await context.params;
44
+ const target = new URL(`${base}/${path.join("/")}`);
45
+ target.search = new URL(request.url).search;
46
+ const headers = new Headers();
47
+ for (const [name, value] of request.headers) {
48
+ if (!STRIPPED_REQUEST_HEADERS.has(name.toLowerCase()))
49
+ headers.set(name, value);
50
+ }
51
+ headers.set("authorization", `Bearer ${credential}`);
52
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
53
+ const response = await doFetch(target, {
54
+ method: request.method,
55
+ headers,
56
+ body: hasBody ? request.body : undefined,
57
+ duplex: "half",
58
+ redirect: "manual",
59
+ });
60
+ // The upstream status and body are returned as they are, so a module's own
61
+ // "too large" or "wrong type" reaches the caller instead of a generic
62
+ // failure nobody can act on.
63
+ const out = new Headers(response.headers);
64
+ out.delete("content-encoding");
65
+ out.delete("content-length");
66
+ out.delete("transfer-encoding");
67
+ return new Response(response.body, { status: response.status, headers: out });
68
+ }
69
+ return {
70
+ GET: forward,
71
+ POST: forward,
72
+ PUT: forward,
73
+ PATCH: forward,
74
+ DELETE: forward,
75
+ };
76
+ }
77
+ //# sourceMappingURL=module-proxy-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module-proxy-routes.js","sourceRoot":"","sources":["../../src/next/module-proxy-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAoEjD;;;;;;;;GAQG;AACH,MAAM,wBAAwB,GAAG,IAAI,GAAG,CAAC;IACvC,QAAQ;IACR,MAAM;IACN,YAAY;IACZ,gBAAgB;IAChB,mBAAmB;IACnB,eAAe;IACf,iBAAiB;CAClB,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,uBAAuB,CAAC,OAAiC;IACvE,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC;IAClD,MAAM,IAAI,GAAG,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAEnF,KAAK,UAAU,OAAO,CAAC,OAAgB,EAAE,OAAqB;QAC5D,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,oBAAoB,EAAE,CAAC;QACxD,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAE5D,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC;QACtC,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACpD,MAAM,CAAC,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;QAE5C,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;QAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YAC5C,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;gBAAE,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAClF,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,UAAU,EAAE,CAAC,CAAC;QAErD,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC;QACtE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,MAAM,EAAE;YACrC,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,OAAO;YACP,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;YACxC,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;SACiB,CAAC,CAAC;QAEvC,2EAA2E;QAC3E,sEAAsE;QACtE,6BAA6B;QAC7B,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC1C,GAAG,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAC/B,GAAG,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;QAC7B,GAAG,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;QAChC,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IAChF,CAAC;IAED,OAAO;QACL,GAAG,EAAE,OAAO;QACZ,IAAI,EAAE,OAAO;QACb,GAAG,EAAE,OAAO;QACZ,KAAK,EAAE,OAAO;QACd,MAAM,EAAE,OAAO;KAChB,CAAC;AACJ,CAAC","sourcesContent":["import { platformBaseUrl } from \"../base-url.js\";\n\n/**\n * The ONE server hop a browser module client talks through.\n *\n * 🔴 WHY THIS EXISTS AT ALL. A custom app keeps its member credential in an\n * HttpOnly cookie, which is the point of an HttpOnly cookie — script cannot\n * read it, so the browser cannot call the platform itself. That is the ONLY\n * thing the browser is missing. This supplies exactly that and forwards\n * everything else untouched.\n *\n * 🔴 WHY IT IS HERE RATHER THAN IN EACH APP. Every app that keeps its\n * credential in a cookie needs byte-identical code, and two lines of it are\n * invisible until they bite:\n *\n * - `duplex: \"half\"` is REQUIRED by undici whenever the body is a stream,\n * which it is for any upload. Without it the fetch throws before a byte\n * leaves, and the error names neither the upload nor the cause.\n * - `content-encoding` / `content-length` MUST be dropped from the response.\n * The body has already been decoded by the time it is re-sent, so a copied\n * `content-length` describes bytes that no longer exist and the request\n * hangs rather than failing.\n *\n * An app that writes this by hand gets to discover both. kaohsiung-association\n * carried a hand-written copy of this file, 98 lines, containing no knowledge\n * of any module — which is what made it boilerplate rather than app code.\n *\n * The alternative shape — a hand-written endpoint per operation — re-declares\n * each module's contract inside the app: one more place to keep in step every\n * time a module changes, and the generated client's types and errors are\n * discarded on the way through. With this mounted the browser uses the real\n * module client and this file never learns what any call means.\n *\n * 🔴 IT FORWARDS, IT DOES NOT DECIDE. Authorization remains the platform's and\n * the modules' answer on every request. This attaches a credential the member\n * already holds and grants nothing that credential does not carry.\n */\n\n/** Inputs for {@link createModuleProxyRoutes}. */\nexport interface ModuleProxyRoutesOptions {\n /** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL`. */\n readonly apiUrl: string;\n /** The custom application's slug, typically `MIRRORSTACK_APP_SLUG`. */\n readonly appSlug: string;\n /**\n * Reads the member credential for the current request. Pass the\n * `readMemberCredential` from {@link createAuthRoutes}, so the proxy and\n * sign-in cannot disagree about where the session lives.\n */\n readonly readMemberCredential: () => Promise<string | null>;\n /** Fetch implementation for the upstream call. Defaults to `globalThis.fetch`. */\n readonly fetch?: typeof globalThis.fetch;\n}\n\n/** A Next.js App Router route module: one handler per forwarded method. */\nexport interface ModuleProxyRoutes {\n GET(request: Request, context: RouteContext): Promise<Response>;\n POST(request: Request, context: RouteContext): Promise<Response>;\n PUT(request: Request, context: RouteContext): Promise<Response>;\n PATCH(request: Request, context: RouteContext): Promise<Response>;\n DELETE(request: Request, context: RouteContext): Promise<Response>;\n}\n\n/** The second argument Next hands a catch-all route handler. */\nexport interface RouteContext {\n readonly params: Promise<{ path: string[] }>;\n}\n\n/**\n * Headers that must NOT be replayed upstream.\n *\n * `cookie` above all: the app's session cookie is its own, and forwarding it\n * hands a second credential to a service that never asked for one. The length\n * and hop-by-hop headers are re-derived by fetch, and a stale `content-length`\n * copied onto a re-encoded body is a hung request. `authorization` is dropped\n * because this hop sets it — a caller-supplied one must never survive.\n */\nconst STRIPPED_REQUEST_HEADERS = new Set([\n \"cookie\",\n \"host\",\n \"connection\",\n \"content-length\",\n \"transfer-encoding\",\n \"authorization\",\n \"accept-encoding\",\n]);\n\n/**\n * Build the catch-all route handlers for a module proxy.\n *\n * Mount at `app/api/mirrorstack/modules/[...path]/route.ts`:\n *\n * ```ts\n * export const runtime = \"nodejs\";\n * export const { GET, POST, PUT, PATCH, DELETE } = createModuleProxyRoutes({\n * apiUrl: process.env.NEXT_PUBLIC_MIRRORSTACK_API!,\n * appSlug: process.env.NEXT_PUBLIC_APP_ID!,\n * readMemberCredential: auth.readMemberCredential,\n * });\n * ```\n *\n * The browser client's `baseUrl` is then that same mount path.\n */\nexport function createModuleProxyRoutes(options: ModuleProxyRoutesOptions): ModuleProxyRoutes {\n const doFetch = options.fetch ?? globalThis.fetch;\n const base = platformBaseUrl({ apiUrl: options.apiUrl, appSlug: options.appSlug });\n\n async function forward(request: Request, context: RouteContext): Promise<Response> {\n const credential = await options.readMemberCredential();\n if (!credential) return new Response(null, { status: 401 });\n\n const { path } = await context.params;\n const target = new URL(`${base}/${path.join(\"/\")}`);\n target.search = new URL(request.url).search;\n\n const headers = new Headers();\n for (const [name, value] of request.headers) {\n if (!STRIPPED_REQUEST_HEADERS.has(name.toLowerCase())) headers.set(name, value);\n }\n headers.set(\"authorization\", `Bearer ${credential}`);\n\n const hasBody = request.method !== \"GET\" && request.method !== \"HEAD\";\n const response = await doFetch(target, {\n method: request.method,\n headers,\n body: hasBody ? request.body : undefined,\n duplex: \"half\",\n redirect: \"manual\",\n } as RequestInit & { duplex: \"half\" });\n\n // The upstream status and body are returned as they are, so a module's own\n // \"too large\" or \"wrong type\" reaches the caller instead of a generic\n // failure nobody can act on.\n const out = new Headers(response.headers);\n out.delete(\"content-encoding\");\n out.delete(\"content-length\");\n out.delete(\"transfer-encoding\");\n return new Response(response.body, { status: response.status, headers: out });\n }\n\n return {\n GET: forward,\n POST: forward,\n PUT: forward,\n PATCH: forward,\n DELETE: forward,\n };\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import type { ScopedTransport } from "./transport.js";
2
+ /** The plugin contract version understood by this package. */
3
+ export declare const MODULE_CLIENT_API_VERSION: 1;
4
+ /** A module HTTP surface that browser and server applications may call. */
5
+ export type ModuleScope = "public" | "platform";
6
+ /** Transports provided to a module plugin while its typed API is created. */
7
+ export interface ModuleClientContext {
8
+ /** Anonymous or module-defined-auth endpoints. */
9
+ readonly public: ScopedTransport;
10
+ /** MirrorStack platform-authenticated endpoints. */
11
+ readonly platform: ScopedTransport;
12
+ }
13
+ /** A statically composed module client plugin. */
14
+ export interface ModuleClientPlugin<TApi = unknown> {
15
+ /** Runtime compatibility marker. */
16
+ readonly apiVersion: typeof MODULE_CLIENT_API_VERSION;
17
+ /** Canonical 1-16 character catalog slug or UUID used as the dispatch segment. */
18
+ readonly moduleRef: string;
19
+ /** Creates the module-specific API from its two allowed transports. */
20
+ readonly create: (context: ModuleClientContext) => TApi;
21
+ }
22
+ /** Definition accepted by {@link defineModuleClient}. */
23
+ export interface ModuleClientDefinition<TApi> {
24
+ /** Canonical 1-16 character catalog slug or UUID; never the Go SDK `Config.ID`. */
25
+ readonly moduleRef: string;
26
+ /** Creates the public typed surface exposed at `client.modules.<alias>`. */
27
+ readonly create: (context: ModuleClientContext) => TApi;
28
+ }
29
+ /** @internal */
30
+ export declare function assertModuleRef(moduleRef: string): void;
31
+ /** @internal */
32
+ export declare function assertCatalogSlug(value: string, label: string): void;
33
+ /**
34
+ * Defines a module plugin while preserving the return type of `create`.
35
+ *
36
+ * The explicit definition is the only discovery mechanism: importing a plugin
37
+ * never mutates a registry or changes another application client.
38
+ */
39
+ export declare function defineModuleClient<TApi>(definition: ModuleClientDefinition<TApi>): ModuleClientPlugin<TApi>;
package/dist/plugin.js ADDED
@@ -0,0 +1,39 @@
1
+ /** The plugin contract version understood by this package. */
2
+ export const MODULE_CLIENT_API_VERSION = 1;
3
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
4
+ // Mirrors the Go module SDK's canonical 1-16 byte catalog slug contract.
5
+ const CATALOG_SLUG_PATTERN = /^[a-z][a-z0-9-]{0,15}$/;
6
+ /** @internal */
7
+ export function assertModuleRef(moduleRef) {
8
+ if (typeof moduleRef !== "string" ||
9
+ (!UUID_PATTERN.test(moduleRef) && !CATALOG_SLUG_PATTERN.test(moduleRef))) {
10
+ throw new TypeError("moduleRef must be a lowercase catalog slug or UUID (not the module SDK Config.ID)");
11
+ }
12
+ }
13
+ /** @internal */
14
+ export function assertCatalogSlug(value, label) {
15
+ if (typeof value !== "string" || !CATALOG_SLUG_PATTERN.test(value)) {
16
+ throw new TypeError(`${label} must be a lowercase catalog slug matching [a-z][a-z0-9-]{0,15}`);
17
+ }
18
+ }
19
+ /**
20
+ * Defines a module plugin while preserving the return type of `create`.
21
+ *
22
+ * The explicit definition is the only discovery mechanism: importing a plugin
23
+ * never mutates a registry or changes another application client.
24
+ */
25
+ export function defineModuleClient(definition) {
26
+ if (definition === null || typeof definition !== "object") {
27
+ throw new TypeError("module client definition must be an object");
28
+ }
29
+ assertModuleRef(definition.moduleRef);
30
+ if (typeof definition.create !== "function") {
31
+ throw new TypeError("module client definition requires a create function");
32
+ }
33
+ return Object.freeze({
34
+ apiVersion: MODULE_CLIENT_API_VERSION,
35
+ moduleRef: definition.moduleRef,
36
+ create: definition.create,
37
+ });
38
+ }
39
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,8DAA8D;AAC9D,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAU,CAAC;AA+BpD,MAAM,YAAY,GAAG,gEAAgE,CAAC;AACtF,yEAAyE;AACzE,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAEtD,gBAAgB;AAChB,MAAM,UAAU,eAAe,CAAC,SAAiB;IAC/C,IACE,OAAO,SAAS,KAAK,QAAQ;QAC7B,CAAC,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,EACxE,CAAC;QACD,MAAM,IAAI,SAAS,CACjB,mFAAmF,CACpF,CAAC;IACJ,CAAC;AACH,CAAC;AAED,gBAAgB;AAChB,MAAM,UAAU,iBAAiB,CAAC,KAAa,EAAE,KAAa;IAC5D,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACnE,MAAM,IAAI,SAAS,CAAC,GAAG,KAAK,iEAAiE,CAAC,CAAC;IACjG,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAChC,UAAwC;IAExC,IAAI,UAAU,KAAK,IAAI,IAAI,OAAO,UAAU,KAAK,QAAQ,EAAE,CAAC;QAC1D,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,eAAe,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACtC,IAAI,OAAO,UAAU,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;QAC5C,MAAM,IAAI,SAAS,CAAC,qDAAqD,CAAC,CAAC;IAC7E,CAAC;IAED,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,UAAU,EAAE,yBAAyB;QACrC,SAAS,EAAE,UAAU,CAAC,SAAS;QAC/B,MAAM,EAAE,UAAU,CAAC,MAAM;KAC1B,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type { ScopedTransport } from \"./transport.js\";\n\n/** The plugin contract version understood by this package. */\nexport const MODULE_CLIENT_API_VERSION = 1 as const;\n\n/** A module HTTP surface that browser and server applications may call. */\nexport type ModuleScope = \"public\" | \"platform\";\n\n/** Transports provided to a module plugin while its typed API is created. */\nexport interface ModuleClientContext {\n /** Anonymous or module-defined-auth endpoints. */\n readonly public: ScopedTransport;\n /** MirrorStack platform-authenticated endpoints. */\n readonly platform: ScopedTransport;\n}\n\n/** A statically composed module client plugin. */\nexport interface ModuleClientPlugin<TApi = unknown> {\n /** Runtime compatibility marker. */\n readonly apiVersion: typeof MODULE_CLIENT_API_VERSION;\n /** Canonical 1-16 character catalog slug or UUID used as the dispatch segment. */\n readonly moduleRef: string;\n /** Creates the module-specific API from its two allowed transports. */\n readonly create: (context: ModuleClientContext) => TApi;\n}\n\n/** Definition accepted by {@link defineModuleClient}. */\nexport interface ModuleClientDefinition<TApi> {\n /** Canonical 1-16 character catalog slug or UUID; never the Go SDK `Config.ID`. */\n readonly moduleRef: string;\n /** Creates the public typed surface exposed at `client.modules.<alias>`. */\n readonly create: (context: ModuleClientContext) => TApi;\n}\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\n// Mirrors the Go module SDK's canonical 1-16 byte catalog slug contract.\nconst CATALOG_SLUG_PATTERN = /^[a-z][a-z0-9-]{0,15}$/;\n\n/** @internal */\nexport function assertModuleRef(moduleRef: string): void {\n if (\n typeof moduleRef !== \"string\" ||\n (!UUID_PATTERN.test(moduleRef) && !CATALOG_SLUG_PATTERN.test(moduleRef))\n ) {\n throw new TypeError(\n \"moduleRef must be a lowercase catalog slug or UUID (not the module SDK Config.ID)\",\n );\n }\n}\n\n/** @internal */\nexport function assertCatalogSlug(value: string, label: string): void {\n if (typeof value !== \"string\" || !CATALOG_SLUG_PATTERN.test(value)) {\n throw new TypeError(`${label} must be a lowercase catalog slug matching [a-z][a-z0-9-]{0,15}`);\n }\n}\n\n/**\n * Defines a module plugin while preserving the return type of `create`.\n *\n * The explicit definition is the only discovery mechanism: importing a plugin\n * never mutates a registry or changes another application client.\n */\nexport function defineModuleClient<TApi>(\n definition: ModuleClientDefinition<TApi>,\n): ModuleClientPlugin<TApi> {\n if (definition === null || typeof definition !== \"object\") {\n throw new TypeError(\"module client definition must be an object\");\n }\n assertModuleRef(definition.moduleRef);\n if (typeof definition.create !== \"function\") {\n throw new TypeError(\"module client definition requires a create function\");\n }\n\n return Object.freeze({\n apiVersion: MODULE_CLIENT_API_VERSION,\n moduleRef: definition.moduleRef,\n create: definition.create,\n });\n}\n"]}
@@ -0,0 +1,13 @@
1
+ /** Default maximum body size parsed by module transports (one mebibyte). */
2
+ export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
3
+ /** Raised when a successful parsed response exceeds its configured body limit. */
4
+ export declare class ModuleResponseTooLargeError extends Error {
5
+ readonly limitBytes: number;
6
+ constructor(limitBytes: number);
7
+ }
8
+ /** @internal */
9
+ export declare function resolveMaxResponseBytes(value: number | undefined): number;
10
+ /** @internal */
11
+ export declare function cancelResponseBody(response: Response, reason?: unknown): void;
12
+ /** @internal */
13
+ export declare function readResponseText(response: Response, maxResponseBytes: number): Promise<string>;
@@ -0,0 +1,80 @@
1
+ /** Default maximum body size parsed by module transports (one mebibyte). */
2
+ export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
3
+ /** Raised when a successful parsed response exceeds its configured body limit. */
4
+ export class ModuleResponseTooLargeError extends Error {
5
+ limitBytes;
6
+ constructor(limitBytes) {
7
+ super(`Module response body exceeds the ${limitBytes} byte limit`);
8
+ this.name = "ModuleResponseTooLargeError";
9
+ this.limitBytes = limitBytes;
10
+ }
11
+ }
12
+ /** @internal */
13
+ export function resolveMaxResponseBytes(value) {
14
+ if (value === undefined)
15
+ return DEFAULT_MAX_RESPONSE_BYTES;
16
+ if (!Number.isSafeInteger(value) || value <= 0) {
17
+ throw new TypeError("maxResponseBytes must be a positive safe integer");
18
+ }
19
+ return value;
20
+ }
21
+ function cancelStream(stream, reason) {
22
+ try {
23
+ // A cloned response is one branch of a tee. Awaiting its cancellation can
24
+ // wait for the other branch, so cancellation is deliberately best-effort.
25
+ void stream.cancel(reason).catch(() => undefined);
26
+ }
27
+ catch {
28
+ // Preserve the response-size or caller-visible lifecycle result.
29
+ }
30
+ }
31
+ /** @internal */
32
+ export function cancelResponseBody(response, reason) {
33
+ if (response.body !== null)
34
+ cancelStream(response.body, reason);
35
+ }
36
+ /** @internal */
37
+ export async function readResponseText(response, maxResponseBytes) {
38
+ const declaredLength = response.headers.get("content-length");
39
+ if (declaredLength !== null) {
40
+ const declaredBytes = Number(declaredLength);
41
+ if (Number.isSafeInteger(declaredBytes)
42
+ && declaredBytes >= 0
43
+ && declaredBytes > maxResponseBytes) {
44
+ const error = new ModuleResponseTooLargeError(maxResponseBytes);
45
+ cancelResponseBody(response, error);
46
+ throw error;
47
+ }
48
+ }
49
+ if (response.body === null)
50
+ return "";
51
+ const reader = response.body.getReader();
52
+ const decoder = new TextDecoder();
53
+ const parts = [];
54
+ let receivedBytes = 0;
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done)
59
+ break;
60
+ receivedBytes += value.byteLength;
61
+ if (receivedBytes > maxResponseBytes) {
62
+ const error = new ModuleResponseTooLargeError(maxResponseBytes);
63
+ try {
64
+ void reader.cancel(error).catch(() => undefined);
65
+ }
66
+ catch {
67
+ // Preserve the deterministic response-size error.
68
+ }
69
+ throw error;
70
+ }
71
+ parts.push(decoder.decode(value, { stream: true }));
72
+ }
73
+ parts.push(decoder.decode());
74
+ return parts.join("");
75
+ }
76
+ finally {
77
+ reader.releaseLock();
78
+ }
79
+ }
80
+ //# sourceMappingURL=response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response.js","sourceRoot":"","sources":["../src/response.ts"],"names":[],"mappings":"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","sourcesContent":["/** Default maximum body size parsed by module transports (one mebibyte). */\nexport const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;\n\n/** Raised when a successful parsed response exceeds its configured body limit. */\nexport class ModuleResponseTooLargeError extends Error {\n readonly limitBytes: number;\n\n constructor(limitBytes: number) {\n super(`Module response body exceeds the ${limitBytes} byte limit`);\n this.name = \"ModuleResponseTooLargeError\";\n this.limitBytes = limitBytes;\n }\n}\n\n/** @internal */\nexport function resolveMaxResponseBytes(value: number | undefined): number {\n if (value === undefined) return DEFAULT_MAX_RESPONSE_BYTES;\n if (!Number.isSafeInteger(value) || value <= 0) {\n throw new TypeError(\"maxResponseBytes must be a positive safe integer\");\n }\n return value;\n}\n\nfunction cancelStream(\n stream: ReadableStream<Uint8Array>,\n reason?: unknown,\n): void {\n try {\n // A cloned response is one branch of a tee. Awaiting its cancellation can\n // wait for the other branch, so cancellation is deliberately best-effort.\n void stream.cancel(reason).catch(() => undefined);\n } catch {\n // Preserve the response-size or caller-visible lifecycle result.\n }\n}\n\n/** @internal */\nexport function cancelResponseBody(response: Response, reason?: unknown): void {\n if (response.body !== null) cancelStream(response.body, reason);\n}\n\n/** @internal */\nexport async function readResponseText(\n response: Response,\n maxResponseBytes: number,\n): Promise<string> {\n const declaredLength = response.headers.get(\"content-length\");\n if (declaredLength !== null) {\n const declaredBytes = Number(declaredLength);\n if (\n Number.isSafeInteger(declaredBytes)\n && declaredBytes >= 0\n && declaredBytes > maxResponseBytes\n ) {\n const error = new ModuleResponseTooLargeError(maxResponseBytes);\n cancelResponseBody(response, error);\n throw error;\n }\n }\n\n if (response.body === null) return \"\";\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n const parts: string[] = [];\n let receivedBytes = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n receivedBytes += value.byteLength;\n if (receivedBytes > maxResponseBytes) {\n const error = new ModuleResponseTooLargeError(maxResponseBytes);\n try {\n void reader.cancel(error).catch(() => undefined);\n } catch {\n // Preserve the deterministic response-size error.\n }\n throw error;\n }\n parts.push(decoder.decode(value, { stream: true }));\n }\n parts.push(decoder.decode());\n return parts.join(\"\");\n } finally {\n reader.releaseLock();\n }\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Server-side helpers for a custom web app: the platform member-session
3
+ * control plane (exchange a provider handoff for a credential, revoke it).
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export { memberSessions, MemberSessionError, type MemberIdentity, type MemberSession, type MemberSessionsApi, type MemberSessionsOptions, type RevokeOutcome, } from "./member-sessions.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Server-side helpers for a custom web app: the platform member-session
3
+ * control plane (exchange a provider handoff for a credential, revoke it).
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export { memberSessions, MemberSessionError, } from "./member-sessions.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,cAAc,EACd,kBAAkB,GAMnB,MAAM,sBAAsB,CAAC","sourcesContent":["/**\n * Server-side helpers for a custom web app: the platform member-session\n * control plane (exchange a provider handoff for a credential, revoke it).\n *\n * @packageDocumentation\n */\n\nexport {\n memberSessions,\n MemberSessionError,\n type MemberIdentity,\n type MemberSession,\n type MemberSessionsApi,\n type MemberSessionsOptions,\n type RevokeOutcome,\n} from \"./member-sessions.js\";\n"]}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * App-scoped platform member sessions — the control plane a custom web app
3
+ * uses to turn an auth provider's one-time handoff into a credential it can
4
+ * present on every module call, and to revoke it again.
5
+ *
6
+ * These routes are dispatch's, not a module's: `POST /dispatch/apps/<app>/member-sessions`
7
+ * and `DELETE …/current`. Dispatch resolves the app's auth-provider slot,
8
+ * redeems the code with that provider itself, and issues its own `mss1_`
9
+ * credential, which every installed module then accepts as
10
+ * `Authorization: Bearer …`. That is why this lives beside `platformBaseUrl`
11
+ * and not in any module's client: the exchange is identical for every
12
+ * provider that can fill the slot, and a module plugin only has module-scoped
13
+ * transports.
14
+ */
15
+ /** Inputs for {@link memberSessions}. */
16
+ export interface MemberSessionsOptions {
17
+ /** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL`. */
18
+ readonly apiUrl: string;
19
+ /** The custom application's slug, typically `MIRRORSTACK_APP_SLUG`. */
20
+ readonly appSlug: string;
21
+ /** Fetch implementation, for SSR and tests. Defaults to `globalThis.fetch`. */
22
+ readonly fetch?: typeof globalThis.fetch;
23
+ }
24
+ /** The identity the platform returns with a freshly issued member session. */
25
+ export interface MemberIdentity {
26
+ readonly id: string;
27
+ readonly email: string | null;
28
+ readonly displayName: string | null;
29
+ readonly avatarUrl: string | null;
30
+ readonly createdAt: string;
31
+ readonly lastSignInAt: string;
32
+ }
33
+ /** A member session as issued by the platform. */
34
+ export interface MemberSession {
35
+ /** The `mss1_` credential to present as a bearer on module calls. Server-side only. */
36
+ readonly credential: string;
37
+ readonly identity: MemberIdentity;
38
+ /** RFC 3339 expiry of the credential. */
39
+ readonly expiresAt: string;
40
+ }
41
+ /**
42
+ * The outcome of a revoke. `revoked` and `alreadyInvalid` are both a finished
43
+ * sign-out; `unavailable` means the platform could not say, so the credential
44
+ * may still be live and a caller must not hide that behind a cleared cookie.
45
+ */
46
+ export type RevokeOutcome = "revoked" | "alreadyInvalid" | "unavailable";
47
+ /** A non-successful response from the member-session control plane. */
48
+ export declare class MemberSessionError extends Error {
49
+ readonly status: number;
50
+ readonly code: string | undefined;
51
+ constructor(status: number, code: string | undefined, message: string);
52
+ }
53
+ /** The member-sessions API a custom app talks to. */
54
+ export interface MemberSessionsApi {
55
+ /** A fresh one-time handoff state: 32 lowercase hex characters, inside the provider's 16–256 byte window. */
56
+ newState(): string;
57
+ /** Redeem a one-time handoff code for a member session. `state` must be the one this app issued for the sign-in. */
58
+ exchange(code: string, state: string): Promise<MemberSession>;
59
+ /** Revoke the current member session on the platform. Never throws; see {@link RevokeOutcome}. */
60
+ revoke(credential: string): Promise<RevokeOutcome>;
61
+ /** The control-plane URL this instance talks to, for assertions and logs. */
62
+ readonly url: string;
63
+ }
64
+ /**
65
+ * Creates the member-sessions API for one custom application.
66
+ *
67
+ * ```ts
68
+ * const sessions = memberSessions({
69
+ * apiUrl: process.env.MIRRORSTACK_API_URL!,
70
+ * appSlug: process.env.MIRRORSTACK_APP_SLUG!,
71
+ * });
72
+ * const state = sessions.newState(); // stored in an HttpOnly cookie, sent on startUrl
73
+ * const session = await sessions.exchange(code, state);
74
+ * await sessions.revoke(session.credential);
75
+ * ```
76
+ *
77
+ * Both inputs are validated up front, like {@link platformBaseUrl}, so a
78
+ * misconfigured environment fails at startup rather than on the first sign-in.
79
+ */
80
+ export declare function memberSessions(options: MemberSessionsOptions): MemberSessionsApi;
@@ -0,0 +1,162 @@
1
+ import { assertAppSlug } from "../base-url.js";
2
+ import { normalizeBaseUrl } from "../transport.js";
3
+ /** A non-successful response from the member-session control plane. */
4
+ export class MemberSessionError extends Error {
5
+ status;
6
+ code;
7
+ constructor(status, code, message) {
8
+ super(message);
9
+ this.name = "MemberSessionError";
10
+ this.status = status;
11
+ this.code = code;
12
+ }
13
+ }
14
+ const HANDOFF_STATE_BYTES = 16;
15
+ const ENVELOPE_VERSION = 1;
16
+ const MAX_ERROR_BODY_BYTES = 4096;
17
+ function stringField(record, key) {
18
+ const value = record[key];
19
+ return typeof value === "string" ? value : undefined;
20
+ }
21
+ function nullableStringField(record, key) {
22
+ const value = record[key];
23
+ return typeof value === "string" ? value : null;
24
+ }
25
+ function errorCodeOf(body) {
26
+ if (body === null || typeof body !== "object")
27
+ return undefined;
28
+ const error = body.error;
29
+ if (error === null || typeof error !== "object")
30
+ return undefined;
31
+ return stringField(error, "code");
32
+ }
33
+ async function boundedText(response) {
34
+ try {
35
+ const text = await response.text();
36
+ return text.length > MAX_ERROR_BODY_BYTES ? text.slice(0, MAX_ERROR_BODY_BYTES) : text;
37
+ }
38
+ catch {
39
+ return "";
40
+ }
41
+ }
42
+ function parseMemberSession(body) {
43
+ if (body === null || typeof body !== "object") {
44
+ throw new MemberSessionError(502, "invalid_member_session_response", "member session response is not an object");
45
+ }
46
+ const record = body;
47
+ const credential = stringField(record, "credential");
48
+ const expiresAt = stringField(record, "expiresAt");
49
+ const identity = record.identity;
50
+ if (credential === undefined || credential.length === 0 ||
51
+ expiresAt === undefined ||
52
+ identity === null || typeof identity !== "object") {
53
+ throw new MemberSessionError(502, "invalid_member_session_response", "member session response is missing credential, identity, or expiresAt");
54
+ }
55
+ const id = stringField(identity, "id");
56
+ if (id === undefined || id.length === 0) {
57
+ throw new MemberSessionError(502, "invalid_member_session_response", "member session identity has no id");
58
+ }
59
+ const identityRecord = identity;
60
+ return {
61
+ credential,
62
+ expiresAt,
63
+ identity: {
64
+ id,
65
+ email: nullableStringField(identityRecord, "email"),
66
+ displayName: nullableStringField(identityRecord, "display_name"),
67
+ avatarUrl: nullableStringField(identityRecord, "avatar_url"),
68
+ createdAt: stringField(identityRecord, "created_at") ?? "",
69
+ lastSignInAt: stringField(identityRecord, "last_sign_in_at") ?? "",
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * Creates the member-sessions API for one custom application.
75
+ *
76
+ * ```ts
77
+ * const sessions = memberSessions({
78
+ * apiUrl: process.env.MIRRORSTACK_API_URL!,
79
+ * appSlug: process.env.MIRRORSTACK_APP_SLUG!,
80
+ * });
81
+ * const state = sessions.newState(); // stored in an HttpOnly cookie, sent on startUrl
82
+ * const session = await sessions.exchange(code, state);
83
+ * await sessions.revoke(session.credential);
84
+ * ```
85
+ *
86
+ * Both inputs are validated up front, like {@link platformBaseUrl}, so a
87
+ * misconfigured environment fails at startup rather than on the first sign-in.
88
+ */
89
+ export function memberSessions(options) {
90
+ if (options === null || typeof options !== "object") {
91
+ throw new TypeError("memberSessions options must be an object");
92
+ }
93
+ const { apiUrl, appSlug } = options;
94
+ if (typeof apiUrl !== "string" || !/^https?:\/\//u.test(apiUrl)) {
95
+ throw new TypeError("apiUrl must be an absolute HTTP(S) URL");
96
+ }
97
+ const parsed = new URL(apiUrl);
98
+ if (parsed.username !== "" || parsed.password !== "" || parsed.search !== "" || parsed.hash !== "") {
99
+ throw new TypeError("apiUrl must not carry credentials, a query, or a fragment");
100
+ }
101
+ assertAppSlug(appSlug);
102
+ const fetchImpl = options.fetch ?? globalThis.fetch;
103
+ if (typeof fetchImpl !== "function") {
104
+ throw new TypeError("a fetch implementation is required");
105
+ }
106
+ const url = `${normalizeBaseUrl(apiUrl)}/dispatch/apps/${encodeURIComponent(appSlug)}/member-sessions`;
107
+ return {
108
+ url,
109
+ newState() {
110
+ const bytes = new Uint8Array(HANDOFF_STATE_BYTES);
111
+ globalThis.crypto.getRandomValues(bytes);
112
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
113
+ },
114
+ async exchange(code, state) {
115
+ if (typeof code !== "string" || code.length === 0) {
116
+ throw new TypeError("a handoff code is required");
117
+ }
118
+ if (typeof state !== "string" || state.length === 0) {
119
+ throw new TypeError("the issued handoff state is required");
120
+ }
121
+ const response = await fetchImpl(url, {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
124
+ body: JSON.stringify({ v: ENVELOPE_VERSION, code, state }),
125
+ cache: "no-store",
126
+ });
127
+ if (!response.ok) {
128
+ const text = await boundedText(response);
129
+ let body;
130
+ try {
131
+ body = JSON.parse(text);
132
+ }
133
+ catch {
134
+ body = undefined;
135
+ }
136
+ throw new MemberSessionError(response.status, errorCodeOf(body), `member session exchange failed (${response.status})`);
137
+ }
138
+ return parseMemberSession(await response.json());
139
+ },
140
+ async revoke(credential) {
141
+ if (typeof credential !== "string" || credential.length === 0) {
142
+ throw new TypeError("a member session credential is required");
143
+ }
144
+ try {
145
+ const response = await fetchImpl(`${url}/current`, {
146
+ method: "DELETE",
147
+ headers: { Authorization: `Bearer ${credential}` },
148
+ cache: "no-store",
149
+ });
150
+ if (response.status === 204)
151
+ return "revoked";
152
+ if (response.status === 401)
153
+ return "alreadyInvalid";
154
+ return "unavailable";
155
+ }
156
+ catch {
157
+ return "unavailable";
158
+ }
159
+ },
160
+ };
161
+ }
162
+ //# sourceMappingURL=member-sessions.js.map