@stigmer/sdk 3.1.7 → 3.1.9

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 (58) hide show
  1. package/__tests__/errors.test.js +42 -0
  2. package/__tests__/errors.test.js.map +1 -1
  3. package/__tests__/guest-auth.test.d.ts +2 -0
  4. package/__tests__/guest-auth.test.d.ts.map +1 -0
  5. package/__tests__/guest-auth.test.js +223 -0
  6. package/__tests__/guest-auth.test.js.map +1 -0
  7. package/__tests__/sharing.test.d.ts +2 -0
  8. package/__tests__/sharing.test.d.ts.map +1 -0
  9. package/__tests__/sharing.test.js +106 -0
  10. package/__tests__/sharing.test.js.map +1 -0
  11. package/gen/agentshare.d.ts +45 -0
  12. package/gen/agentshare.d.ts.map +1 -0
  13. package/gen/agentshare.js +142 -0
  14. package/gen/agentshare.js.map +1 -0
  15. package/gen/authorization-config.d.ts.map +1 -1
  16. package/gen/authorization-config.js +1 -0
  17. package/gen/authorization-config.js.map +1 -1
  18. package/gen/client.d.ts +4 -0
  19. package/gen/client.d.ts.map +1 -1
  20. package/gen/client.js +4 -0
  21. package/gen/client.js.map +1 -1
  22. package/gen/environment.d.ts +2 -0
  23. package/gen/environment.d.ts.map +1 -1
  24. package/gen/environment.js +8 -0
  25. package/gen/environment.js.map +1 -1
  26. package/gen/platformclient.d.ts +2 -1
  27. package/gen/platformclient.d.ts.map +1 -1
  28. package/gen/platformclient.js +8 -0
  29. package/gen/platformclient.js.map +1 -1
  30. package/gen/session.d.ts +2 -2
  31. package/gen/session.d.ts.map +1 -1
  32. package/gen/session.js +2 -2
  33. package/gen/session.js.map +1 -1
  34. package/guest-auth.d.ts +175 -0
  35. package/guest-auth.d.ts.map +1 -0
  36. package/guest-auth.js +227 -0
  37. package/guest-auth.js.map +1 -0
  38. package/index.d.ts +3 -0
  39. package/index.d.ts.map +1 -1
  40. package/index.js +5 -0
  41. package/index.js.map +1 -1
  42. package/package.json +2 -2
  43. package/sharing.d.ts +76 -0
  44. package/sharing.d.ts.map +1 -0
  45. package/sharing.js +117 -0
  46. package/sharing.js.map +1 -0
  47. package/src/__tests__/errors.test.ts +56 -0
  48. package/src/__tests__/guest-auth.test.ts +287 -0
  49. package/src/__tests__/sharing.test.ts +159 -0
  50. package/src/gen/agentshare.ts +148 -0
  51. package/src/gen/authorization-config.ts +1 -0
  52. package/src/gen/client.ts +5 -0
  53. package/src/gen/environment.ts +7 -1
  54. package/src/gen/platformclient.ts +7 -1
  55. package/src/gen/session.ts +3 -3
  56. package/src/guest-auth.ts +334 -0
  57. package/src/index.ts +25 -0
  58. package/src/sharing.ts +135 -0
package/sharing.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Agent-sharing helpers — the single source of truth for the hosted chat
3
+ * URL shape, the embed snippet, and client-side `allowed_origins`
4
+ * validation. Framework-free by design: consumed by the web console and
5
+ * desktop app (via `@stigmer/react`), the `stigmer` CLI, and any platform
6
+ * builder that wants to construct share links or embed snippets itself.
7
+ *
8
+ * The canonical URL shape is `<app-origin>/chat/<org>/<slug>` (a T01
9
+ * design decision), and `embed.js` is served from the root of that same
10
+ * app origin (T04). Callers supply the origin — resolving it is a host
11
+ * concern (the console knows its `appUrl`, the CLI resolves it from the
12
+ * backend type) — while the path and snippet shapes live here so every
13
+ * surface emits byte-identical output.
14
+ */
15
+ /** Maximum number of allowed origins (proto: `repeated.max_items = 32`). */
16
+ export const MAX_ALLOWED_ORIGINS = 32;
17
+ /**
18
+ * Exact web origin: scheme://host[:port] — no path, query, fragment, or
19
+ * trailing slash. Mirrors the CEL expression `allowed_origins.format` on
20
+ * `AgentShareSpec` (`apis/ai/stigmer/agentic/agentshare/v1/spec.proto`).
21
+ *
22
+ * The proto is the source of truth — if the CEL expression changes, this
23
+ * pattern must change with it. Mirroring it client-side gives immediate
24
+ * feedback instead of a round-trip rejection.
25
+ */
26
+ const ORIGIN_PATTERN = /^https?:\/\/[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:[0-9]{1,5})?$/;
27
+ /**
28
+ * Validate a single `allowed_origins` entry.
29
+ *
30
+ * Returns `null` when valid, or a user-facing message explaining what
31
+ * to fix (DD-006: errors state what happened and what to do).
32
+ */
33
+ export function validateOrigin(value) {
34
+ const trimmed = value.trim();
35
+ if (!trimmed)
36
+ return "Enter an origin, like https://example.com";
37
+ if (!ORIGIN_PATTERN.test(trimmed)) {
38
+ return "Must be an exact web origin like https://example.com — no path, query, or trailing slash";
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * Query parameter carrying the share-link token on a locked link:
44
+ * `/chat/<org>/<slug>?k=<token>`. Short by design — the token rides every
45
+ * copied link, and `k` (for "key") is the platform's one-character
46
+ * convention, mirrored by the hosted page and the embed widget.
47
+ */
48
+ export const LINK_TOKEN_PARAM = "k";
49
+ /**
50
+ * The hosted chat page path for a shared agent: `/chat/<org>/<slug>`
51
+ * (the AgentShare's org and slug — the slug defaults to the agent's),
52
+ * plus `?k=<token>` when the share link is locked with a rotatable
53
+ * token (`AgentShareStatus.share_link_token`).
54
+ *
55
+ * Useful on its own when the caller renders relative to the current
56
+ * origin (e.g. a host that never configured an absolute app URL).
57
+ */
58
+ export function chatPath(org, slug, linkToken) {
59
+ const path = `/chat/${org}/${slug}`;
60
+ return linkToken
61
+ ? `${path}?${LINK_TOKEN_PARAM}=${encodeURIComponent(linkToken)}`
62
+ : path;
63
+ }
64
+ /**
65
+ * The absolute hosted chat URL for a shared agent:
66
+ * `<appOrigin>/chat/<org>/<slug>[?k=<token>]`.
67
+ *
68
+ * A trailing slash on `appOrigin` is tolerated so callers can pass
69
+ * user-configured values verbatim. An empty `appOrigin` degrades to the
70
+ * relative {@link chatPath} — the same graceful fallback a host without a
71
+ * configured public origin gets in the share dialog.
72
+ */
73
+ export function buildChatUrl(appOrigin, org, slug, linkToken) {
74
+ return stripTrailingSlash(appOrigin) + chatPath(org, slug, linkToken);
75
+ }
76
+ /**
77
+ * Append the share-link token to an already-built chat URL.
78
+ *
79
+ * For hosts that construct the base URL through their own callback (the
80
+ * share dialog's `buildShareUrl` prop) rather than {@link buildChatUrl}.
81
+ * A `null`/empty token returns the URL unchanged, so callers can pass
82
+ * `share.status?.shareLinkToken` straight through. Emits the identical
83
+ * `?k=` shape as {@link chatPath} — one URL grammar across every surface.
84
+ */
85
+ export function appendLinkToken(url, linkToken) {
86
+ if (!linkToken)
87
+ return url;
88
+ const separator = url.includes("?") ? "&" : "?";
89
+ return `${url}${separator}${LINK_TOKEN_PARAM}=${encodeURIComponent(linkToken)}`;
90
+ }
91
+ /**
92
+ * The embed loader URL: `embed.js` lives at the root of the app origin
93
+ * (the loader derives the chat-page origin from its own script URL, so
94
+ * the two must share an origin). An empty `appOrigin` degrades to the
95
+ * relative `/embed.js`.
96
+ */
97
+ export function buildEmbedLoaderUrl(appOrigin) {
98
+ return `${stripTrailingSlash(appOrigin)}/embed.js`;
99
+ }
100
+ /**
101
+ * The two-line embed snippet an owner pastes into any website: the
102
+ * loader script plus the `<stigmer-agent>` element where the widget
103
+ * renders. A locked share link adds the `token` attribute, which the
104
+ * widget forwards as `?k=` on its iframe URL. Every surface (share
105
+ * dialog, CLI, docs) emits exactly this.
106
+ */
107
+ export function buildEmbedSnippet(appOrigin, org, slug, linkToken) {
108
+ const tokenAttribute = linkToken ? ` token="${linkToken}"` : "";
109
+ return [
110
+ `<script src="${buildEmbedLoaderUrl(appOrigin)}" async></script>`,
111
+ `<stigmer-agent org="${org}" agent="${slug}"${tokenAttribute}></stigmer-agent>`,
112
+ ].join("\n");
113
+ }
114
+ function stripTrailingSlash(origin) {
115
+ return origin.endsWith("/") ? origin.slice(0, -1) : origin;
116
+ }
117
+ //# sourceMappingURL=sharing.js.map
package/sharing.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sharing.js","sourceRoot":"","sources":["../src/sharing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,4EAA4E;AAC5E,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAEtC;;;;;;;;GAQG;AACH,MAAM,cAAc,GAClB,8GAA8G,CAAC;AAEjH;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,CAAC,OAAO;QAAE,OAAO,2CAA2C,CAAC;IACjE,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QAClC,OAAO,0FAA0F,CAAC;IACpG,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAEpC;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAW,EAAE,IAAY,EAAE,SAAkB;IACpE,MAAM,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC;IACpC,OAAO,SAAS;QACd,CAAC,CAAC,GAAG,IAAI,IAAI,gBAAgB,IAAI,kBAAkB,CAAC,SAAS,CAAC,EAAE;QAChE,CAAC,CAAC,IAAI,CAAC;AACX,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAC1B,SAAiB,EACjB,GAAW,EACX,IAAY,EACZ,SAAkB;IAElB,OAAO,kBAAkB,CAAC,SAAS,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW,EAAE,SAAoC;IAC/E,IAAI,CAAC,SAAS;QAAE,OAAO,GAAG,CAAC;IAC3B,MAAM,SAAS,GAAG,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IAChD,OAAO,GAAG,GAAG,GAAG,SAAS,GAAG,gBAAgB,IAAI,kBAAkB,CAAC,SAAS,CAAC,EAAE,CAAC;AAClF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,SAAiB;IACnD,OAAO,GAAG,kBAAkB,CAAC,SAAS,CAAC,WAAW,CAAC;AACrD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAC/B,SAAiB,EACjB,GAAW,EACX,IAAY,EACZ,SAAkB;IAElB,MAAM,cAAc,GAAG,SAAS,CAAC,CAAC,CAAC,WAAW,SAAS,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IAChE,OAAO;QACL,gBAAgB,mBAAmB,CAAC,SAAS,CAAC,mBAAmB;QACjE,uBAAuB,GAAG,YAAY,IAAI,IAAI,cAAc,mBAAmB;KAChF,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,SAAS,kBAAkB,CAAC,MAAc;IACxC,OAAO,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;AAC7D,CAAC"}
@@ -11,6 +11,62 @@ import {
11
11
  type ErrorCategory,
12
12
  } from "../errors";
13
13
 
14
+ /**
15
+ * Guest launch-gate refusal contract (shared-agent abuse controls).
16
+ *
17
+ * The cloud backend resolves owner-customizable refusal copy server-side and
18
+ * carries it in the gRPC status description; the SDK must surface that copy
19
+ * VERBATIM through getUserMessage — no client-side mapping exists by design.
20
+ * These tests pin the two halves of that contract:
21
+ *
22
+ * 1. RESOURCE_EXHAUSTED / FAILED_PRECONDITION descriptions pass through
23
+ * getUserMessage untouched.
24
+ * 2. The platform-default copy (mirrors GuestLimitReason in the cloud
25
+ * backend) never collides with the sanitizer's rewrite patterns.
26
+ */
27
+ describe("guest launch-gate refusal copy passthrough", () => {
28
+ // Mirrors GuestLimitReason default copy in the cloud stigmer-service.
29
+ // If those strings change, update here — this guardrail exists to catch a
30
+ // default that a sanitizer pattern would silently rewrite.
31
+ const platformDefaultCopy = [
32
+ "You\u2019re sending messages too quickly. Please wait a moment before sending another.",
33
+ "This agent is currently unavailable. Please check back later.",
34
+ "This conversation has ended. Please start a new conversation to continue.",
35
+ "This agent can\u2019t be embedded on this site.",
36
+ ];
37
+
38
+ const refusalCodes: Array<[string, Code]> = [
39
+ ["ResourceExhausted (rate limit)", Code.ResourceExhausted],
40
+ ["FailedPrecondition (fail-closed / bounds)", Code.FailedPrecondition],
41
+ ["PermissionDenied (embed origin)", Code.PermissionDenied],
42
+ ];
43
+
44
+ it.each(refusalCodes)(
45
+ "surfaces a %s status description verbatim",
46
+ (_label, code) => {
47
+ for (const copy of platformDefaultCopy) {
48
+ expect(getUserMessage(new ConnectError(copy, code))).toBe(copy);
49
+ }
50
+ },
51
+ );
52
+
53
+ it("surfaces owner-customized copy verbatim", () => {
54
+ const ownerCopy = "Whoa, slow down! Try again in a minute or two.";
55
+ expect(
56
+ getUserMessage(new ConnectError(ownerCopy, Code.ResourceExhausted)),
57
+ ).toBe(ownerCopy);
58
+ });
59
+
60
+ it("classifies rate-limit refusals as retryable and bounds refusals as not", () => {
61
+ expect(
62
+ isRetryableError(new ConnectError("copy", Code.ResourceExhausted)),
63
+ ).toBe(true);
64
+ expect(
65
+ isRetryableError(new ConnectError("copy", Code.FailedPrecondition)),
66
+ ).toBe(false);
67
+ });
68
+ });
69
+
14
70
  describe("classifyError", () => {
15
71
  const stigmerMappings: Array<[string, ErrorCategory]> = [
16
72
  ["unauthenticated", "auth"],
@@ -0,0 +1,287 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { create } from "@bufbuild/protobuf";
3
+ import { MintGuestTokenResponseSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
4
+ import { ConnectError, Code } from "@connectrpc/connect";
5
+
6
+ // Intercept the token client so no transport is exercised. The mock
7
+ // preserves the real module (create/Code/ConnectError) and only stubs
8
+ // createClient, which GuestAuth uses to build its RPC client.
9
+ const mintGuestToken = vi.fn();
10
+ vi.mock("@connectrpc/connect", async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import("@connectrpc/connect")>();
12
+ return {
13
+ ...actual,
14
+ createClient: () => ({ mintGuestToken }),
15
+ };
16
+ });
17
+
18
+ import { createGuestAuth, GuestAuth, type GuestIdStorage } from "../guest-auth";
19
+
20
+ const CONFIG = {
21
+ baseUrl: "https://api.stigmer.ai",
22
+ org: "acme",
23
+ slug: "support-agent",
24
+ };
25
+
26
+ function createMemoryStorage(): GuestIdStorage & { store: Map<string, string> } {
27
+ const store = new Map<string, string>();
28
+ return {
29
+ store,
30
+ getItem: (key) => store.get(key) ?? null,
31
+ setItem: (key, value) => {
32
+ store.set(key, value);
33
+ },
34
+ };
35
+ }
36
+
37
+ function mintResponse(overrides?: {
38
+ accessToken?: string;
39
+ expiresIn?: number;
40
+ guestCookieId?: string;
41
+ }) {
42
+ return create(MintGuestTokenResponseSchema, {
43
+ accessToken: overrides?.accessToken ?? "guest-jwt-1",
44
+ tokenType: "Bearer",
45
+ expiresIn: overrides?.expiresIn ?? 900,
46
+ guestCookieId: overrides?.guestCookieId ?? "cookie-abc",
47
+ });
48
+ }
49
+
50
+ beforeEach(() => {
51
+ mintGuestToken.mockReset();
52
+ vi.useFakeTimers();
53
+ });
54
+
55
+ afterEach(() => {
56
+ vi.useRealTimers();
57
+ });
58
+
59
+ describe("createGuestAuth", () => {
60
+ it("throws when baseUrl is missing", () => {
61
+ expect(() =>
62
+ createGuestAuth({ ...CONFIG, baseUrl: "" }),
63
+ ).toThrow("baseUrl is required");
64
+ });
65
+
66
+ it("throws when org is missing", () => {
67
+ expect(() => createGuestAuth({ ...CONFIG, org: "" })).toThrow(
68
+ "org is required",
69
+ );
70
+ });
71
+
72
+ it("throws when slug is missing", () => {
73
+ expect(() => createGuestAuth({ ...CONFIG, slug: "" })).toThrow(
74
+ "slug is required",
75
+ );
76
+ });
77
+
78
+ it("returns a GuestAuth instance with valid config", () => {
79
+ expect(createGuestAuth(CONFIG)).toBeInstanceOf(GuestAuth);
80
+ });
81
+ });
82
+
83
+ describe("GuestAuth.getAccessToken", () => {
84
+ it("mints on first call and sends an empty guest id", async () => {
85
+ mintGuestToken.mockResolvedValue(mintResponse());
86
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
87
+
88
+ const token = await auth.getAccessToken();
89
+
90
+ expect(token).toBe("guest-jwt-1");
91
+ expect(mintGuestToken).toHaveBeenCalledTimes(1);
92
+ expect(mintGuestToken.mock.calls[0][0]).toMatchObject({
93
+ org: "acme",
94
+ slug: "support-agent",
95
+ guestCookieId: "",
96
+ // No embedOrigin configured (the unframed hosted page): the field
97
+ // must stay empty so the server's absence-means-exempt rule applies.
98
+ embedOrigin: "",
99
+ });
100
+ });
101
+
102
+ it("passes the configured embedOrigin through to every mint", async () => {
103
+ mintGuestToken.mockResolvedValue(mintResponse());
104
+ const auth = createGuestAuth({
105
+ ...CONFIG,
106
+ storage: createMemoryStorage(),
107
+ embedOrigin: "https://docs.example.com",
108
+ });
109
+
110
+ await auth.getAccessToken();
111
+ // Expire the cached token, forcing a re-mint — the origin must persist.
112
+ vi.advanceTimersByTime(900_000);
113
+ await auth.getAccessToken();
114
+
115
+ expect(mintGuestToken).toHaveBeenCalledTimes(2);
116
+ for (const call of mintGuestToken.mock.calls) {
117
+ expect(call[0]).toMatchObject({ embedOrigin: "https://docs.example.com" });
118
+ }
119
+ });
120
+
121
+ it("passes the configured linkToken through to every mint", async () => {
122
+ mintGuestToken.mockResolvedValue(mintResponse());
123
+ const auth = createGuestAuth({
124
+ ...CONFIG,
125
+ storage: createMemoryStorage(),
126
+ linkToken: "tok123",
127
+ });
128
+
129
+ await auth.getAccessToken();
130
+ // Expire the cached token, forcing a re-mint — the token must persist
131
+ // so a locked link keeps working across silent re-mints.
132
+ vi.advanceTimersByTime(900_000);
133
+ await auth.getAccessToken();
134
+
135
+ expect(mintGuestToken).toHaveBeenCalledTimes(2);
136
+ for (const call of mintGuestToken.mock.calls) {
137
+ expect(call[0]).toMatchObject({ linkToken: "tok123" });
138
+ }
139
+ });
140
+
141
+ it("sends an empty linkToken when none is configured (plain link)", async () => {
142
+ mintGuestToken.mockResolvedValue(mintResponse());
143
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
144
+
145
+ await auth.getAccessToken();
146
+
147
+ expect(mintGuestToken.mock.calls[0][0]).toMatchObject({ linkToken: "" });
148
+ });
149
+
150
+ it("rejects with permission-denied when the embed origin is refused", async () => {
151
+ mintGuestToken.mockRejectedValue(
152
+ new ConnectError(
153
+ "This agent can\u2019t be embedded on this site.",
154
+ Code.PermissionDenied,
155
+ ),
156
+ );
157
+ const auth = createGuestAuth({
158
+ ...CONFIG,
159
+ storage: createMemoryStorage(),
160
+ embedOrigin: "https://evil.example.com",
161
+ });
162
+
163
+ await expect(auth.getAccessToken()).rejects.toMatchObject({
164
+ code: "permission-denied",
165
+ });
166
+ });
167
+
168
+ it("persists the server-issued guest id and presents it on re-mint", async () => {
169
+ const storage = createMemoryStorage();
170
+ mintGuestToken.mockResolvedValue(mintResponse({ guestCookieId: "cookie-abc" }));
171
+ const auth = createGuestAuth({ ...CONFIG, storage });
172
+
173
+ await auth.getAccessToken();
174
+ expect(storage.store.get("stigmer:guest-id:acme")).toBe("cookie-abc");
175
+ expect(auth.guestCookieId).toBe("cookie-abc");
176
+
177
+ // Expire the cached token, forcing a second mint.
178
+ vi.advanceTimersByTime(900_000);
179
+ await auth.getAccessToken();
180
+
181
+ expect(mintGuestToken).toHaveBeenCalledTimes(2);
182
+ expect(mintGuestToken.mock.calls[1][0]).toMatchObject({
183
+ guestCookieId: "cookie-abc",
184
+ });
185
+ });
186
+
187
+ it("restores a persisted guest id from a prior visit", async () => {
188
+ const storage = createMemoryStorage();
189
+ storage.setItem("stigmer:guest-id:acme", "cookie-from-last-visit");
190
+ mintGuestToken.mockResolvedValue(mintResponse());
191
+ const auth = createGuestAuth({ ...CONFIG, storage });
192
+
193
+ await auth.getAccessToken();
194
+
195
+ expect(mintGuestToken.mock.calls[0][0]).toMatchObject({
196
+ guestCookieId: "cookie-from-last-visit",
197
+ });
198
+ });
199
+
200
+ it("returns the cached token while it is fresh", async () => {
201
+ mintGuestToken.mockResolvedValue(mintResponse());
202
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
203
+
204
+ await auth.getAccessToken();
205
+ // 13 minutes into a 15-minute token: still beyond the 60s skew.
206
+ vi.advanceTimersByTime(13 * 60_000);
207
+ const token = await auth.getAccessToken();
208
+
209
+ expect(token).toBe("guest-jwt-1");
210
+ expect(mintGuestToken).toHaveBeenCalledTimes(1);
211
+ });
212
+
213
+ it("re-mints when the cached token is within the expiry skew", async () => {
214
+ mintGuestToken
215
+ .mockResolvedValueOnce(mintResponse({ accessToken: "guest-jwt-1" }))
216
+ .mockResolvedValueOnce(mintResponse({ accessToken: "guest-jwt-2" }));
217
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
218
+
219
+ await auth.getAccessToken();
220
+ // 30 seconds of life left — inside the 60s skew window.
221
+ vi.advanceTimersByTime(900_000 - 30_000);
222
+ const token = await auth.getAccessToken();
223
+
224
+ expect(token).toBe("guest-jwt-2");
225
+ expect(mintGuestToken).toHaveBeenCalledTimes(2);
226
+ });
227
+
228
+ it("collapses concurrent callers into a single mint", async () => {
229
+ let release!: (value: ReturnType<typeof mintResponse>) => void;
230
+ mintGuestToken.mockImplementation(
231
+ () => new Promise((resolve) => { release = resolve; }),
232
+ );
233
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
234
+
235
+ const first = auth.getAccessToken();
236
+ const second = auth.getAccessToken();
237
+ release(mintResponse());
238
+
239
+ await expect(first).resolves.toBe("guest-jwt-1");
240
+ await expect(second).resolves.toBe("guest-jwt-1");
241
+ expect(mintGuestToken).toHaveBeenCalledTimes(1);
242
+ });
243
+
244
+ it("rejects with a wrapped StigmerError when sharing is revoked", async () => {
245
+ mintGuestToken.mockRejectedValue(
246
+ new ConnectError("agent not found", Code.NotFound),
247
+ );
248
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
249
+
250
+ try {
251
+ await auth.getAccessToken();
252
+ expect.fail("should have thrown");
253
+ } catch (e: unknown) {
254
+ expect(e).toHaveProperty("name", "StigmerError");
255
+ expect(e).toHaveProperty("code", "not-found");
256
+ }
257
+ });
258
+
259
+ it("recovers after a failed mint instead of caching the failure", async () => {
260
+ mintGuestToken
261
+ .mockRejectedValueOnce(new ConnectError("unavailable", Code.Unavailable))
262
+ .mockResolvedValueOnce(mintResponse());
263
+ const auth = createGuestAuth({ ...CONFIG, storage: createMemoryStorage() });
264
+
265
+ await expect(auth.getAccessToken()).rejects.toMatchObject({
266
+ code: "unavailable",
267
+ });
268
+ await expect(auth.getAccessToken()).resolves.toBe("guest-jwt-1");
269
+ expect(mintGuestToken).toHaveBeenCalledTimes(2);
270
+ });
271
+
272
+ it("still mints when storage throws", async () => {
273
+ const brokenStorage: GuestIdStorage = {
274
+ getItem: () => {
275
+ throw new Error("storage disabled");
276
+ },
277
+ setItem: () => {
278
+ throw new Error("storage disabled");
279
+ },
280
+ };
281
+ mintGuestToken.mockResolvedValue(mintResponse());
282
+ const auth = createGuestAuth({ ...CONFIG, storage: brokenStorage });
283
+
284
+ await expect(auth.getAccessToken()).resolves.toBe("guest-jwt-1");
285
+ expect(auth.guestCookieId).toBeNull();
286
+ });
287
+ });
@@ -0,0 +1,159 @@
1
+ // Unit tests for the framework-free agent-sharing helpers: origin
2
+ // validation (mirror of the proto CEL rule) and the hosted-link / embed
3
+ // snippet builders shared by the web console, desktop app, and CLI.
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ LINK_TOKEN_PARAM,
8
+ MAX_ALLOWED_ORIGINS,
9
+ appendLinkToken,
10
+ buildChatUrl,
11
+ buildEmbedLoaderUrl,
12
+ buildEmbedSnippet,
13
+ chatPath,
14
+ validateOrigin,
15
+ } from "../sharing";
16
+
17
+ describe("validateOrigin", () => {
18
+ it("accepts exact web origins", () => {
19
+ expect(validateOrigin("https://example.com")).toBeNull();
20
+ expect(validateOrigin("http://example.com")).toBeNull();
21
+ expect(validateOrigin("https://sub.example.com")).toBeNull();
22
+ expect(validateOrigin("https://example.com:8443")).toBeNull();
23
+ expect(validateOrigin("http://localhost:3000")).toBeNull();
24
+ });
25
+
26
+ it("trims surrounding whitespace before validating", () => {
27
+ expect(validateOrigin(" https://example.com ")).toBeNull();
28
+ });
29
+
30
+ it("rejects empty input with guidance", () => {
31
+ expect(validateOrigin("")).toMatch(/Enter an origin/);
32
+ expect(validateOrigin(" ")).toMatch(/Enter an origin/);
33
+ });
34
+
35
+ it("rejects trailing slashes, paths, queries, and fragments", () => {
36
+ for (const bad of [
37
+ "https://example.com/",
38
+ "https://example.com/path",
39
+ "https://example.com?q=1",
40
+ "https://example.com#top",
41
+ ]) {
42
+ expect(validateOrigin(bad)).toMatch(/exact web origin/);
43
+ }
44
+ });
45
+
46
+ it("rejects non-http(s) schemes and bare hosts", () => {
47
+ expect(validateOrigin("ftp://example.com")).toMatch(/exact web origin/);
48
+ expect(validateOrigin("example.com")).toMatch(/exact web origin/);
49
+ });
50
+
51
+ it("rejects hostname labels with leading/trailing hyphens", () => {
52
+ expect(validateOrigin("https://-bad.example.com")).toMatch(/exact web origin/);
53
+ expect(validateOrigin("https://bad-.example.com")).toMatch(/exact web origin/);
54
+ });
55
+
56
+ it("exposes the proto max_items bound", () => {
57
+ expect(MAX_ALLOWED_ORIGINS).toBe(32);
58
+ });
59
+ });
60
+
61
+ describe("chatPath / buildChatUrl", () => {
62
+ it("builds the canonical /chat/<org>/<slug> path", () => {
63
+ expect(chatPath("acme", "support-agent")).toBe("/chat/acme/support-agent");
64
+ });
65
+
66
+ it("builds the absolute hosted chat URL", () => {
67
+ expect(buildChatUrl("https://app.stigmer.ai", "acme", "support-agent")).toBe(
68
+ "https://app.stigmer.ai/chat/acme/support-agent",
69
+ );
70
+ });
71
+
72
+ it("tolerates a trailing slash on the origin", () => {
73
+ expect(buildChatUrl("https://app.stigmer.ai/", "acme", "support-agent")).toBe(
74
+ "https://app.stigmer.ai/chat/acme/support-agent",
75
+ );
76
+ });
77
+
78
+ it("works with localhost origins (local backend)", () => {
79
+ expect(buildChatUrl("http://localhost:8234", "stigmer", "helper")).toBe(
80
+ "http://localhost:8234/chat/stigmer/helper",
81
+ );
82
+ });
83
+
84
+ it("appends ?k= when the share link is locked with a token", () => {
85
+ expect(chatPath("acme", "support-agent", "tok123")).toBe(
86
+ "/chat/acme/support-agent?k=tok123",
87
+ );
88
+ expect(
89
+ buildChatUrl("https://app.stigmer.ai", "acme", "support-agent", "tok123"),
90
+ ).toBe("https://app.stigmer.ai/chat/acme/support-agent?k=tok123");
91
+ });
92
+
93
+ it("url-encodes the token (defense in depth; generated tokens are url-safe)", () => {
94
+ expect(chatPath("acme", "bot", "a+b/c")).toBe("/chat/acme/bot?k=a%2Bb%2Fc");
95
+ });
96
+
97
+ it("omits ?k= for an empty/undefined token (plain link)", () => {
98
+ expect(chatPath("acme", "bot", "")).toBe("/chat/acme/bot");
99
+ expect(chatPath("acme", "bot", undefined)).toBe("/chat/acme/bot");
100
+ });
101
+ });
102
+
103
+ describe("appendLinkToken", () => {
104
+ it("appends the identical ?k= shape chatPath emits", () => {
105
+ expect(appendLinkToken("https://app.stigmer.ai/chat/acme/bot", "tok123")).toBe(
106
+ buildChatUrl("https://app.stigmer.ai", "acme", "bot", "tok123"),
107
+ );
108
+ });
109
+
110
+ it("uses & when the URL already carries a query", () => {
111
+ expect(appendLinkToken("/chat/acme/bot?theme=dark", "tok123")).toBe(
112
+ `/chat/acme/bot?theme=dark&${LINK_TOKEN_PARAM}=tok123`,
113
+ );
114
+ });
115
+
116
+ it("returns the URL unchanged for a null/empty token", () => {
117
+ expect(appendLinkToken("/chat/acme/bot", null)).toBe("/chat/acme/bot");
118
+ expect(appendLinkToken("/chat/acme/bot", undefined)).toBe("/chat/acme/bot");
119
+ expect(appendLinkToken("/chat/acme/bot", "")).toBe("/chat/acme/bot");
120
+ });
121
+ });
122
+
123
+ describe("buildEmbedLoaderUrl", () => {
124
+ it("points at embed.js on the app origin root", () => {
125
+ expect(buildEmbedLoaderUrl("https://app.stigmer.ai")).toBe("https://app.stigmer.ai/embed.js");
126
+ });
127
+
128
+ it("tolerates a trailing slash on the origin", () => {
129
+ expect(buildEmbedLoaderUrl("https://app.stigmer.ai/")).toBe("https://app.stigmer.ai/embed.js");
130
+ });
131
+ });
132
+
133
+ describe("buildEmbedSnippet", () => {
134
+ it("emits exactly the two-line loader + element snippet", () => {
135
+ expect(buildEmbedSnippet("https://app.stigmer.ai", "acme", "support-agent")).toBe(
136
+ [
137
+ `<script src="https://app.stigmer.ai/embed.js" async></script>`,
138
+ `<stigmer-agent org="acme" agent="support-agent"></stigmer-agent>`,
139
+ ].join("\n"),
140
+ );
141
+ });
142
+
143
+ it("adds the token attribute when the share link is locked", () => {
144
+ expect(
145
+ buildEmbedSnippet("https://app.stigmer.ai", "acme", "support-agent", "tok123"),
146
+ ).toBe(
147
+ [
148
+ `<script src="https://app.stigmer.ai/embed.js" async></script>`,
149
+ `<stigmer-agent org="acme" agent="support-agent" token="tok123"></stigmer-agent>`,
150
+ ].join("\n"),
151
+ );
152
+ });
153
+
154
+ it("omits the token attribute for an empty token (plain link)", () => {
155
+ expect(buildEmbedSnippet("https://app.stigmer.ai", "acme", "bot", "")).toBe(
156
+ buildEmbedSnippet("https://app.stigmer.ai", "acme", "bot"),
157
+ );
158
+ });
159
+ });