@lightworkai.official/debug-capture 0.12.7 → 0.12.8

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/dist/server.d.ts CHANGED
@@ -1,10 +1,36 @@
1
+ /**
2
+ * One fact about a person's WORK, as the host knows it — their unit, their
3
+ * position — with the host's own label for it. The support desk shows these
4
+ * beside the person's name and records them with what the person does (an
5
+ * event registration takes them as its own), so nobody types their unit into
6
+ * a form the host could have filled.
7
+ *
8
+ * `key` is stable across people (it names a column in an export); `label` is
9
+ * what a person reads, in the host's language. Order matters: the first is
10
+ * the one drawn under a name.
11
+ */
12
+ export interface SupportProfileFact {
13
+ key: string;
14
+ label: string;
15
+ value: string;
16
+ }
1
17
  /** Whoever the host has authenticated. Email is the account key. */
2
18
  export interface SupportUser {
3
19
  /** The host's own id for this person, recorded so a changed email still matches. */
4
20
  id?: string | null;
5
21
  email: string;
6
22
  name?: string | null;
23
+ /** What the host knows about their work — most important first. Optional; bounded on the way in. */
24
+ profile?: SupportProfileFact[] | null;
7
25
  }
26
+ /** How much of a profile rides in a token. It travels in a URL, so it is small on purpose. */
27
+ export declare const PROFILE_MAX_FACTS = 10;
28
+ /**
29
+ * A profile made safe to sign: known shape, stable keys, trimmed, bounded.
30
+ * A fact with no value is left out (an empty unit is not a unit). Null when
31
+ * there is nothing to say — the support side then keeps what it had.
32
+ */
33
+ export declare function cleanProfile(profile: SupportProfileFact[] | null | undefined): SupportProfileFact[] | null;
8
34
  /**
9
35
  * Two minutes.
10
36
  *
@@ -31,6 +57,28 @@ export declare function createSupportToken(options: {
31
57
  token: string;
32
58
  expiresIn: number;
33
59
  };
60
+ /**
61
+ * The address that signs a person into the support desk and lands them on a
62
+ * page of it — for a host that sends people there by LINK rather than from
63
+ * its own button (a notice on a portal, a "sign in" door the desk bounces a
64
+ * signed-out reader to). The token is minted here, on the server, at the
65
+ * moment of the redirect; it lives two minutes and is spent on arrival.
66
+ *
67
+ * redirect(supportSignInUrl({ host, realmKey, secret, user, returnPath: "/e/อบรม-a4f2" }))
68
+ *
69
+ * `returnPath` must be a path on the support desk; the desk decides again
70
+ * where it may land (its own reporter pages, or an event).
71
+ */
72
+ export declare function supportSignInUrl(options: {
73
+ /** The support desk's origin, e.g. https://support.example.com */
74
+ host: string;
75
+ /** The realm's public key. */
76
+ realmKey: string;
77
+ secret: string;
78
+ user: SupportUser;
79
+ returnPath?: string | null;
80
+ ttlSeconds?: number;
81
+ }): string;
34
82
  /**
35
83
  * A ready-made route handler, for the common case.
36
84
  *
package/dist/server.mjs CHANGED
@@ -1,5 +1,25 @@
1
1
  // src/server.ts
2
2
  import { createHmac, randomUUID } from "node:crypto";
3
+ var PROFILE_MAX_FACTS = 10;
4
+ var PROFILE_KEY = /^[a-z][a-z0-9_]{0,31}$/;
5
+ function cleanProfile(profile) {
6
+ if (!Array.isArray(profile))
7
+ return null;
8
+ const out = [];
9
+ const seen = new Set;
10
+ for (const f of profile) {
11
+ const key = typeof f?.key === "string" ? f.key.trim() : "";
12
+ const value = typeof f?.value === "string" ? f.value.trim() : "";
13
+ if (!PROFILE_KEY.test(key) || !value || seen.has(key))
14
+ continue;
15
+ seen.add(key);
16
+ const label = typeof f.label === "string" && f.label.trim() ? f.label.trim().slice(0, 40) : key;
17
+ out.push({ key, label, value: value.slice(0, 120) });
18
+ if (out.length >= PROFILE_MAX_FACTS)
19
+ break;
20
+ }
21
+ return out.length ? out : null;
22
+ }
3
23
  var DEFAULT_TTL_SECONDS = 120;
4
24
  var b64 = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
5
25
  function createSupportToken(options) {
@@ -15,12 +35,23 @@ function createSupportToken(options) {
15
35
  sub: options.user.id?.trim() || email,
16
36
  email,
17
37
  name: options.user.name?.trim() || null,
38
+ ...cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {},
18
39
  jti: randomUUID(),
19
40
  iat: now,
20
41
  exp: now + expiresIn
21
42
  })}`;
22
43
  return { token: `${body}.${createHmac("sha256", options.secret).update(body).digest("base64url")}`, expiresIn };
23
44
  }
45
+ function supportSignInUrl(options) {
46
+ const { token } = createSupportToken({ secret: options.secret, user: options.user, ttlSeconds: options.ttlSeconds });
47
+ const url = new URL("/sso", options.host.replace(/\/+$/, ""));
48
+ url.searchParams.set("realm", options.realmKey);
49
+ url.searchParams.set("token", token);
50
+ const back = options.returnPath?.trim();
51
+ if (back && back.startsWith("/") && !back.startsWith("//"))
52
+ url.searchParams.set("return", back);
53
+ return url.toString();
54
+ }
24
55
  function supportTokenHandler(options) {
25
56
  return async () => {
26
57
  const secret = options.secret ?? process.env.SUPPORT_REPORTER_SECRET;
@@ -49,6 +80,9 @@ function json(body, status) {
49
80
  }
50
81
  export {
51
82
  supportTokenHandler,
83
+ supportSignInUrl,
52
84
  createSupportToken,
85
+ cleanProfile,
86
+ PROFILE_MAX_FACTS,
53
87
  DEFAULT_TTL_SECONDS
54
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
5
5
  "type": "module",
6
6
  "module": "./dist/index.mjs",
package/src/server.ts CHANGED
@@ -17,12 +17,56 @@
17
17
  */
18
18
  import { createHmac, randomUUID } from "node:crypto";
19
19
 
20
+ /**
21
+ * One fact about a person's WORK, as the host knows it — their unit, their
22
+ * position — with the host's own label for it. The support desk shows these
23
+ * beside the person's name and records them with what the person does (an
24
+ * event registration takes them as its own), so nobody types their unit into
25
+ * a form the host could have filled.
26
+ *
27
+ * `key` is stable across people (it names a column in an export); `label` is
28
+ * what a person reads, in the host's language. Order matters: the first is
29
+ * the one drawn under a name.
30
+ */
31
+ export interface SupportProfileFact {
32
+ key: string;
33
+ label: string;
34
+ value: string;
35
+ }
36
+
20
37
  /** Whoever the host has authenticated. Email is the account key. */
21
38
  export interface SupportUser {
22
39
  /** The host's own id for this person, recorded so a changed email still matches. */
23
40
  id?: string | null;
24
41
  email: string;
25
42
  name?: string | null;
43
+ /** What the host knows about their work — most important first. Optional; bounded on the way in. */
44
+ profile?: SupportProfileFact[] | null;
45
+ }
46
+
47
+ /** How much of a profile rides in a token. It travels in a URL, so it is small on purpose. */
48
+ export const PROFILE_MAX_FACTS = 10;
49
+ const PROFILE_KEY = /^[a-z][a-z0-9_]{0,31}$/;
50
+
51
+ /**
52
+ * A profile made safe to sign: known shape, stable keys, trimmed, bounded.
53
+ * A fact with no value is left out (an empty unit is not a unit). Null when
54
+ * there is nothing to say — the support side then keeps what it had.
55
+ */
56
+ export function cleanProfile(profile: SupportProfileFact[] | null | undefined): SupportProfileFact[] | null {
57
+ if (!Array.isArray(profile)) return null;
58
+ const out: SupportProfileFact[] = [];
59
+ const seen = new Set<string>();
60
+ for (const f of profile) {
61
+ const key = typeof f?.key === "string" ? f.key.trim() : "";
62
+ const value = typeof f?.value === "string" ? f.value.trim() : "";
63
+ if (!PROFILE_KEY.test(key) || !value || seen.has(key)) continue;
64
+ seen.add(key);
65
+ const label = typeof f.label === "string" && f.label.trim() ? f.label.trim().slice(0, 40) : key;
66
+ out.push({ key, label, value: value.slice(0, 120) });
67
+ if (out.length >= PROFILE_MAX_FACTS) break;
68
+ }
69
+ return out.length ? out : null;
26
70
  }
27
71
 
28
72
  /**
@@ -65,6 +109,8 @@ export function createSupportToken(options: {
65
109
  sub: options.user.id?.trim() || email,
66
110
  email,
67
111
  name: options.user.name?.trim() || null,
112
+ // Only when there is one: an older support desk ignores the claim, a newer one records it.
113
+ ...(cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {}),
68
114
  // Spent once on arrival; without it the freshness window is a replay window.
69
115
  jti: randomUUID(),
70
116
  iat: now,
@@ -74,6 +120,38 @@ export function createSupportToken(options: {
74
120
  return { token: `${body}.${createHmac("sha256", options.secret).update(body).digest("base64url")}`, expiresIn };
75
121
  }
76
122
 
123
+ /**
124
+ * The address that signs a person into the support desk and lands them on a
125
+ * page of it — for a host that sends people there by LINK rather than from
126
+ * its own button (a notice on a portal, a "sign in" door the desk bounces a
127
+ * signed-out reader to). The token is minted here, on the server, at the
128
+ * moment of the redirect; it lives two minutes and is spent on arrival.
129
+ *
130
+ * redirect(supportSignInUrl({ host, realmKey, secret, user, returnPath: "/e/อบรม-a4f2" }))
131
+ *
132
+ * `returnPath` must be a path on the support desk; the desk decides again
133
+ * where it may land (its own reporter pages, or an event).
134
+ */
135
+ export function supportSignInUrl(options: {
136
+ /** The support desk's origin, e.g. https://support.example.com */
137
+ host: string;
138
+ /** The realm's public key. */
139
+ realmKey: string;
140
+ secret: string;
141
+ user: SupportUser;
142
+ returnPath?: string | null;
143
+ ttlSeconds?: number;
144
+ }): string {
145
+ const { token } = createSupportToken({ secret: options.secret, user: options.user, ttlSeconds: options.ttlSeconds });
146
+ const url = new URL("/sso", options.host.replace(/\/+$/, ""));
147
+ url.searchParams.set("realm", options.realmKey);
148
+ url.searchParams.set("token", token);
149
+ const back = options.returnPath?.trim();
150
+ // A path, never an address: the desk would refuse anything else anyway, so say so here first.
151
+ if (back && back.startsWith("/") && !back.startsWith("//")) url.searchParams.set("return", back);
152
+ return url.toString();
153
+ }
154
+
77
155
  /**
78
156
  * A ready-made route handler, for the common case.
79
157
  *