@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.
- package/LICENSE +21 -0
- package/README.md +373 -2
- package/dist/chunks/app-7692BILt.js +2 -0
- package/dist/chunks/app-7692BILt.js.map +1 -0
- package/dist/chunks/constants-ClGbEpjT.js +2 -0
- package/dist/chunks/constants-ClGbEpjT.js.map +1 -0
- package/dist/chunks/support-x7N-k02b.js +4 -0
- package/dist/chunks/support-x7N-k02b.js.map +1 -0
- package/dist/chunks/widget-Bxsz5StK.js +2 -0
- package/dist/chunks/widget-Bxsz5StK.js.map +1 -0
- package/dist/client.d.ts +99 -0
- package/dist/constants.d.ts +2 -0
- package/dist/errors.d.ts +25 -0
- package/dist/evidence.d.ts +87 -0
- package/dist/iife.d.ts +12 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1 -0
- package/dist/model.d.ts +201 -0
- package/dist/react.d.ts +26 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -0
- package/dist/reporter.d.ts +14 -0
- package/dist/server.d.ts +54 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +29 -0
- package/dist/signal.iife.js +4 -0
- package/dist/signal.iife.js.map +1 -0
- package/dist/support.d.ts +108 -0
- package/dist/trail.d.ts +27 -0
- package/dist/widget/app.d.ts +31 -0
- package/dist/widget/copy.d.ts +104 -0
- package/dist/widget/ctx.d.ts +75 -0
- package/dist/widget/icons.d.ts +38 -0
- package/dist/widget/index.d.ts +111 -0
- package/dist/widget/index.js +1 -0
- package/dist/widget/parts.d.ts +28 -0
- package/dist/widget/pick.d.ts +30 -0
- package/dist/widget/screens/faq.d.ts +8 -0
- package/dist/widget/screens/feedback.d.ts +11 -0
- package/dist/widget/screens/home.d.ts +12 -0
- package/dist/widget/screens/report.d.ts +9 -0
- package/dist/widget/screens/reports.d.ts +26 -0
- package/dist/widget/store.d.ts +31 -0
- package/dist/widget/styles.d.ts +10 -0
- package/dist/widget/theme.d.ts +57 -0
- package/dist/widget/ui.d.ts +58 -0
- package/package.json +73 -4
package/dist/model.d.ts
ADDED
|
@@ -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
|
+
}[];
|
package/dist/react.d.ts
ADDED
|
@@ -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;
|
package/dist/server.d.ts
ADDED
|
@@ -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;
|