@signalsupport/sdk 0.0.0-stage → 0.7.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +373 -2
  3. package/dist/chunks/app-7692BILt.js +2 -0
  4. package/dist/chunks/app-7692BILt.js.map +1 -0
  5. package/dist/chunks/constants-ClGbEpjT.js +2 -0
  6. package/dist/chunks/constants-ClGbEpjT.js.map +1 -0
  7. package/dist/chunks/support-x7N-k02b.js +4 -0
  8. package/dist/chunks/support-x7N-k02b.js.map +1 -0
  9. package/dist/chunks/widget-Bxsz5StK.js +2 -0
  10. package/dist/chunks/widget-Bxsz5StK.js.map +1 -0
  11. package/dist/client.d.ts +99 -0
  12. package/dist/constants.d.ts +2 -0
  13. package/dist/errors.d.ts +25 -0
  14. package/dist/evidence.d.ts +87 -0
  15. package/dist/iife.d.ts +12 -0
  16. package/dist/index.d.ts +21 -0
  17. package/dist/index.js +1 -0
  18. package/dist/model.d.ts +201 -0
  19. package/dist/react.d.ts +26 -0
  20. package/dist/react.js +2 -0
  21. package/dist/react.js.map +1 -0
  22. package/dist/reporter.d.ts +14 -0
  23. package/dist/server.d.ts +54 -0
  24. package/dist/server.js +2 -0
  25. package/dist/server.js.map +1 -0
  26. package/dist/session.d.ts +29 -0
  27. package/dist/signal.iife.js +4 -0
  28. package/dist/signal.iife.js.map +1 -0
  29. package/dist/support.d.ts +108 -0
  30. package/dist/trail.d.ts +27 -0
  31. package/dist/widget/app.d.ts +31 -0
  32. package/dist/widget/copy.d.ts +104 -0
  33. package/dist/widget/ctx.d.ts +75 -0
  34. package/dist/widget/icons.d.ts +38 -0
  35. package/dist/widget/index.d.ts +111 -0
  36. package/dist/widget/index.js +1 -0
  37. package/dist/widget/parts.d.ts +28 -0
  38. package/dist/widget/pick.d.ts +30 -0
  39. package/dist/widget/screens/faq.d.ts +8 -0
  40. package/dist/widget/screens/feedback.d.ts +11 -0
  41. package/dist/widget/screens/home.d.ts +12 -0
  42. package/dist/widget/screens/report.d.ts +9 -0
  43. package/dist/widget/screens/reports.d.ts +26 -0
  44. package/dist/widget/store.d.ts +31 -0
  45. package/dist/widget/styles.d.ts +10 -0
  46. package/dist/widget/theme.d.ts +57 -0
  47. package/dist/widget/ui.d.ts +58 -0
  48. package/package.json +73 -4
@@ -0,0 +1,201 @@
1
+ /**
2
+ * What a report means to the person who filed it.
3
+ *
4
+ * Signal's statuses are written for the team working the queue. A reporter
5
+ * needs the same facts from the other side of the table: "someone is reading
6
+ * it", not "triage"; "fixed, check it on your machine", not "resolved". Every
7
+ * rule here is pure and unit-tested, because a status machine enforced inside
8
+ * a click handler is a status machine with one path per button.
9
+ */
10
+ export type SignalType = 'incident' | 'request' | 'feedback';
11
+ export type Impact = 'extensive' | 'significant' | 'moderate' | 'minor';
12
+ export type Urgency = 'critical' | 'high' | 'medium' | 'low';
13
+ export type SignalStatus = 'triage' | 'open' | 'in_progress' | 'waiting_user' | 'fix_pending_release' | 'deferred' | 'resolved' | 'rejected' | 'under_review' | 'planned' | 'shipped' | 'declined';
14
+ export type Tone = 'neutral' | 'accent' | 'info' | 'warn' | 'danger' | 'success';
15
+ /** One of the reporter's own signals, as `GET /v1/reporter/signals` returns it. */
16
+ export interface ReporterReport {
17
+ id: string;
18
+ ref: string;
19
+ type: SignalType;
20
+ subject: string;
21
+ body: string;
22
+ status: SignalStatus;
23
+ disposition: string | null;
24
+ fixed_in_version: string | null;
25
+ verified_at: string | null;
26
+ reopens: number;
27
+ source_context: Record<string, unknown> | null;
28
+ created_at: string;
29
+ updated_at: string;
30
+ team_replies: number;
31
+ last_team_reply_at: string | null;
32
+ /**
33
+ * The first time somebody on the team opened it. Separate from `status`: a
34
+ * person looking is not a decision about the report. Absent from a Signal
35
+ * that predates it, so read as "not yet".
36
+ *
37
+ * Never WHO: the team speaks as one, as it does in its replies. Signal
38
+ * 0.14.0-alpha.1 briefly sent `read_by_name`; it is ignored if present.
39
+ */
40
+ read_at?: string | null;
41
+ }
42
+ export type ReporterEvent = {
43
+ kind: 'status';
44
+ from: SignalStatus | null;
45
+ to: SignalStatus;
46
+ at: string;
47
+ by: 'you' | 'team';
48
+ } | {
49
+ kind: 'reply';
50
+ body: string;
51
+ at: string;
52
+ by: 'you' | 'team';
53
+ author_name: string;
54
+ };
55
+ /**
56
+ * One line of the conversation as the panel draws it: the server's timeline,
57
+ * plus the moment the team first read the report. The read is a field on the
58
+ * report rather than a timeline event, so a panel that predates it is not
59
+ * handed a kind it cannot draw.
60
+ */
61
+ export type ThreadItem = ReporterEvent | {
62
+ kind: 'read';
63
+ at: string;
64
+ by: 'team';
65
+ name: string | null;
66
+ };
67
+ export interface ReporterReportDetail extends ReporterReport {
68
+ timeline: ReporterEvent[];
69
+ attached: {
70
+ screenshot: boolean;
71
+ system: boolean;
72
+ browser: boolean;
73
+ trail: boolean;
74
+ extra: boolean;
75
+ };
76
+ }
77
+ /**
78
+ * The stages a report walks through, as a person would describe them.
79
+ *
80
+ * ⚠ THE LAST STAGE IS THE REPORTER'S, AND DRAWING IT IS THE POINT. The anxiety
81
+ * behind every bug report is "does this go anywhere", so the path is shown
82
+ * before anything is filed and again on every report, with the final node
83
+ * belonging to the person reading it.
84
+ */
85
+ export declare const DEFECT_STAGES: readonly ["Sent", "Read", "Accepted", "Fixed", "You check"];
86
+ export declare const FEEDBACK_STAGES: readonly ["Sent", "Read", "Planned", "Shipped"];
87
+ export interface StatusView {
88
+ /** Short, for a pill. */
89
+ label: string;
90
+ /** One sentence, written for the reporter: what this state means for THEM. */
91
+ blurb: string;
92
+ tone: Tone;
93
+ stages: readonly string[];
94
+ /** Index into `stages`. */
95
+ stage: number;
96
+ /** Work is happening at this stage right now, so its marker can pulse. */
97
+ active: boolean;
98
+ /** Left the happy path (not taken, duplicate). The rail stops rather than implying a fix. */
99
+ offPath: boolean;
100
+ /** Still able to change, from the reporter's point of view. */
101
+ open: boolean;
102
+ /** Something is waiting on the reporter. Only this badges. */
103
+ needsYou: boolean;
104
+ }
105
+ /**
106
+ * Compare two version strings, segment by segment.
107
+ *
108
+ * ⚠ A STRING COMPARE IS WRONG HERE AND WOULD LOOK RIGHT FOR A YEAR. "0.9.0"
109
+ * sorts above "0.24.0" lexically, so someone on 0.24.0 would be told to update
110
+ * to an older build, and the check would never appear on the reports already
111
+ * fixed for them. A missing segment reads as 0.
112
+ */
113
+ export declare function compareVersions(a: string, b: string): number;
114
+ /**
115
+ * May the reporter confirm the fix on the build they are running?
116
+ *
117
+ * ⚠ ONLY ONCE THEY ACTUALLY HAVE IT. A "yes, it works" given on the broken
118
+ * build is worse than none: afterwards it looks exactly like a real one. With
119
+ * no fixed version named, or no running version known, the reporter is the
120
+ * best judge, so they are asked.
121
+ */
122
+ export declare function canVerify(r: ReporterReport, runningVersion?: string): boolean;
123
+ /** Fixed, but the reporter is still on an older build. */
124
+ export declare function awaitingUpdate(r: ReporterReport, runningVersion?: string): boolean;
125
+ /**
126
+ * Whether the reporter may still take this report back. Not once it is
127
+ * closed, and not while its fix is on its way: by then the work is done and
128
+ * the useful next step is checking it.
129
+ */
130
+ export declare function canWithdraw(r: ReporterReport): boolean;
131
+ export declare function statusView(r: ReporterReport, runningVersion?: string): StatusView;
132
+ /**
133
+ * The conversation in the order things happened, with the team's first read
134
+ * placed where it fell. Accepting a report usually follows reading it within
135
+ * minutes, and without this line the two arrive as one.
136
+ *
137
+ * A read recorded AFTER the team had already acted is left out: the action
138
+ * already said somebody looked, and "Accepted" followed by "Read" reads as
139
+ * nonsense. It happens to reports acted on before reads were recorded.
140
+ */
141
+ export declare function threadOf(d: ReporterReportDetail): ThreadItem[];
142
+ /** The read line, said from the reporter's side. */
143
+ export declare function readWords(name: string | null): string;
144
+ export type Severity = 'blocking' | 'major' | 'minor' | 'cosmetic';
145
+ /**
146
+ * How much a problem is holding the reporter up, in their words, and what the
147
+ * team's queue needs from it. The team's priority is derived from impact and
148
+ * urgency on the server; a reporter is never asked for a priority, because
149
+ * every reporter would pick the top one.
150
+ */
151
+ export declare const SEVERITY: Record<Severity, {
152
+ label: string;
153
+ hint: string;
154
+ tone: Tone;
155
+ impact: Impact;
156
+ urgency: Urgency;
157
+ }>;
158
+ export type FeedbackKind = 'idea' | 'praise' | 'confusing' | 'complaint';
159
+ export declare const FEEDBACK_KINDS: Record<FeedbackKind, {
160
+ label: string;
161
+ hint: string;
162
+ }>;
163
+ export declare const MIN_DESCRIPTION = 12;
164
+ /**
165
+ * Which field stops a report being sent, if any.
166
+ *
167
+ * Names the field rather than returning a boolean, because "Submit is disabled
168
+ * and I cannot see why" is the most common way a form wastes someone's time.
169
+ */
170
+ export declare function validateIssue(d: {
171
+ what: string;
172
+ expected: string;
173
+ }): {
174
+ field: 'what' | 'expected';
175
+ message: string;
176
+ } | null;
177
+ /** Open reports above closed ones whatever their age: the list exists to be acted on. */
178
+ export declare function sortReports<T extends ReporterReport>(reports: readonly T[], runningVersion?: string): T[];
179
+ /** "3m ago". One phrasing for every duration on the panel. */
180
+ export declare function formatAgo(then: string | number, now?: number): string;
181
+ /**
182
+ * One status move, said from the reporter's side.
183
+ *
184
+ * The same move reads differently depending on who made it and where it came
185
+ * from: the reporter's own filing is "you sent this", not "received by you";
186
+ * answering the team's question is "back with the team", not "accepted by you".
187
+ */
188
+ export declare function eventWords(e: {
189
+ from: SignalStatus | null;
190
+ to: SignalStatus;
191
+ by: 'you' | 'team';
192
+ }): string;
193
+ /**
194
+ * A report's body as the parts it was written in: the SDK files "What
195
+ * happened" and "Expected instead" as labelled blocks, and other producers
196
+ * send free text, which comes back as one unlabelled part.
197
+ */
198
+ export declare function reportParts(body: string): {
199
+ label: string | null;
200
+ text: string;
201
+ }[];
@@ -0,0 +1,26 @@
1
+ import { type SlotName, type SupportWidget, type WidgetConfig } from './widget/index.js';
2
+ export type SignalSupportProps = Omit<WidgetConfig, 'key'> & {
3
+ /** The publishable key (`sig_pub_…`). Named `keyId` because `key` is React's. */
4
+ keyId?: string;
5
+ onReady?: (widget: SupportWidget) => void;
6
+ onAttention?: (count: number) => void;
7
+ };
8
+ /**
9
+ * Mount the panel for the life of the calling component. Configuration is
10
+ * read once; `context` is kept current on every render, because it is the
11
+ * part that changes as someone moves around the product.
12
+ */
13
+ export declare function useSignalSupport(props: SignalSupportProps): SupportWidget | null;
14
+ /**
15
+ * The element behind one of the panel's slots, for `createPortal`:
16
+ *
17
+ * ```tsx
18
+ * const slot = useSignalSlot(widget, 'home-top');
19
+ * return slot && createPortal(<StatusNotice />, slot);
20
+ * ```
21
+ *
22
+ * Your component renders in your tree, with your context and your CSS.
23
+ */
24
+ export declare function useSignalSlot(widget: SupportWidget | null, name: SlotName): HTMLElement | null;
25
+ /** The same, as a component that renders nothing of its own. */
26
+ export declare function SignalSupport(props: SignalSupportProps): null;
package/dist/react.js ADDED
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./chunks/widget-Bxsz5StK.js";import{useEffect as t,useLayoutEffect as n,useMemo as r,useRef as i,useState as a}from"react";function o(r){let[o,s]=a(null),c=i(r);n(()=>{c.current=r}),t(()=>{let{keyId:t,onReady:n,onAttention:r,...i}=c.current,a=e({...i,key:t}),o=r?a.on(`attention`,e=>c.current.onAttention?.(e)):()=>{};return s(a),n?.(a),()=>{o(),a.destroy(),s(null)}},[]);let l=JSON.stringify(r.context??{});t(()=>{o&&r.context&&o.setContext(r.context)},[o,l]);let u=JSON.stringify(r.theme??null);return t(()=>{o&&r.theme&&o.setTheme(r.theme)},[o,u]),t(()=>{o&&r.css!==void 0&&o.setCss(r.css)},[o,r.css]),o}function s(e,t){return r(()=>e?e.slot(t):null,[e,t])}function c(e){return o(e),null}export{c as SignalSupport,s as useSignalSlot,o as useSignalSupport};
2
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["/**\n * `@signalsupport/sdk/react` — the panel as a React hook or component.\n *\n * ```tsx\n * import { SignalSupport } from '@signalsupport/sdk/react';\n *\n * <SignalSupport\n * keyId={process.env.NEXT_PUBLIC_SIGNAL_KEY_ID}\n * session=\"/api/signal/session\"\n * appVersion={version}\n * context={{ area: 'Rigging', projectId }}\n * theme={{ preset: 'studio' }}\n * launcher={false}\n * onReady={(w) => (supportRef.current = w)}\n * />\n * ```\n *\n * The panel is still the framework-free widget in its own Shadow DOM; this\n * only mounts it with the component and keeps its context current.\n */\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\n\nimport { mountSupport, type SlotName, type SupportWidget, type WidgetConfig } from './widget/index.js';\n\nexport type SignalSupportProps = Omit<WidgetConfig, 'key'> & {\n /** The publishable key (`sig_pub_…`). Named `keyId` because `key` is React's. */\n keyId?: string;\n onReady?: (widget: SupportWidget) => void;\n onAttention?: (count: number) => void;\n};\n\n/**\n * Mount the panel for the life of the calling component. Configuration is\n * read once; `context` is kept current on every render, because it is the\n * part that changes as someone moves around the product.\n */\nexport function useSignalSupport(props: SignalSupportProps): SupportWidget | null {\n const [widget, setWidget] = useState<SupportWidget | null>(null);\n const latest = useRef(props);\n // Never written during render: a concurrent render React throws away would\n // leave the ref holding props that were never committed. A layout effect runs\n // before the mount effect below, so that one still reads this render's props.\n useLayoutEffect(() => {\n latest.current = props;\n });\n\n useEffect(() => {\n const { keyId, onReady, onAttention, ...rest } = latest.current;\n const w = mountSupport({ ...rest, key: keyId });\n const off = onAttention ? w.on('attention', (n) => latest.current.onAttention?.(n as number)) : () => {};\n setWidget(w);\n onReady?.(w);\n return () => {\n off();\n w.destroy();\n setWidget(null);\n };\n }, []);\n\n const contextKey = JSON.stringify(props.context ?? {});\n useEffect(() => {\n if (widget && props.context) widget.setContext(props.context);\n }, [widget, contextKey]);\n\n const themeKey = JSON.stringify(props.theme ?? null);\n useEffect(() => {\n if (widget && props.theme) widget.setTheme(props.theme);\n }, [widget, themeKey]);\n\n useEffect(() => {\n if (widget && props.css !== undefined) widget.setCss(props.css);\n }, [widget, props.css]);\n\n return widget;\n}\n\n/**\n * The element behind one of the panel's slots, for `createPortal`:\n *\n * ```tsx\n * const slot = useSignalSlot(widget, 'home-top');\n * return slot && createPortal(<StatusNotice />, slot);\n * ```\n *\n * Your component renders in your tree, with your context and your CSS.\n */\nexport function useSignalSlot(widget: SupportWidget | null, name: SlotName): HTMLElement | null {\n return useMemo(() => (widget ? widget.slot(name) : null), [widget, name]);\n}\n\n/** The same, as a component that renders nothing of its own. */\nexport function SignalSupport(props: SignalSupportProps): null {\n useSignalSupport(props);\n return null;\n}\n"],"mappings":"8IAoCA,SAAgB,EAAiB,EAAiD,CAChF,GAAM,CAAC,EAAQ,GAAa,EAA+B,IAAI,EACzD,EAAS,EAAO,CAAK,EAI3B,MAAsB,CACpB,EAAO,QAAU,CACnB,CAAC,EAED,MAAgB,CACd,GAAM,CAAE,QAAO,UAAS,cAAa,GAAG,GAAS,EAAO,QAClD,EAAI,EAAa,CAAE,GAAG,EAAM,IAAK,CAAM,CAAC,EACxC,EAAM,EAAc,EAAE,GAAG,YAAc,GAAM,EAAO,QAAQ,cAAc,CAAW,CAAC,MAAU,CAAC,EAGvG,OAFA,EAAU,CAAC,EACX,IAAU,CAAC,MACE,CACX,EAAI,EACJ,EAAE,QAAQ,EACV,EAAU,IAAI,CAChB,CACF,EAAG,CAAC,CAAC,EAEL,IAAM,EAAa,KAAK,UAAU,EAAM,SAAW,CAAC,CAAC,EACrD,MAAgB,CACV,GAAU,EAAM,SAAS,EAAO,WAAW,EAAM,OAAO,CAC9D,EAAG,CAAC,EAAQ,CAAU,CAAC,EAEvB,IAAM,EAAW,KAAK,UAAU,EAAM,OAAS,IAAI,EASnD,OARA,MAAgB,CACV,GAAU,EAAM,OAAO,EAAO,SAAS,EAAM,KAAK,CACxD,EAAG,CAAC,EAAQ,CAAQ,CAAC,EAErB,MAAgB,CACV,GAAU,EAAM,MAAQ,IAAA,IAAW,EAAO,OAAO,EAAM,GAAG,CAChE,EAAG,CAAC,EAAQ,EAAM,GAAG,CAAC,EAEf,CACT,CAYA,SAAgB,EAAc,EAA8B,EAAoC,CAC9F,OAAO,MAAe,EAAS,EAAO,KAAK,CAAI,EAAI,KAAO,CAAC,EAAQ,CAAI,CAAC,CAC1E,CAGA,SAAgB,EAAc,EAAiC,CAE7D,OADA,EAAiB,CAAK,EACf,IACT"}
@@ -0,0 +1,14 @@
1
+ import type { ReporterReport, ReporterReportDetail } from './model.js';
2
+ import type { SessionCache } from './session.js';
3
+ /**
4
+ * The reporter's own reports, through their session: list, read, reply, and
5
+ * say whether a fix works. Nothing here can reach anyone else's.
6
+ */
7
+ export interface ReporterApi {
8
+ list(): Promise<ReporterReport[]>;
9
+ get(id: string): Promise<ReporterReportDetail>;
10
+ reply(id: string, body: string): Promise<ReporterReportDetail>;
11
+ verify(id: string, works: boolean, note?: string): Promise<ReporterReportDetail>;
12
+ withdraw(id: string, note?: string): Promise<ReporterReportDetail>;
13
+ }
14
+ export declare function createReporterApi(base: string, session: SessionCache, fetchImpl?: typeof fetch): ReporterApi;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `@signalsupport/sdk/server` — the part that runs on YOUR server.
3
+ *
4
+ * A reporter session lets a signed-in person's browser file reports as them,
5
+ * follow those reports, read the team's replies and confirm a fix, without the
6
+ * browser ever holding your secret key. Your server vouches for the person:
7
+ *
8
+ * ```ts
9
+ * // app/api/signal/session/route.ts (Next.js; any Fetch-style framework works)
10
+ * import { sessionHandler } from '@signalsupport/sdk/server';
11
+ *
12
+ * export const POST = sessionHandler({
13
+ * secretKey: process.env.SIGNAL_KEY_SECRET!,
14
+ * getUser: async (req) => {
15
+ * const user = await currentUser(req); // your own auth
16
+ * return user && { id: user.id, name: user.name, email: user.email };
17
+ * },
18
+ * });
19
+ * ```
20
+ *
21
+ * Then give the widget `session: '/api/signal/session'`.
22
+ */
23
+ export interface ReporterIdentity {
24
+ /** Your product's id for this person. */
25
+ id: string;
26
+ name?: string;
27
+ email?: string;
28
+ }
29
+ export interface SessionOptions {
30
+ /** A secret key (`sig_live_…` or `sig_test_…`). Never a publishable key. */
31
+ secretKey: string;
32
+ apiBase?: string;
33
+ /** Seconds. Default one hour, at most a day. */
34
+ ttlSeconds?: number;
35
+ fetchImpl?: typeof fetch;
36
+ }
37
+ export interface MintedSession {
38
+ token: string;
39
+ expires_at: string;
40
+ }
41
+ /** Mint a reporter session for one of your users. Server-side only. */
42
+ export declare function createReporterSession(user: ReporterIdentity, options: SessionOptions): Promise<MintedSession>;
43
+ export interface HandlerOptions extends SessionOptions {
44
+ /**
45
+ * Who is signed in, from your own auth. Return null for nobody: the handler
46
+ * answers 401 and the panel works anonymously.
47
+ */
48
+ getUser: (req: Request) => Promise<ReporterIdentity | null | undefined> | ReporterIdentity | null | undefined;
49
+ }
50
+ /**
51
+ * A ready-made route for any framework that speaks `Request` / `Response`:
52
+ * Next.js route handlers, Hono, Remix, SvelteKit, Bun, Deno, Workers.
53
+ */
54
+ export declare function sessionHandler(options: HandlerOptions): (req: Request) => Promise<Response>;
package/dist/server.js ADDED
@@ -0,0 +1,2 @@
1
+ import{i as e,n as t,r as n}from"./chunks/constants-ClGbEpjT.js";async function r(r,i){if(!i.secretKey)throw new t(`CONFIG`,`createReporterSession needs your secret key`);if(i.secretKey.startsWith(`sig_pub_`))throw new t(`CONFIG`,`a publishable key (sig_pub_) cannot vouch for a person. Use the secret key (sig_live_), on your server.`);let a=(i.apiBase??`https://signal-api.character.xyz`).replace(/\/+$/,``),o=i.fetchImpl??globalThis.fetch?.bind(globalThis);if(!o)throw new t(`CONFIG`,`no fetch is available; pass fetchImpl`);let s;try{s=await o(`${a}/v1/reporter-sessions`,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${i.secretKey}`},body:JSON.stringify({external_id:r.id,name:r.name,email:r.email,ttl_seconds:i.ttlSeconds})})}catch(t){throw e(a,t)}let c=await n(s);return{token:c.token,expires_at:c.expires_at}}function i(e){return async n=>{let i=(e,t)=>new Response(JSON.stringify(t),{status:e,headers:{"content-type":`application/json`,"cache-control":`no-store`}}),a;try{a=await e.getUser(n)}catch{a=null}if(!a?.id)return i(401,{error:{code:`UNAUTHENTICATED`,message:`nobody is signed in`}});try{return i(200,await r(a,e))}catch(e){return i(e instanceof t&&e.status?e.status:502,{error:{code:e instanceof t?e.code:`UPSTREAM`,message:e instanceof Error?e.message:String(e)}})}}}export{r as createReporterSession,i as sessionHandler};
2
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * `@signalsupport/sdk/server` — the part that runs on YOUR server.\n *\n * A reporter session lets a signed-in person's browser file reports as them,\n * follow those reports, read the team's replies and confirm a fix, without the\n * browser ever holding your secret key. Your server vouches for the person:\n *\n * ```ts\n * // app/api/signal/session/route.ts (Next.js; any Fetch-style framework works)\n * import { sessionHandler } from '@signalsupport/sdk/server';\n *\n * export const POST = sessionHandler({\n * secretKey: process.env.SIGNAL_KEY_SECRET!,\n * getUser: async (req) => {\n * const user = await currentUser(req); // your own auth\n * return user && { id: user.id, name: user.name, email: user.email };\n * },\n * });\n * ```\n *\n * Then give the widget `session: '/api/signal/session'`.\n */\n\nimport { DEFAULT_API_BASE } from './constants.js';\nimport { readEnvelope, transportError, SignalError } from './errors.js';\n\nexport interface ReporterIdentity {\n /** Your product's id for this person. */\n id: string;\n name?: string;\n email?: string;\n}\n\nexport interface SessionOptions {\n /** A secret key (`sig_live_…` or `sig_test_…`). Never a publishable key. */\n secretKey: string;\n apiBase?: string;\n /** Seconds. Default one hour, at most a day. */\n ttlSeconds?: number;\n fetchImpl?: typeof fetch;\n}\n\nexport interface MintedSession {\n token: string;\n expires_at: string;\n}\n\n/** Mint a reporter session for one of your users. Server-side only. */\nexport async function createReporterSession(\n user: ReporterIdentity,\n options: SessionOptions,\n): Promise<MintedSession> {\n if (!options.secretKey) throw new SignalError('CONFIG', 'createReporterSession needs your secret key');\n if (options.secretKey.startsWith('sig_pub_')) {\n throw new SignalError(\n 'CONFIG',\n 'a publishable key (sig_pub_) cannot vouch for a person. Use the secret key (sig_live_), on your server.',\n );\n }\n const base = (options.apiBase ?? DEFAULT_API_BASE).replace(/\\/+$/, '');\n const f = options.fetchImpl ?? globalThis.fetch?.bind(globalThis);\n if (!f) throw new SignalError('CONFIG', 'no fetch is available; pass fetchImpl');\n let res: Response;\n try {\n res = await f(`${base}/v1/reporter-sessions`, {\n method: 'POST',\n headers: { 'content-type': 'application/json', authorization: `Bearer ${options.secretKey}` },\n body: JSON.stringify({\n external_id: user.id,\n name: user.name,\n email: user.email,\n ttl_seconds: options.ttlSeconds,\n }),\n });\n } catch (cause) {\n throw transportError(base, cause);\n }\n const data = await readEnvelope<MintedSession>(res);\n return { token: data.token, expires_at: data.expires_at };\n}\n\nexport interface HandlerOptions extends SessionOptions {\n /**\n * Who is signed in, from your own auth. Return null for nobody: the handler\n * answers 401 and the panel works anonymously.\n */\n getUser: (req: Request) => Promise<ReporterIdentity | null | undefined> | ReporterIdentity | null | undefined;\n}\n\n/**\n * A ready-made route for any framework that speaks `Request` / `Response`:\n * Next.js route handlers, Hono, Remix, SvelteKit, Bun, Deno, Workers.\n */\nexport function sessionHandler(options: HandlerOptions): (req: Request) => Promise<Response> {\n return async (req) => {\n const json = (status: number, body: unknown) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },\n });\n let user: ReporterIdentity | null | undefined;\n try {\n user = await options.getUser(req);\n } catch {\n user = null;\n }\n if (!user?.id) return json(401, { error: { code: 'UNAUTHENTICATED', message: 'nobody is signed in' } });\n try {\n return json(200, await createReporterSession(user, options));\n } catch (e) {\n const status = e instanceof SignalError && e.status ? e.status : 502;\n return json(status, { error: { code: e instanceof SignalError ? e.code : 'UPSTREAM', message: e instanceof Error ? e.message : String(e) } });\n }\n };\n}\n"],"mappings":"iEAgDA,eAAsB,EACpB,EACA,EACwB,CACxB,GAAI,CAAC,EAAQ,UAAW,MAAM,IAAI,EAAY,SAAU,6CAA6C,EACrG,GAAI,EAAQ,UAAU,WAAW,UAAU,EACzC,MAAM,IAAI,EACR,SACA,yGACF,EAEF,IAAM,GAAQ,EAAQ,SAAA,mCAAA,CAA6B,QAAQ,OAAQ,EAAE,EAC/D,EAAI,EAAQ,WAAa,WAAW,OAAO,KAAK,UAAU,EAChE,GAAI,CAAC,EAAG,MAAM,IAAI,EAAY,SAAU,uCAAuC,EAC/E,IAAI,EACJ,GAAI,CACF,EAAM,MAAM,EAAE,GAAG,EAAK,uBAAwB,CAC5C,OAAQ,OACR,QAAS,CAAE,eAAgB,mBAAoB,cAAe,UAAU,EAAQ,WAAY,EAC5F,KAAM,KAAK,UAAU,CACnB,YAAa,EAAK,GAClB,KAAM,EAAK,KACX,MAAO,EAAK,MACZ,YAAa,EAAQ,UACvB,CAAC,CACH,CAAC,CACH,OAAS,EAAO,CACd,MAAM,EAAe,EAAM,CAAK,CAClC,CACA,IAAM,EAAO,MAAM,EAA4B,CAAG,EAClD,MAAO,CAAE,MAAO,EAAK,MAAO,WAAY,EAAK,UAAW,CAC1D,CAcA,SAAgB,EAAe,EAA8D,CAC3F,OAAO,KAAO,IAAQ,CACpB,IAAM,GAAQ,EAAgB,IAC5B,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACjC,SACA,QAAS,CAAE,eAAgB,mBAAoB,gBAAiB,UAAW,CAC7E,CAAC,EACC,EACJ,GAAI,CACF,EAAO,MAAM,EAAQ,QAAQ,CAAG,CAClC,MAAQ,CACN,EAAO,IACT,CACA,GAAI,CAAC,GAAM,GAAI,OAAO,EAAK,IAAK,CAAE,MAAO,CAAE,KAAM,kBAAmB,QAAS,qBAAsB,CAAE,CAAC,EACtG,GAAI,CACF,OAAO,EAAK,IAAK,MAAM,EAAsB,EAAM,CAAO,CAAC,CAC7D,OAAS,EAAG,CAEV,OAAO,EADQ,aAAa,GAAe,EAAE,OAAS,EAAE,OAAS,IAC7C,CAAE,MAAO,CAAE,KAAM,aAAa,EAAc,EAAE,KAAO,WAAY,QAAS,aAAa,MAAQ,EAAE,QAAU,OAAO,CAAC,CAAE,CAAE,CAAC,CAC9I,CACF,CACF"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The reporter session: who this browser is reporting as.
3
+ *
4
+ * The product's SERVER mints it with the secret key (see `./server`) and hands
5
+ * it to the browser; the browser never sees the secret. The SDK asks for one
6
+ * when it first needs it, keeps it until a minute before it expires, and asks
7
+ * again once if the service says it is no longer valid.
8
+ */
9
+ export interface SessionToken {
10
+ /** `rs_…` */
11
+ token: string;
12
+ /** ISO time. Without it the token is kept until the service refuses it. */
13
+ expires_at?: string;
14
+ }
15
+ /**
16
+ * Where a session comes from.
17
+ *
18
+ * - a URL on your own server (`'/api/signal/session'`): POSTed with the
19
+ * browser's cookies, answering `{ token, expires_at }`. Anything other than
20
+ * 200 means "nobody is signed in", and the panel works anonymously.
21
+ * - a function returning the same, for apps that fetch it their own way.
22
+ */
23
+ export type SessionSource = string | (() => Promise<SessionToken | string | null | undefined>);
24
+ export interface SessionCache {
25
+ /** The current token, fetching one if needed. Null when nobody is signed in. */
26
+ get(force?: boolean): Promise<string | null>;
27
+ clear(): void;
28
+ }
29
+ export declare function createSessionCache(source: SessionSource | undefined, fetchImpl?: typeof fetch): SessionCache;