@guuey/widget-auth 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Loqu, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # @guuey/widget-auth
2
+
3
+ Mint end-user identity tokens for a [guuey](https://guuey.com) embeddable widget, from your own backend.
4
+
5
+ Zero runtime dependencies. Node 18+.
6
+
7
+ ```bash
8
+ npm install @guuey/widget-auth
9
+ ```
10
+
11
+ ## Why this exists
12
+
13
+ The widget on your page needs to know **who the visitor is** — otherwise every visit
14
+ starts from scratch, with no history, memory or files. It learns that from a short-lived
15
+ signed token, and only your backend can issue one, because only your backend knows who
16
+ is logged in.
17
+
18
+ You never hold a signing key. Your app's private key is sealed in the platform's KMS and
19
+ never leaves it; this package is a typed, validated call to the mint route.
20
+
21
+ ## Quick start
22
+
23
+ **1. Enrol your app once** and keep the secret it prints:
24
+
25
+ ```bash
26
+ guuey widget keys create <appId> --audience <your-app-audience>
27
+ ```
28
+
29
+ The secret is shown **once**. Store it the way you store a database password.
30
+
31
+ **2. Add a token endpoint to your backend.** This example is a Next.js route handler;
32
+ any framework works the same way.
33
+
34
+ ```ts
35
+ // app/api/guuey-token/route.ts
36
+ import { signUserToken } from "@guuey/widget-auth";
37
+ import { getSession } from "@/lib/auth";
38
+
39
+ export async function GET() {
40
+ const session = await getSession();
41
+ if (!session) return new Response("Unauthorized", { status: 401 });
42
+
43
+ const { token } = await signUserToken(
44
+ { userId: session.userId, name: session.name, email: session.email },
45
+ {
46
+ appId: process.env.GUUEY_APP_ID!,
47
+ appSecret: process.env.GUUEY_APP_SECRET!,
48
+ }
49
+ );
50
+
51
+ // The widget's `getToken` expects the raw token string.
52
+ return new Response(token, { headers: { "content-type": "text/plain" } });
53
+ }
54
+ ```
55
+
56
+ **3. Point the widget at it** in the embed snippet:
57
+
58
+ <!-- GENERATED — do not hand-edit. This block is a paste target, generated by
59
+ `embedSnippet()` in apps/widget/loader/snippet.ts and byte-asserted against
60
+ it by that package's snippet-docs-parity.test.ts. Prettier reformatting it
61
+ (it exploded the queue shim once already) makes it valid JS and a docs lie:
62
+ the shim must stay byte-identical to the one the loader suite replays. -->
63
+ <!-- prettier-ignore -->
64
+ ```html
65
+ <!-- guuey widget. Add this site to the app's allowed domains or the embed is refused. -->
66
+ <script>
67
+ window.guuey = window.guuey || function(){(guuey.q=guuey.q||[]).push(arguments)};
68
+ guuey("init", {
69
+ app: "app_abc123",
70
+ identity: {
71
+ getToken: (reason) =>
72
+ fetch("/api/guuey-token" + "?reason=" + reason).then((r) => {
73
+ if (!r.ok) throw new Error("token endpoint failed: " + r.status);
74
+ return r.text();
75
+ }),
76
+ },
77
+ });
78
+ </script>
79
+ <script src="https://widget.guuey.com/v1.js" async></script>
80
+ ```
81
+
82
+ Your endpoint is called by the browser; the app secret stays on your server.
83
+
84
+ ## The app secret is server-side only
85
+
86
+ `appSecret` authorizes minting an identity for **any user of your app**. If it reaches a
87
+ browser — bundled into frontend code, or because you called this package from the client —
88
+ anyone who reads it can impersonate any of your users.
89
+
90
+ That is the whole reason the widget asks _your_ server for a token instead of minting one
91
+ itself. Keep the secret in an environment variable on the server, and never return it from
92
+ an endpoint.
93
+
94
+ **`signUserToken` refuses to run in a browser** — it throws a `WidgetAuthConfigError` if it
95
+ detects a DOM, before doing anything else — so this fails loudly at your first call rather
96
+ than silently shipping the secret to every visitor. Edge runtimes (Cloudflare Workers,
97
+ Vercel Edge, Deno Deploy) are not browsers and are fine.
98
+
99
+ If it does leak, rotate it:
100
+
101
+ ```bash
102
+ guuey widget keys rotate <appId> --new-secret
103
+ ```
104
+
105
+ ## Caching tokens
106
+
107
+ Tokens are short-lived (15 minutes by default). Minting one per page load is fine, but if
108
+ your token endpoint is hot you can cache.
109
+
110
+ **Cache per user, and expire ~2 minutes before the token does.** The margin covers the
111
+ round trip plus any clock difference between your server and the visitor's browser.
112
+
113
+ ```ts
114
+ const cache = new Map<string, { token: string; refreshAt: number }>();
115
+ const MARGIN_SECONDS = 120;
116
+
117
+ async function tokenFor(user: { userId: string; name?: string }, force: boolean) {
118
+ const hit = cache.get(user.userId);
119
+ if (!force && hit && Date.now() / 1000 < hit.refreshAt) return hit.token;
120
+
121
+ const { token, expiresAtEpoch } = await signUserToken(user, {
122
+ appId: process.env.GUUEY_APP_ID!,
123
+ appSecret: process.env.GUUEY_APP_SECRET!,
124
+ });
125
+ cache.set(user.userId, { token, refreshAt: expiresAtEpoch - MARGIN_SECONDS });
126
+ return token;
127
+ }
128
+ ```
129
+
130
+ **If you cache, you must honour `reason`.** The widget calls `getToken(reason)` with:
131
+
132
+ | `reason` | Meaning |
133
+ | ----------- | ----------------------------------------------------------------------------- |
134
+ | `'initial'` | First token needed for this session — a cached token is fine. |
135
+ | `'expired'` | The token was rejected as expired. **Mint a fresh one, ignoring your cache.** |
136
+
137
+ A cache that ignores `'expired'` keeps returning the same dead token, and every turn after
138
+ the first expiry fails. Forward the reason to your endpoint and pass it through:
139
+
140
+ ```ts
141
+ export async function GET(request: Request) {
142
+ const session = await getSession();
143
+ if (!session) return new Response("Unauthorized", { status: 401 });
144
+
145
+ const reason = new URL(request.url).searchParams.get("reason");
146
+ const token = await tokenFor(session, reason === "expired");
147
+ return new Response(token, { headers: { "content-type": "text/plain" } });
148
+ }
149
+ ```
150
+
151
+ ## Errors
152
+
153
+ Every failure throws a subclass of `WidgetAuthError` — never a partial result, and never a
154
+ token-shaped value. A service that returns a 500, an HTML error page, or a 200 with an
155
+ unusable body all raise an error rather than producing a token nobody issued.
156
+
157
+ | Class | Cause | Retry? |
158
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
159
+ | `WidgetAuthConfigError` | Running in a browser (see above), or bad arguments — missing `appId`/`appSecret`/base URL, over-long field, `ttlSeconds` outside `1..3600`. Thrown before any network call. | No |
160
+ | `WidgetAuthCredentialError` | HTTP 401. The secret is wrong, revoked, or for another app. | No |
161
+ | `WidgetAuthAppNotConfiguredError` | HTTP 409. The app isn't wired to its own widget issuer; the message names the fixing command. | No |
162
+ | `WidgetAuthRequestError` | HTTP 400. Usually means this package is out of date with the deployed API. | No |
163
+ | `WidgetAuthServiceError` | 5xx, an unexpected status, or an unusable response body. | `retryable` is `true` for 5xx |
164
+ | `WidgetAuthNetworkError` | The request never arrived — DNS, TLS, timeout, abort. Original error on `cause`. | Yes |
165
+
166
+ Each carries `status` (when there was one) and `retryable`:
167
+
168
+ ```ts
169
+ try {
170
+ await signUserToken(user, config);
171
+ } catch (err) {
172
+ if (err instanceof WidgetAuthError && err.retryable) {
173
+ // transient — back off and try again
174
+ }
175
+ throw err;
176
+ }
177
+ ```
178
+
179
+ A 401 does not tell you _which_ of "wrong secret", "revoked key" or "app not enrolled" it
180
+ was. That is deliberate: the app id is caller-supplied, so a more specific answer would let
181
+ anyone probe which apps exist.
182
+
183
+ **The app secret is kept out of every error this package constructs** — its message, its
184
+ stack, and `JSON.stringify(err)`. Any text sourced from the service or from a transport
185
+ error is redacted first, and `cause` is non-enumerable so a structured logger cannot drag
186
+ a transport error's request dump into a log line.
187
+
188
+ One gap worth knowing, because it is outside what this package controls:
189
+ `console.error(err)` in Node prints the **cause chain** via `util.inspect`, which reaches
190
+ the non-enumerable `cause`. If your `fetch` implementation puts the secret in its own
191
+ error's message, that text is not redacted. Log `err.message` rather than `err` if your
192
+ aggregator ingests raw error objects.
193
+
194
+ ## What the platform sets, and you must not
195
+
196
+ The token's claims are assembled **server-side**. Do not re-implement any of this:
197
+
198
+ - **`iat` and `nbf` are backdated 60 seconds.** The agent verifies with zero clock
199
+ tolerance, so this absorbs ordinary drift. `exp` is measured from the real current time,
200
+ not from the backdated `iat`, so the headroom costs no usable life. Backdating again on
201
+ top of it would not shorten anything — it would widen the acceptance window, so the
202
+ 60-second skew margin quietly stops meaning 60 seconds, and `iat` would misstate the
203
+ token's age to anything that reads it.
204
+ - **`exp`** defaults to 15 minutes; `ttlSeconds` may set `1..3600`. Shorter is safer — the
205
+ token is a bearer credential held in a browser, and the widget re-requests one when it
206
+ expires. It is not a session length.
207
+ - **`iss`** is your app's canonical issuer, and **`aud`** is your app's configured audience.
208
+
209
+ Attempting to send any of them is rejected by the signer outright.
210
+
211
+ ## API
212
+
213
+ ### `signUserToken(user, config): Promise<WidgetToken>`
214
+
215
+ **`user`**
216
+
217
+ | Field | Type | |
218
+ | -------- | -------- | ---------------------------------------------------------------------- |
219
+ | `userId` | `string` | **Required.** Your stable id for this user; becomes the token's `sub`. |
220
+ | `name` | `string` | Optional. Display name. |
221
+ | `email` | `string` | Optional. |
222
+
223
+ `userId` must be **stable for the life of the account** — it is what ties a returning
224
+ visitor to their existing conversations, memory and files. A value that changes (a session
225
+ id, an editable email) silently orphans all of it and the user reappears as a stranger.
226
+
227
+ **`config`**
228
+
229
+ | Field | Type | |
230
+ | ------------ | ------------- | ------------------------------------------------------------------------------------------- |
231
+ | `appId` | `string` | **Required.** |
232
+ | `appSecret` | `string` | **Required.** Server-side only. |
233
+ | `apiBaseUrl` | `string` | Defaults to `GUUEY_API_URL`. No compiled-in default — the API base differs per environment. |
234
+ | `ttlSeconds` | `number` | `1..3600`. Defaults to the service's 15 minutes. |
235
+ | `signal` | `AbortSignal` | Aborts the request. |
236
+ | `fetch` | `FetchLike` | Override the HTTP client. For tests. |
237
+
238
+ **Returns `WidgetToken`**
239
+
240
+ | Field | Type | |
241
+ | ---------------- | -------- | ----------------------------------------------- |
242
+ | `token` | `string` | The signed JWT to hand to the widget. |
243
+ | `expiresAtEpoch` | `number` | Unix epoch seconds. Use it to drive your cache. |
244
+ | `issuer` | `string` | The issuer that signed it. |
245
+ | `kid` | `string` | The signing key's id. |
246
+
247
+ ## License
248
+
249
+ MIT
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The error taxonomy for `@guuey/widget-auth`.
3
+ *
4
+ * Every failure is one of these, and every one of them is a subclass of
5
+ * {@link WidgetAuthError} — so a caller that only wants "did minting fail?" needs
6
+ * one `catch` clause, while a caller that wants to react differently to a bad
7
+ * secret than to a transient outage can switch on the class.
8
+ *
9
+ * ## Two invariants this file exists to hold
10
+ *
11
+ * **1. A failure is never a token.** Every path here throws. There is no
12
+ * "degraded" return value, no empty-string token, no partially-populated result:
13
+ * a token handed to a browser is a bearer credential, and one minted from a
14
+ * misread 500 response would be a credential nobody issued.
15
+ *
16
+ * **2. The app secret never reaches an error.** It is a server-side credential
17
+ * whose entire value is that it exists in exactly one place, and an exception is
18
+ * the most likely way for it to escape into a log aggregator. Errors therefore
19
+ * carry only a status, a retryability flag and a message — and any message
20
+ * sourced from outside this package (the service's response, a transport
21
+ * error's text) is passed through {@link redactSecret} first, so the property
22
+ * holds structurally rather than by trusting the other end.
23
+ *
24
+ * `cause` is attached with the `Error` options form deliberately: that makes it
25
+ * non-enumerable, so a structured logger serializing the error cannot drag a
26
+ * transport error's request dump — headers included — into the log line.
27
+ *
28
+ * **The honest bound on that claim.** It covers what this package constructs:
29
+ * the message, the stack, and `JSON.stringify`. It does NOT cover
30
+ * `console.error(err)`, which in Node prints the cause chain through
31
+ * `util.inspect` and reaches the non-enumerable `cause` — so a `fetch`
32
+ * implementation that puts the secret in its OWN error's message escapes
33
+ * redaction by that path. Closing it means wrapping the cause in a redacted
34
+ * copy, which costs the caller the original error object (`cause.code`,
35
+ * `instanceof`); that trade is worth making deliberately rather than as a side
36
+ * effect, so it is documented here and in the README instead of half-done.
37
+ */
38
+ /** What a caller may do about a failure, without parsing messages. */
39
+ export declare abstract class WidgetAuthError extends Error {
40
+ /**
41
+ * The HTTP status the token service returned, or `undefined` when the request
42
+ * never got that far (bad configuration, a transport failure).
43
+ */
44
+ readonly status: number | undefined;
45
+ /**
46
+ * Whether retrying the SAME call with backoff could plausibly succeed.
47
+ *
48
+ * `false` for anything the caller must change first — a wrong secret, an app
49
+ * that is not wired to its issuer, a malformed request. Retrying those just
50
+ * burns the shared rate-limit bucket.
51
+ */
52
+ readonly retryable: boolean;
53
+ protected constructor(message: string, retryable: boolean, status?: number, options?: {
54
+ cause?: unknown;
55
+ });
56
+ }
57
+ /**
58
+ * The call was wrong before it was ever sent — a missing or malformed `appId`,
59
+ * `appSecret`, `apiBaseUrl`, `userId`, or a `ttlSeconds` outside `1..3600`.
60
+ *
61
+ * Thrown before any network call, so a configuration mistake costs nothing and
62
+ * cannot be mistaken for a service outage.
63
+ */
64
+ export declare class WidgetAuthConfigError extends WidgetAuthError {
65
+ constructor(message: string);
66
+ }
67
+ /**
68
+ * HTTP 401 — the app secret was not accepted.
69
+ *
70
+ * **Deliberately indistinguishable**: a wrong secret, a revoked key and an app
71
+ * that was never enrolled all produce this one error, because the `appId` is
72
+ * caller-supplied and anything finer would turn the route into an oracle for
73
+ * which apps exist. So "which of the three is it?" is a question this error
74
+ * cannot answer by design — check the secret first, then the key's status with
75
+ * `guuey widget keys`.
76
+ */
77
+ export declare class WidgetAuthCredentialError extends WidgetAuthError {
78
+ constructor(message: string, status: number);
79
+ }
80
+ /**
81
+ * HTTP 409 — the secret was accepted, but the app is not wired to its own widget
82
+ * issuer, so a token minted here would be rejected by the app that received it.
83
+ *
84
+ * The message names the command that repairs it. A correctly-onboarded app never
85
+ * reaches this: `guuey widget keys create --audience` wires the binding in the
86
+ * same ceremony that mints the secret.
87
+ */
88
+ export declare class WidgetAuthAppNotConfiguredError extends WidgetAuthError {
89
+ constructor(message: string, status: number);
90
+ }
91
+ /**
92
+ * HTTP 400 — the token service rejected the request body.
93
+ *
94
+ * This package validates every field the service validates, so in normal use
95
+ * this is unreachable; seeing it means either a version skew between this
96
+ * package and the deployed service, or a bug here. Either way it is not
97
+ * something a retry fixes.
98
+ */
99
+ export declare class WidgetAuthRequestError extends WidgetAuthError {
100
+ constructor(message: string, status: number);
101
+ }
102
+ /**
103
+ * The service failed, or answered with something this package cannot use — a
104
+ * 5xx, an unexpected status, a body that is not JSON, or a 200 whose shape is
105
+ * not a minted token.
106
+ *
107
+ * `retryable` is `true` only for 5xx. A 200 with an unusable body is NOT
108
+ * retryable: the request succeeded and the answer was wrong, which a retry
109
+ * reproduces.
110
+ */
111
+ export declare class WidgetAuthServiceError extends WidgetAuthError {
112
+ constructor(message: string, status: number | undefined, retryable: boolean, options?: {
113
+ cause?: unknown;
114
+ });
115
+ }
116
+ /**
117
+ * The request never reached the token service — DNS, TCP, TLS, a timeout, or an
118
+ * aborted `AbortSignal`.
119
+ *
120
+ * The underlying error is attached as `cause` (non-enumerable, so it does not
121
+ * land in a serialized log line) and its text is redacted into the message.
122
+ */
123
+ export declare class WidgetAuthNetworkError extends WidgetAuthError {
124
+ constructor(message: string, options?: {
125
+ cause?: unknown;
126
+ retryable?: boolean;
127
+ });
128
+ }
129
+ /**
130
+ * Was this transport failure the CALLER cancelling the request?
131
+ *
132
+ * A caller-initiated abort is the one transport failure that must not be
133
+ * `retryable`: the integrator asked for this mint to stop, and a retry loop
134
+ * keyed on `retryable` would re-issue the very call they abandoned — spending
135
+ * a mint (and a rate-limit slot) against their own intent. Everything else
136
+ * here — DNS, TCP, TLS, a timeout — genuinely may succeed on a second try.
137
+ *
138
+ * Detected structurally rather than by message text: the DOM standard's
139
+ * `AbortError` name is what `fetch` rejects with on `signal.abort()`, and
140
+ * undici/Node use the same name.
141
+ */
142
+ export declare function isAbortError(err: unknown): boolean;
143
+ /**
144
+ * Replace every occurrence of the app secret with `[redacted]`.
145
+ *
146
+ * Applied to every string that enters an error message from outside this
147
+ * package. The token service never echoes the secret and a transport error
148
+ * usually does not either — but "usually" is not a security property, and
149
+ * `fetch` implementations that quote the failing request with its headers do
150
+ * exist. One cheap pass makes the guarantee structural.
151
+ *
152
+ * A short or empty secret is ignored rather than replaced: redacting a 1-char
153
+ * string would corrupt the message without protecting anything, and only a
154
+ * well-formed secret is long enough to be worth hiding.
155
+ */
156
+ export declare function redactSecret(text: string, secret: string): string;
157
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,sEAAsE;AACtE,8BAAsB,eAAgB,SAAQ,KAAK;IACjD;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAEpC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAE5B,SAAS,aACP,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,OAAO,EAClB,MAAM,CAAC,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAOhC;AAED;;;;;;GAMG;AACH,qBAAa,qBAAsB,SAAQ,eAAe;gBAC5C,OAAO,EAAE,MAAM;CAG5B;AAED;;;;;;;;;GASG;AACH,qBAAa,yBAA0B,SAAQ,eAAe;gBAChD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAG5C;AAED;;;;;;;GAOG;AACH,qBAAa,+BAAgC,SAAQ,eAAe;gBACtD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAG5C;AAED;;;;;;;GAOG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAG5C;AAED;;;;;;;;GAQG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAEvD,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,SAAS,EAAE,OAAO,EAClB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAIhC;AAED;;;;;;GAMG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE;CAGhF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAOlD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGjE"}
package/dist/errors.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The error taxonomy for `@guuey/widget-auth`.
3
+ *
4
+ * Every failure is one of these, and every one of them is a subclass of
5
+ * {@link WidgetAuthError} — so a caller that only wants "did minting fail?" needs
6
+ * one `catch` clause, while a caller that wants to react differently to a bad
7
+ * secret than to a transient outage can switch on the class.
8
+ *
9
+ * ## Two invariants this file exists to hold
10
+ *
11
+ * **1. A failure is never a token.** Every path here throws. There is no
12
+ * "degraded" return value, no empty-string token, no partially-populated result:
13
+ * a token handed to a browser is a bearer credential, and one minted from a
14
+ * misread 500 response would be a credential nobody issued.
15
+ *
16
+ * **2. The app secret never reaches an error.** It is a server-side credential
17
+ * whose entire value is that it exists in exactly one place, and an exception is
18
+ * the most likely way for it to escape into a log aggregator. Errors therefore
19
+ * carry only a status, a retryability flag and a message — and any message
20
+ * sourced from outside this package (the service's response, a transport
21
+ * error's text) is passed through {@link redactSecret} first, so the property
22
+ * holds structurally rather than by trusting the other end.
23
+ *
24
+ * `cause` is attached with the `Error` options form deliberately: that makes it
25
+ * non-enumerable, so a structured logger serializing the error cannot drag a
26
+ * transport error's request dump — headers included — into the log line.
27
+ *
28
+ * **The honest bound on that claim.** It covers what this package constructs:
29
+ * the message, the stack, and `JSON.stringify`. It does NOT cover
30
+ * `console.error(err)`, which in Node prints the cause chain through
31
+ * `util.inspect` and reaches the non-enumerable `cause` — so a `fetch`
32
+ * implementation that puts the secret in its OWN error's message escapes
33
+ * redaction by that path. Closing it means wrapping the cause in a redacted
34
+ * copy, which costs the caller the original error object (`cause.code`,
35
+ * `instanceof`); that trade is worth making deliberately rather than as a side
36
+ * effect, so it is documented here and in the README instead of half-done.
37
+ */
38
+ /** What a caller may do about a failure, without parsing messages. */
39
+ export class WidgetAuthError extends Error {
40
+ /**
41
+ * The HTTP status the token service returned, or `undefined` when the request
42
+ * never got that far (bad configuration, a transport failure).
43
+ */
44
+ status;
45
+ /**
46
+ * Whether retrying the SAME call with backoff could plausibly succeed.
47
+ *
48
+ * `false` for anything the caller must change first — a wrong secret, an app
49
+ * that is not wired to its issuer, a malformed request. Retrying those just
50
+ * burns the shared rate-limit bucket.
51
+ */
52
+ retryable;
53
+ constructor(message, retryable, status, options) {
54
+ super(message, options);
55
+ this.name = new.target.name;
56
+ this.retryable = retryable;
57
+ this.status = status;
58
+ }
59
+ }
60
+ /**
61
+ * The call was wrong before it was ever sent — a missing or malformed `appId`,
62
+ * `appSecret`, `apiBaseUrl`, `userId`, or a `ttlSeconds` outside `1..3600`.
63
+ *
64
+ * Thrown before any network call, so a configuration mistake costs nothing and
65
+ * cannot be mistaken for a service outage.
66
+ */
67
+ export class WidgetAuthConfigError extends WidgetAuthError {
68
+ constructor(message) {
69
+ super(message, false);
70
+ }
71
+ }
72
+ /**
73
+ * HTTP 401 — the app secret was not accepted.
74
+ *
75
+ * **Deliberately indistinguishable**: a wrong secret, a revoked key and an app
76
+ * that was never enrolled all produce this one error, because the `appId` is
77
+ * caller-supplied and anything finer would turn the route into an oracle for
78
+ * which apps exist. So "which of the three is it?" is a question this error
79
+ * cannot answer by design — check the secret first, then the key's status with
80
+ * `guuey widget keys`.
81
+ */
82
+ export class WidgetAuthCredentialError extends WidgetAuthError {
83
+ constructor(message, status) {
84
+ super(message, false, status);
85
+ }
86
+ }
87
+ /**
88
+ * HTTP 409 — the secret was accepted, but the app is not wired to its own widget
89
+ * issuer, so a token minted here would be rejected by the app that received it.
90
+ *
91
+ * The message names the command that repairs it. A correctly-onboarded app never
92
+ * reaches this: `guuey widget keys create --audience` wires the binding in the
93
+ * same ceremony that mints the secret.
94
+ */
95
+ export class WidgetAuthAppNotConfiguredError extends WidgetAuthError {
96
+ constructor(message, status) {
97
+ super(message, false, status);
98
+ }
99
+ }
100
+ /**
101
+ * HTTP 400 — the token service rejected the request body.
102
+ *
103
+ * This package validates every field the service validates, so in normal use
104
+ * this is unreachable; seeing it means either a version skew between this
105
+ * package and the deployed service, or a bug here. Either way it is not
106
+ * something a retry fixes.
107
+ */
108
+ export class WidgetAuthRequestError extends WidgetAuthError {
109
+ constructor(message, status) {
110
+ super(message, false, status);
111
+ }
112
+ }
113
+ /**
114
+ * The service failed, or answered with something this package cannot use — a
115
+ * 5xx, an unexpected status, a body that is not JSON, or a 200 whose shape is
116
+ * not a minted token.
117
+ *
118
+ * `retryable` is `true` only for 5xx. A 200 with an unusable body is NOT
119
+ * retryable: the request succeeded and the answer was wrong, which a retry
120
+ * reproduces.
121
+ */
122
+ export class WidgetAuthServiceError extends WidgetAuthError {
123
+ constructor(message, status, retryable, options) {
124
+ super(message, retryable, status, options);
125
+ }
126
+ }
127
+ /**
128
+ * The request never reached the token service — DNS, TCP, TLS, a timeout, or an
129
+ * aborted `AbortSignal`.
130
+ *
131
+ * The underlying error is attached as `cause` (non-enumerable, so it does not
132
+ * land in a serialized log line) and its text is redacted into the message.
133
+ */
134
+ export class WidgetAuthNetworkError extends WidgetAuthError {
135
+ constructor(message, options) {
136
+ super(message, options?.retryable ?? true, undefined, options);
137
+ }
138
+ }
139
+ /**
140
+ * Was this transport failure the CALLER cancelling the request?
141
+ *
142
+ * A caller-initiated abort is the one transport failure that must not be
143
+ * `retryable`: the integrator asked for this mint to stop, and a retry loop
144
+ * keyed on `retryable` would re-issue the very call they abandoned — spending
145
+ * a mint (and a rate-limit slot) against their own intent. Everything else
146
+ * here — DNS, TCP, TLS, a timeout — genuinely may succeed on a second try.
147
+ *
148
+ * Detected structurally rather than by message text: the DOM standard's
149
+ * `AbortError` name is what `fetch` rejects with on `signal.abort()`, and
150
+ * undici/Node use the same name.
151
+ */
152
+ export function isAbortError(err) {
153
+ return (typeof err === 'object' &&
154
+ err !== null &&
155
+ 'name' in err &&
156
+ err.name === 'AbortError');
157
+ }
158
+ /**
159
+ * Replace every occurrence of the app secret with `[redacted]`.
160
+ *
161
+ * Applied to every string that enters an error message from outside this
162
+ * package. The token service never echoes the secret and a transport error
163
+ * usually does not either — but "usually" is not a security property, and
164
+ * `fetch` implementations that quote the failing request with its headers do
165
+ * exist. One cheap pass makes the guarantee structural.
166
+ *
167
+ * A short or empty secret is ignored rather than replaced: redacting a 1-char
168
+ * string would corrupt the message without protecting anything, and only a
169
+ * well-formed secret is long enough to be worth hiding.
170
+ */
171
+ export function redactSecret(text, secret) {
172
+ if (secret.length < 8)
173
+ return text;
174
+ return text.split(secret).join('[redacted]');
175
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `@guuey/widget-auth` — mint end-user identity tokens for a guuey embeddable
3
+ * widget, from your own backend.
4
+ *
5
+ * ```ts
6
+ * import { signUserToken } from '@guuey/widget-auth';
7
+ *
8
+ * const { token, expiresAtEpoch } = await signUserToken(
9
+ * { userId: user.id, name: user.name, email: user.email },
10
+ * { appId: process.env.GUUEY_APP_ID!, appSecret: process.env.GUUEY_APP_SECRET! },
11
+ * );
12
+ * ```
13
+ *
14
+ * ## What this package does NOT do, and why that matters
15
+ *
16
+ * It holds no key material and assembles no claims. The app's RSA private key
17
+ * lives sealed in the platform's KMS and never leaves it, so this package is a
18
+ * typed, validated HTTP call to the mint route and nothing more.
19
+ *
20
+ * In particular, **the token's time claims are assembled server-side and this
21
+ * package must never re-implement them.** The signer sets `iat` and `nbf`
22
+ * backdated 60 seconds — absorbing ordinary clock drift between the signer and
23
+ * the agent pod, which verifies with zero clock tolerance — and derives `exp`
24
+ * from the real current time, so the backdating buys verification headroom
25
+ * without shortening the token's usable life. It also sets `iss` from the app's
26
+ * canonical issuer string and `aud` from the app's own configuration. Sending
27
+ * any of those from here is rejected outright by the signer's strict claim
28
+ * parser, which allowlists exactly `sub`, `name` and `email` — so the rule is
29
+ * enforced by the other end rather than merely stated here.
30
+ *
31
+ * Were the backdate re-implemented here anyway, the damage would not be a
32
+ * shorter token: `exp` does not move, so nothing expires sooner. It would WIDEN
33
+ * the acceptance window — `nbf` slides another 60s into the past, so the
34
+ * skew margin silently stops being the 60 seconds it is documented as — and
35
+ * `iat` would misstate the token's age to every consumer that reads it.
36
+ *
37
+ * ## The app secret is a SERVER-side credential
38
+ *
39
+ * `appSecret` authorizes minting an identity for *any* user of your app. It must
40
+ * live only on your backend. If it reaches a browser — bundled into frontend
41
+ * code, or because this package was called from the client — anyone who reads it
42
+ * can mint a token for any of your users, which is the entire threat model this
43
+ * design exists to prevent. That is why the widget asks *your* server for a
44
+ * token rather than minting one itself.
45
+ */
46
+ import { WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError } from './errors.js';
47
+ export { WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError, };
48
+ /** The end-user a token is being minted for. */
49
+ export interface WidgetUser {
50
+ /**
51
+ * Your stable identifier for this user — it becomes the token's `sub`, and the
52
+ * platform derives the user's durable widget identity from it.
53
+ *
54
+ * It must be stable for the life of the account: it is what ties a returning
55
+ * visitor to their existing conversations, memory and files. A value that
56
+ * changes (a session id, an email that can be edited) silently orphans all of
57
+ * it and the user reappears as a stranger.
58
+ */
59
+ userId: string;
60
+ /** Display name, shown in the widget. Optional. */
61
+ name?: string;
62
+ /** Email, available to the agent. Optional. */
63
+ email?: string;
64
+ }
65
+ /** Where to mint, and as whom. */
66
+ export interface WidgetAuthConfig {
67
+ /** The guuey app this token is for. */
68
+ appId: string;
69
+ /**
70
+ * The app secret from `guuey widget keys create`. **Server-side only** — see
71
+ * the module docblock.
72
+ */
73
+ appSecret: string;
74
+ /**
75
+ * The guuey API base URL, e.g. `https://api.guuey.com`. Falls back to the
76
+ * `GUUEY_API_URL` environment variable.
77
+ *
78
+ * There is deliberately no compiled-in default: the API base differs per
79
+ * environment, and a wrong built-in default would fail in a way that looks
80
+ * like a credential problem rather than a configuration one.
81
+ */
82
+ apiBaseUrl?: string;
83
+ /**
84
+ * Token lifetime in seconds, `1..3600`. Defaults to the service's 15 minutes.
85
+ *
86
+ * Shorter is safer — the token is a bearer credential held in a browser, and
87
+ * the widget re-requests one from you when it expires. Prefer the default over
88
+ * a long TTL; it is not a session length.
89
+ */
90
+ ttlSeconds?: number;
91
+ /** Aborts the request. */
92
+ signal?: AbortSignal;
93
+ /** Override the HTTP client. Intended for tests. */
94
+ fetch?: FetchLike;
95
+ }
96
+ /** A minted token, exactly as the mint route returns it. */
97
+ export interface WidgetToken {
98
+ /** The signed JWT to hand to the widget. */
99
+ token: string;
100
+ /** Unix epoch seconds at which `token` stops verifying. */
101
+ expiresAtEpoch: number;
102
+ /** The issuer that signed it. */
103
+ issuer: string;
104
+ /** The signing key's id. */
105
+ kid: string;
106
+ }
107
+ /** The request shape this package sends. */
108
+ export interface WidgetAuthRequestInit {
109
+ method: string;
110
+ headers: Record<string, string>;
111
+ body: string;
112
+ signal?: AbortSignal;
113
+ }
114
+ /** The part of a `fetch` response this package reads. */
115
+ export interface WidgetAuthFetchResponse {
116
+ status: number;
117
+ json(): Promise<unknown>;
118
+ }
119
+ /**
120
+ * The HTTP seam. Structurally satisfied by the global `fetch`, so overriding it
121
+ * is only needed in tests.
122
+ */
123
+ export type FetchLike = (url: string, init: WidgetAuthRequestInit) => Promise<WidgetAuthFetchResponse>;
124
+ /**
125
+ * Mint an end-user token for your widget.
126
+ *
127
+ * Resolves with a {@link WidgetToken}, or **throws** — always a
128
+ * {@link WidgetAuthError} subclass, never a partially-formed result. See
129
+ * `errors.ts` for the taxonomy and which failures a retry can fix.
130
+ *
131
+ * @param user the end-user this token identifies
132
+ * @param config the app, its secret, and where to mint
133
+ */
134
+ export declare function signUserToken(user: WidgetUser, config: WidgetAuthConfig): Promise<WidgetToken>;
135
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,EAGL,+BAA+B,EAC/B,qBAAqB,EACrB,yBAAyB,EACzB,eAAe,EACf,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,+BAA+B,EAC/B,qBAAqB,EACrB,yBAAyB,EACzB,eAAe,EACf,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,GACvB,CAAC;AA4BF,gDAAgD;AAChD,MAAM,WAAW,UAAU;IACzB;;;;;;;;OAQG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAED,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IAC1B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,cAAc,EAAE,MAAM,CAAC;IACvB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,4BAA4B;IAC5B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,4CAA4C;AAC5C,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,yDAAyD;AACzD,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,qBAAqB,KACxB,OAAO,CAAC,uBAAuB,CAAC,CAAC;AAWtC;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,WAAW,CAAC,CAmEtB"}
package/dist/index.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * `@guuey/widget-auth` — mint end-user identity tokens for a guuey embeddable
3
+ * widget, from your own backend.
4
+ *
5
+ * ```ts
6
+ * import { signUserToken } from '@guuey/widget-auth';
7
+ *
8
+ * const { token, expiresAtEpoch } = await signUserToken(
9
+ * { userId: user.id, name: user.name, email: user.email },
10
+ * { appId: process.env.GUUEY_APP_ID!, appSecret: process.env.GUUEY_APP_SECRET! },
11
+ * );
12
+ * ```
13
+ *
14
+ * ## What this package does NOT do, and why that matters
15
+ *
16
+ * It holds no key material and assembles no claims. The app's RSA private key
17
+ * lives sealed in the platform's KMS and never leaves it, so this package is a
18
+ * typed, validated HTTP call to the mint route and nothing more.
19
+ *
20
+ * In particular, **the token's time claims are assembled server-side and this
21
+ * package must never re-implement them.** The signer sets `iat` and `nbf`
22
+ * backdated 60 seconds — absorbing ordinary clock drift between the signer and
23
+ * the agent pod, which verifies with zero clock tolerance — and derives `exp`
24
+ * from the real current time, so the backdating buys verification headroom
25
+ * without shortening the token's usable life. It also sets `iss` from the app's
26
+ * canonical issuer string and `aud` from the app's own configuration. Sending
27
+ * any of those from here is rejected outright by the signer's strict claim
28
+ * parser, which allowlists exactly `sub`, `name` and `email` — so the rule is
29
+ * enforced by the other end rather than merely stated here.
30
+ *
31
+ * Were the backdate re-implemented here anyway, the damage would not be a
32
+ * shorter token: `exp` does not move, so nothing expires sooner. It would WIDEN
33
+ * the acceptance window — `nbf` slides another 60s into the past, so the
34
+ * skew margin silently stops being the 60 seconds it is documented as — and
35
+ * `iat` would misstate the token's age to every consumer that reads it.
36
+ *
37
+ * ## The app secret is a SERVER-side credential
38
+ *
39
+ * `appSecret` authorizes minting an identity for *any* user of your app. It must
40
+ * live only on your backend. If it reaches a browser — bundled into frontend
41
+ * code, or because this package was called from the client — anyone who reads it
42
+ * can mint a token for any of your users, which is the entire threat model this
43
+ * design exists to prevent. That is why the widget asks *your* server for a
44
+ * token rather than minting one itself.
45
+ */
46
+ import { isAbortError, redactSecret, WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError, } from './errors.js';
47
+ export { WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError, };
48
+ /** The credential family the mint route accepts. */
49
+ const SECRET_PREFIX = 'guuey_widget_';
50
+ /** The mint route's path, appended to the resolved API base URL. */
51
+ const MINT_PATH = '/v1/widget/token';
52
+ /**
53
+ * Field caps, mirroring the token service's own.
54
+ *
55
+ * They exist server-side because KMS refuses to sign a message over 4096 bytes
56
+ * and these fields are caller-controlled. Mirroring them here turns a round trip
57
+ * that ends in a 400 into an immediate, local error naming the field — and the
58
+ * service still enforces them, so a skew between this package and a newer
59
+ * deployment fails safe rather than silently.
60
+ */
61
+ const MAX_USER_ID_LENGTH = 256;
62
+ const MAX_NAME_LENGTH = 256;
63
+ const MAX_EMAIL_LENGTH = 320;
64
+ /** TTL bounds, mirroring the service. The default (900s) is the service's. */
65
+ const MIN_TTL_SECONDS = 1;
66
+ const MAX_TTL_SECONDS = 3600;
67
+ /** How much of a service-supplied message to keep in an error. */
68
+ const MAX_SERVICE_MESSAGE_LENGTH = 400;
69
+ /**
70
+ * Mint an end-user token for your widget.
71
+ *
72
+ * Resolves with a {@link WidgetToken}, or **throws** — always a
73
+ * {@link WidgetAuthError} subclass, never a partially-formed result. See
74
+ * `errors.ts` for the taxonomy and which failures a retry can fix.
75
+ *
76
+ * @param user the end-user this token identifies
77
+ * @param config the app, its secret, and where to mint
78
+ */
79
+ export async function signUserToken(user, config) {
80
+ // FIRST, before any other check. Running in a browser is not one
81
+ // misconfiguration among several — it means the secret is already in a
82
+ // shipped bundle, and saying "appId is required" to someone in that position
83
+ // would be answering the wrong question.
84
+ assertNotBrowser();
85
+ const { appId, appSecret } = config;
86
+ // Validate everything before touching the network: a configuration mistake
87
+ // should cost nothing, name its own field, and never be confusable with an
88
+ // outage or a rejected credential.
89
+ requireNonEmpty(appId, 'appId');
90
+ requireNonEmpty(appSecret, 'appSecret');
91
+ if (!appSecret.startsWith(SECRET_PREFIX)) {
92
+ // Checked locally so a credential from another family is never transmitted
93
+ // at all — and because the service's answer would be the deliberately
94
+ // uninformative 401, which would send the reader hunting for the wrong bug.
95
+ throw new WidgetAuthConfigError(`appSecret does not look like a widget app secret (expected it to start with "${SECRET_PREFIX}"). ` +
96
+ 'Widget secrets come from `guuey widget keys create <appId>`; a personal access token or ' +
97
+ 'workspace API key will not work on this route.');
98
+ }
99
+ const baseUrl = resolveBaseUrl(config.apiBaseUrl);
100
+ const body = buildBody(appId, user, config.ttlSeconds);
101
+ const doFetch = resolveFetch(config.fetch);
102
+ const init = {
103
+ method: 'POST',
104
+ headers: {
105
+ authorization: `Bearer ${appSecret}`,
106
+ 'content-type': 'application/json',
107
+ },
108
+ body: JSON.stringify(body),
109
+ };
110
+ if (config.signal !== undefined)
111
+ init.signal = config.signal;
112
+ let response;
113
+ try {
114
+ response = await doFetch(`${baseUrl}${MINT_PATH}`, init);
115
+ }
116
+ catch (err) {
117
+ // A caller-initiated abort is NOT retryable — see `isAbortError`.
118
+ const aborted = isAbortError(err);
119
+ throw new WidgetAuthNetworkError(aborted
120
+ ? `The mint request to ${baseUrl} was aborted by the caller.`
121
+ : `Could not reach the guuey token service at ${baseUrl}: ${describe(err, appSecret)}`, { cause: err, retryable: !aborted });
122
+ }
123
+ // A body that is not JSON is a fact about the response, not an exception:
124
+ // a proxy's HTML error page is a perfectly ordinary way for this to fail, and
125
+ // it must map onto the taxonomy rather than escaping as a SyntaxError.
126
+ let payload;
127
+ try {
128
+ payload = await response.json();
129
+ }
130
+ catch {
131
+ payload = undefined;
132
+ }
133
+ if (response.status !== 200) {
134
+ throw mapFailure(response.status, payload, appSecret);
135
+ }
136
+ return readToken(payload);
137
+ }
138
+ // ─── Validation ────────────────────────────────────────────────────────
139
+ /**
140
+ * Refuse to run in a browser.
141
+ *
142
+ * This package's entire threat model is that `appSecret` never leaves the
143
+ * server. Everything else here — the redaction, the non-enumerable `cause`, the
144
+ * never-a-garbage-token property — defends a secret that is already in the right
145
+ * place. None of it helps if the secret was bundled into a page, where every
146
+ * visitor can read it and mint an identity for any of your users. That is the
147
+ * one failure this package can detect itself, so it does, loudly, instead of
148
+ * documenting it and hoping.
149
+ *
150
+ * **Why BOTH `window` and `document`, and not either alone.** Edge runtimes
151
+ * (Cloudflare Workers, Vercel Edge, Deno Deploy) are legitimate places to mint
152
+ * from, and some of them define a `window`-ish global while defining no `document`
153
+ * — testing `window` alone would refuse a correct deployment. A DOM is the thing
154
+ * that actually distinguishes a page from a server-side JS runtime, so the pair
155
+ * is the discriminator and the conjunction is deliberate, not defensive noise.
156
+ *
157
+ * Called from every exported entry path. There is one today; anything added
158
+ * later calls this first, for the same reason.
159
+ *
160
+ * Probed through `globalThis` by name rather than written as
161
+ * `typeof window !== 'undefined'` because `lib` is pinned to `["ES2022"]` with
162
+ * no `dom`, so those identifiers do not exist for the compiler. Adding `dom`
163
+ * to satisfy the check would declare browser globals throughout a package whose
164
+ * whole point is that it does not run in one — the wrong fix. Comparing the
165
+ * value against `undefined` (rather than testing key presence) keeps the
166
+ * `typeof` semantics: a runtime that defines `globalThis.window = undefined` is
167
+ * not a browser and must not be refused.
168
+ */
169
+ function globalIsDefined(name) {
170
+ return Reflect.get(globalThis, name) !== undefined;
171
+ }
172
+ function assertNotBrowser() {
173
+ if (globalIsDefined('window') && globalIsDefined('document')) {
174
+ throw new WidgetAuthConfigError('@guuey/widget-auth ran in a browser, and refused. Your app secret authorizes minting ' +
175
+ 'an identity for ANY user of your app, so if this code reached a browser the secret is ' +
176
+ 'in your shipped bundle and every visitor can read it — rotate it now with ' +
177
+ '`guuey widget keys rotate <appId> --new-secret`. Mint on your server instead and hand ' +
178
+ 'the returned token to the widget: that is what the widget asks your backend for a ' +
179
+ 'token rather than minting one itself.');
180
+ }
181
+ }
182
+ function requireNonEmpty(value, field) {
183
+ if (typeof value !== 'string' || value.length === 0) {
184
+ throw new WidgetAuthConfigError(`${field} is required.`);
185
+ }
186
+ }
187
+ function requireWithin(value, max, field) {
188
+ if (value.length > max) {
189
+ throw new WidgetAuthConfigError(`${field} must be ${max} characters or less (got ${value.length}).`);
190
+ }
191
+ }
192
+ /**
193
+ * Resolve the API base, trimming trailing slashes so callers can pass either
194
+ * form without producing a `//v1/...` path.
195
+ */
196
+ function resolveBaseUrl(configured) {
197
+ const raw = configured ?? readEnv('GUUEY_API_URL');
198
+ if (raw === undefined || raw.length === 0) {
199
+ throw new WidgetAuthConfigError('No guuey API base URL. Pass `apiBaseUrl` or set the GUUEY_API_URL environment variable ' +
200
+ '(for example https://api.guuey.com).');
201
+ }
202
+ return raw.replace(/\/+$/, '');
203
+ }
204
+ /**
205
+ * Read an environment variable without assuming a Node-shaped global.
206
+ *
207
+ * The tolerance is for EDGE RUNTIMES — Cloudflare Workers, Vercel Edge, Deno
208
+ * Deploy — which are legitimate places to mint from and may not expose
209
+ * `process`. It is emphatically not tolerance for browsers, which
210
+ * {@link assertNotBrowser} refuses outright; an earlier version of this comment
211
+ * said "edge or browser bundle", which read as sanctioning exactly the
212
+ * deployment this package exists to prevent.
213
+ */
214
+ function readEnv(name) {
215
+ if (typeof process === 'undefined')
216
+ return undefined;
217
+ return process.env?.[name];
218
+ }
219
+ function buildBody(appId, user, ttlSeconds) {
220
+ requireNonEmpty(user.userId, 'userId');
221
+ requireWithin(user.userId, MAX_USER_ID_LENGTH, 'userId');
222
+ const body = { appId, userId: user.userId };
223
+ if (user.name !== undefined) {
224
+ requireWithin(user.name, MAX_NAME_LENGTH, 'name');
225
+ body.name = user.name;
226
+ }
227
+ if (user.email !== undefined) {
228
+ requireWithin(user.email, MAX_EMAIL_LENGTH, 'email');
229
+ body.email = user.email;
230
+ }
231
+ if (ttlSeconds !== undefined) {
232
+ if (!Number.isInteger(ttlSeconds) ||
233
+ ttlSeconds < MIN_TTL_SECONDS ||
234
+ ttlSeconds > MAX_TTL_SECONDS) {
235
+ throw new WidgetAuthConfigError(`ttlSeconds must be a whole number of seconds between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS} (got ${ttlSeconds}).`);
236
+ }
237
+ body.ttlSeconds = ttlSeconds;
238
+ }
239
+ return body;
240
+ }
241
+ function resolveFetch(configured) {
242
+ if (configured !== undefined)
243
+ return configured;
244
+ if (typeof globalThis.fetch !== 'function') {
245
+ throw new WidgetAuthConfigError('No global fetch available. @guuey/widget-auth needs Node 18 or newer, or a `fetch` ' +
246
+ 'implementation passed as `config.fetch`.');
247
+ }
248
+ const globalFetch = globalThis.fetch;
249
+ return (url, init) => globalFetch(url, init);
250
+ }
251
+ // ─── Response handling ─────────────────────────────────────────────────
252
+ /** Pull `error.message` out of the service's error envelope, if it has one. */
253
+ function serviceMessage(payload, secret) {
254
+ if (typeof payload !== 'object' || payload === null)
255
+ return undefined;
256
+ const error = payload.error;
257
+ if (typeof error !== 'object' || error === null)
258
+ return undefined;
259
+ const message = error.message;
260
+ if (typeof message !== 'string' || message.length === 0)
261
+ return undefined;
262
+ return truncate(redactSecret(message, secret), MAX_SERVICE_MESSAGE_LENGTH);
263
+ }
264
+ /** Describe a thrown value for a message, redacted and bounded. */
265
+ function describe(err, secret) {
266
+ const text = err instanceof Error ? err.message : String(err);
267
+ return truncate(redactSecret(text, secret), MAX_SERVICE_MESSAGE_LENGTH);
268
+ }
269
+ function truncate(text, max) {
270
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
271
+ }
272
+ /** Map a non-200 onto the taxonomy. */
273
+ function mapFailure(status, payload, secret) {
274
+ const detail = serviceMessage(payload, secret);
275
+ const suffix = detail === undefined ? '' : ` ${detail}`;
276
+ if (status === 401) {
277
+ return new WidgetAuthCredentialError('The widget app secret was not accepted. It may be wrong, revoked, or for a different app — ' +
278
+ 'these are deliberately indistinguishable, so that this route cannot be used to discover ' +
279
+ `which apps exist. Check the secret, then \`guuey widget keys\`.${suffix}`, status);
280
+ }
281
+ if (status === 409) {
282
+ return new WidgetAuthAppNotConfiguredError(`The app is not configured to accept tokens from its own widget issuer.${suffix}`, status);
283
+ }
284
+ if (status === 400) {
285
+ return new WidgetAuthRequestError('The token service rejected the request. @guuey/widget-auth validates these fields itself, ' +
286
+ `so this usually means it is out of date with the deployed API.${suffix}`, status);
287
+ }
288
+ if (status >= 500) {
289
+ return new WidgetAuthServiceError(`The guuey token service failed (HTTP ${status}). This is usually transient — retry with backoff.${suffix}`, status, true);
290
+ }
291
+ return new WidgetAuthServiceError(`Unexpected response from the guuey token service (HTTP ${status}).${suffix}`, status, false);
292
+ }
293
+ /**
294
+ * Validate a 200 body into a {@link WidgetToken}.
295
+ *
296
+ * Every field is checked before anything is returned. A 200 carrying a body this
297
+ * package cannot recognize is an ERROR, never a best-effort object: the caller
298
+ * hands the result to a browser, so a `token` that is `undefined`, empty, or a
299
+ * number would become an authentication failure surfacing far from its cause —
300
+ * or, worse, a value from a response nobody authenticated.
301
+ */
302
+ function readToken(payload) {
303
+ const unusable = (why) => new WidgetAuthServiceError(`The guuey token service returned a response this package cannot use: ${why}. ` +
304
+ 'No token was issued.', 200, false);
305
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
306
+ throw unusable('the body was not a JSON object');
307
+ }
308
+ const body = payload;
309
+ if (typeof body.token !== 'string' || body.token.length === 0) {
310
+ throw unusable('`token` was missing or not a non-empty string');
311
+ }
312
+ if (typeof body.expiresAtEpoch !== 'number' || !Number.isFinite(body.expiresAtEpoch)) {
313
+ throw unusable('`expiresAtEpoch` was missing or not a number');
314
+ }
315
+ if (typeof body.issuer !== 'string' || typeof body.kid !== 'string') {
316
+ throw unusable('`issuer` or `kid` was missing');
317
+ }
318
+ return {
319
+ token: body.token,
320
+ expiresAtEpoch: body.expiresAtEpoch,
321
+ issuer: body.issuer,
322
+ kid: body.kid,
323
+ };
324
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@guuey/widget-auth",
3
+ "version": "0.1.0",
4
+ "description": "Mint end-user identity tokens for a guuey embeddable widget from your own backend. One call, zero runtime dependencies — your app secret stays on your server and the platform holds the signing key.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepack": "rm -rf dist && pnpm build",
27
+ "dev": "tsc --watch",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run && pnpm run smoke:dist",
30
+ "test:watch": "vitest",
31
+ "smoke:dist": "pnpm build && node scripts/import-smoke.mjs"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "24.12.4",
35
+ "typescript": "^5.0.0",
36
+ "vitest": "^3.0.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "keywords": [
42
+ "guuey",
43
+ "widget",
44
+ "embed",
45
+ "auth",
46
+ "jwt",
47
+ "identity"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/withguuey/guuey-sdks.git",
52
+ "directory": "packages/widget-auth"
53
+ },
54
+ "homepage": "https://guuey.com",
55
+ "bugs": {
56
+ "url": "https://github.com/loqu-co/guuey/issues"
57
+ }
58
+ }