@lightworkai.official/debug-capture 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -14
- package/dist/server.d.ts +57 -0
- package/dist/server.mjs +54 -0
- package/package.json +8 -3
- package/src/server.ts +132 -0
- package/src/submit.ts +1 -1
package/README.md
CHANGED
|
@@ -81,32 +81,41 @@ behind a public key is an endpoint that reads *everyone's* reports.
|
|
|
81
81
|
|
|
82
82
|
So your server vouches for your user. Ask the support team for the realm's
|
|
83
83
|
reporter secret (an admin sets `authConfig.reporterSecret` on the realm), keep
|
|
84
|
-
it on your **server**, and
|
|
84
|
+
it on your **server**, and mount one route:
|
|
85
85
|
|
|
86
86
|
```ts
|
|
87
|
-
//
|
|
88
|
-
import
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
87
|
+
// app/api/support-token/route.ts (Next.js app router)
|
|
88
|
+
import { supportTokenHandler } from "@lightworkai.official/debug-capture/server";
|
|
89
|
+
import { getServerSession } from "next-auth";
|
|
90
|
+
import { authOptions } from "@/lib/auth";
|
|
91
|
+
|
|
92
|
+
export const dynamic = "force-dynamic";
|
|
93
|
+
export const GET = supportTokenHandler({
|
|
94
|
+
// The one thing we cannot do for you: only you can read your session.
|
|
95
|
+
user: async () => {
|
|
96
|
+
const session = await getServerSession(authOptions);
|
|
97
|
+
return session?.user?.email ? { id: session.user.id, email: session.user.email } : null;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
99
100
|
```
|
|
100
101
|
|
|
102
|
+
It returns a standard `Response`, so it works anywhere the Fetch API does — Bun,
|
|
103
|
+
Deno, Hono, Next. The secret comes from `SUPPORT_REPORTER_SECRET` unless you
|
|
104
|
+
pass one. `createSupportToken()` is the same thing without the routing, if your
|
|
105
|
+
framework wants the response built its own way.
|
|
106
|
+
|
|
101
107
|
Wire it once, as the `identity` callback:
|
|
102
108
|
|
|
103
109
|
```ts
|
|
104
110
|
configureDebugCapture({
|
|
105
|
-
host, realmKey, app: { name: "
|
|
111
|
+
host, realmKey, app: { name: "ACME" },
|
|
106
112
|
identity: async () => (await fetch("/api/support-token").then((r) => r.json())).token,
|
|
107
113
|
});
|
|
108
114
|
```
|
|
109
115
|
|
|
116
|
+
**Import from `/server`, never the package root** — it uses `node:crypto` and
|
|
117
|
+
touches the secret, while the root export is browser code.
|
|
118
|
+
|
|
110
119
|
The **secret never reaches the browser** — the callback fetches a finished token
|
|
111
120
|
from your own backend. Returning `null` means "nobody is signed in", and the
|
|
112
121
|
button says so rather than opening a dead link.
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Whoever the host has authenticated. Email is the account key. */
|
|
2
|
+
export interface SupportUser {
|
|
3
|
+
/** The host's own id for this person, recorded so a changed email still matches. */
|
|
4
|
+
id?: string | null;
|
|
5
|
+
email: string;
|
|
6
|
+
name?: string | null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Two minutes.
|
|
10
|
+
*
|
|
11
|
+
* The token is fetched when the button is pressed and spent on arrival, so it
|
|
12
|
+
* only has to survive one redirect — and it travels in a URL, which means
|
|
13
|
+
* browser history and every access log along the way. The support host refuses
|
|
14
|
+
* anything older than this regardless of what `exp` claims, so a longer TTL
|
|
15
|
+
* buys nothing.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DEFAULT_TTL_SECONDS = 120;
|
|
18
|
+
/**
|
|
19
|
+
* Sign a reporter token for a user the host has ALREADY authenticated.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately not a general "sign anything" helper: it asserts the person
|
|
22
|
+
* handed to it and nothing else. There is no path here to mint a token for
|
|
23
|
+
* somebody who is not signed in.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createSupportToken(options: {
|
|
26
|
+
/** The realm's reporter secret. Server-side only — never send it to a browser. */
|
|
27
|
+
secret: string;
|
|
28
|
+
user: SupportUser;
|
|
29
|
+
ttlSeconds?: number;
|
|
30
|
+
}): {
|
|
31
|
+
token: string;
|
|
32
|
+
expiresIn: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A ready-made route handler, for the common case.
|
|
36
|
+
*
|
|
37
|
+
* Returns a standard `Response`, so it works anywhere the Fetch API does —
|
|
38
|
+
* Next's app router, Bun, Deno, Hono. The one thing it cannot do for you is
|
|
39
|
+
* work out who is signed in; that is your session, and only you can read it.
|
|
40
|
+
*
|
|
41
|
+
* // app/api/support-token/route.ts
|
|
42
|
+
* import { supportTokenHandler } from "@lightworkai.official/debug-capture/server";
|
|
43
|
+
* export const dynamic = "force-dynamic";
|
|
44
|
+
* export const GET = supportTokenHandler({
|
|
45
|
+
* user: async () => {
|
|
46
|
+
* const session = await getServerSession(authOptions);
|
|
47
|
+
* return session?.user ? { id: session.user.id, email: session.user.email } : null;
|
|
48
|
+
* },
|
|
49
|
+
* });
|
|
50
|
+
*/
|
|
51
|
+
export declare function supportTokenHandler(options: {
|
|
52
|
+
/** Defaults to `process.env.SUPPORT_REPORTER_SECRET`. */
|
|
53
|
+
secret?: string;
|
|
54
|
+
/** Null when nobody is signed in — answered 401, which the button reports. */
|
|
55
|
+
user: () => SupportUser | null | Promise<SupportUser | null>;
|
|
56
|
+
ttlSeconds?: number;
|
|
57
|
+
}): () => Promise<Response>;
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
3
|
+
var DEFAULT_TTL_SECONDS = 120;
|
|
4
|
+
var b64 = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
5
|
+
function createSupportToken(options) {
|
|
6
|
+
const email = options.user.email?.trim().toLowerCase();
|
|
7
|
+
if (!email || !email.includes("@"))
|
|
8
|
+
throw new Error("createSupportToken: a real email is required");
|
|
9
|
+
if (!options.secret || options.secret.length < 16) {
|
|
10
|
+
throw new Error("createSupportToken: the realm's reporter secret is missing or too short");
|
|
11
|
+
}
|
|
12
|
+
const now = Math.floor(Date.now() / 1000);
|
|
13
|
+
const expiresIn = Math.min(Math.max(Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), 30), 12 * 60 * 60);
|
|
14
|
+
const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({
|
|
15
|
+
sub: options.user.id?.trim() || email,
|
|
16
|
+
email,
|
|
17
|
+
name: options.user.name?.trim() || null,
|
|
18
|
+
jti: randomUUID(),
|
|
19
|
+
iat: now,
|
|
20
|
+
exp: now + expiresIn
|
|
21
|
+
})}`;
|
|
22
|
+
return { token: `${body}.${createHmac("sha256", options.secret).update(body).digest("base64url")}`, expiresIn };
|
|
23
|
+
}
|
|
24
|
+
function supportTokenHandler(options) {
|
|
25
|
+
return async () => {
|
|
26
|
+
const secret = options.secret ?? process.env.SUPPORT_REPORTER_SECRET;
|
|
27
|
+
if (!secret) {
|
|
28
|
+
return json({ error: "Support identity is not configured" }, 503);
|
|
29
|
+
}
|
|
30
|
+
const user = await options.user();
|
|
31
|
+
if (!user?.email)
|
|
32
|
+
return json({ error: "Unauthorized" }, 401);
|
|
33
|
+
try {
|
|
34
|
+
const { token, expiresIn } = createSupportToken({ secret, user, ttlSeconds: options.ttlSeconds });
|
|
35
|
+
return json({ token, expiresIn }, 200);
|
|
36
|
+
} catch {
|
|
37
|
+
return json({ error: "Support identity is not configured" }, 503);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function json(body, status) {
|
|
42
|
+
return new Response(JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: {
|
|
45
|
+
"content-type": "application/json",
|
|
46
|
+
"cache-control": "no-store, private"
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
supportTokenHandler,
|
|
52
|
+
createSupportToken,
|
|
53
|
+
DEFAULT_TTL_SECONDS
|
|
54
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Report-a-problem widget: rolling network/console/action capture, annotated screenshot, and submission to a Lightwork Support host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
"default": "./dist/index.mjs"
|
|
13
13
|
},
|
|
14
14
|
"./embed": "./dist/debug-capture.js",
|
|
15
|
-
"./style.css": "./src/styles.css"
|
|
15
|
+
"./style.css": "./src/styles.css",
|
|
16
|
+
"./server": {
|
|
17
|
+
"types": "./dist/server.d.ts",
|
|
18
|
+
"import": "./dist/server.mjs",
|
|
19
|
+
"default": "./dist/server.mjs"
|
|
20
|
+
}
|
|
16
21
|
},
|
|
17
22
|
"files": [
|
|
18
23
|
"dist",
|
|
@@ -22,7 +27,7 @@
|
|
|
22
27
|
],
|
|
23
28
|
"scripts": {
|
|
24
29
|
"build": "rm -rf dist && bun run build:esm && bun run build:iife && bun run build:types",
|
|
25
|
-
"build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm --minify",
|
|
30
|
+
"build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm --minify && bun build src/server.ts --outfile dist/server.mjs --format esm --target node --external node:crypto",
|
|
26
31
|
"build:iife": "bun build src/embed.ts --outfile dist/debug-capture.js --format iife --minify",
|
|
27
32
|
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
28
33
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half that runs on the HOST'S SERVER.
|
|
3
|
+
*
|
|
4
|
+
* Reading someone's tickets needs proof of who is asking, and only the host can
|
|
5
|
+
* give it: the realm key ships in client source, so it can authorise filing a
|
|
6
|
+
* report and never reading one back. The host signs a short statement of who is
|
|
7
|
+
* in front of it, with a secret only its server holds.
|
|
8
|
+
*
|
|
9
|
+
* That much is unavoidable. What was avoidable is every adopter hand-rolling
|
|
10
|
+
* HMAC and guessing the claim names — the ERP's version of this file was
|
|
11
|
+
* eighty-five lines, and getting `alg`, `jti` or `iat` wrong fails in ways that
|
|
12
|
+
* are hard to read from the outside.
|
|
13
|
+
*
|
|
14
|
+
* Imported from "@lightworkai.official/debug-capture/server", NOT from the
|
|
15
|
+
* package root: this uses node:crypto and must never be reachable from a
|
|
16
|
+
* browser bundle. It is the one part of this library that touches the secret.
|
|
17
|
+
*/
|
|
18
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
/** Whoever the host has authenticated. Email is the account key. */
|
|
21
|
+
export interface SupportUser {
|
|
22
|
+
/** The host's own id for this person, recorded so a changed email still matches. */
|
|
23
|
+
id?: string | null;
|
|
24
|
+
email: string;
|
|
25
|
+
name?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Two minutes.
|
|
30
|
+
*
|
|
31
|
+
* The token is fetched when the button is pressed and spent on arrival, so it
|
|
32
|
+
* only has to survive one redirect — and it travels in a URL, which means
|
|
33
|
+
* browser history and every access log along the way. The support host refuses
|
|
34
|
+
* anything older than this regardless of what `exp` claims, so a longer TTL
|
|
35
|
+
* buys nothing.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_TTL_SECONDS = 120;
|
|
38
|
+
|
|
39
|
+
const b64 = (value: object): string => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sign a reporter token for a user the host has ALREADY authenticated.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately not a general "sign anything" helper: it asserts the person
|
|
45
|
+
* handed to it and nothing else. There is no path here to mint a token for
|
|
46
|
+
* somebody who is not signed in.
|
|
47
|
+
*/
|
|
48
|
+
export function createSupportToken(options: {
|
|
49
|
+
/** The realm's reporter secret. Server-side only — never send it to a browser. */
|
|
50
|
+
secret: string;
|
|
51
|
+
user: SupportUser;
|
|
52
|
+
ttlSeconds?: number;
|
|
53
|
+
}): { token: string; expiresIn: number } {
|
|
54
|
+
const email = options.user.email?.trim().toLowerCase();
|
|
55
|
+
if (!email || !email.includes("@")) throw new Error("createSupportToken: a real email is required");
|
|
56
|
+
if (!options.secret || options.secret.length < 16) {
|
|
57
|
+
throw new Error("createSupportToken: the realm's reporter secret is missing or too short");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const now = Math.floor(Date.now() / 1000);
|
|
61
|
+
const expiresIn = Math.min(Math.max(Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), 30), 12 * 60 * 60);
|
|
62
|
+
const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({
|
|
63
|
+
// The host's own id when it has one, so the same person is recognised even
|
|
64
|
+
// if their email later changes. The email stands in when it does not.
|
|
65
|
+
sub: options.user.id?.trim() || email,
|
|
66
|
+
email,
|
|
67
|
+
name: options.user.name?.trim() || null,
|
|
68
|
+
// Spent once on arrival; without it the freshness window is a replay window.
|
|
69
|
+
jti: randomUUID(),
|
|
70
|
+
iat: now,
|
|
71
|
+
exp: now + expiresIn,
|
|
72
|
+
})}`;
|
|
73
|
+
|
|
74
|
+
return { token: `${body}.${createHmac("sha256", options.secret).update(body).digest("base64url")}`, expiresIn };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A ready-made route handler, for the common case.
|
|
79
|
+
*
|
|
80
|
+
* Returns a standard `Response`, so it works anywhere the Fetch API does —
|
|
81
|
+
* Next's app router, Bun, Deno, Hono. The one thing it cannot do for you is
|
|
82
|
+
* work out who is signed in; that is your session, and only you can read it.
|
|
83
|
+
*
|
|
84
|
+
* // app/api/support-token/route.ts
|
|
85
|
+
* import { supportTokenHandler } from "@lightworkai.official/debug-capture/server";
|
|
86
|
+
* export const dynamic = "force-dynamic";
|
|
87
|
+
* export const GET = supportTokenHandler({
|
|
88
|
+
* user: async () => {
|
|
89
|
+
* const session = await getServerSession(authOptions);
|
|
90
|
+
* return session?.user ? { id: session.user.id, email: session.user.email } : null;
|
|
91
|
+
* },
|
|
92
|
+
* });
|
|
93
|
+
*/
|
|
94
|
+
export function supportTokenHandler(options: {
|
|
95
|
+
/** Defaults to `process.env.SUPPORT_REPORTER_SECRET`. */
|
|
96
|
+
secret?: string;
|
|
97
|
+
/** Null when nobody is signed in — answered 401, which the button reports. */
|
|
98
|
+
user: () => SupportUser | null | Promise<SupportUser | null>;
|
|
99
|
+
ttlSeconds?: number;
|
|
100
|
+
}): () => Promise<Response> {
|
|
101
|
+
return async () => {
|
|
102
|
+
const secret = options.secret ?? process.env.SUPPORT_REPORTER_SECRET;
|
|
103
|
+
if (!secret) {
|
|
104
|
+
// Not something the user did: this deployment was never given a secret.
|
|
105
|
+
return json({ error: "Support identity is not configured" }, 503);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const user = await options.user();
|
|
109
|
+
if (!user?.email) return json({ error: "Unauthorized" }, 401);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const { token, expiresIn } = createSupportToken({ secret, user, ttlSeconds: options.ttlSeconds });
|
|
113
|
+
return json({ token, expiresIn }, 200);
|
|
114
|
+
} catch {
|
|
115
|
+
// A bad secret or a malformed user is a misconfiguration, not a 401 —
|
|
116
|
+
// saying "unauthorized" would send someone to check their login.
|
|
117
|
+
return json({ error: "Support identity is not configured" }, 503);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function json(body: unknown, status: number): Response {
|
|
123
|
+
return new Response(JSON.stringify(body), {
|
|
124
|
+
status,
|
|
125
|
+
headers: {
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
// A bearer credential must not sit in a shared cache, or the next user of
|
|
128
|
+
// this browser (or a CDN) is handed the last one.
|
|
129
|
+
"cache-control": "no-store, private",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
package/src/submit.ts
CHANGED
|
@@ -100,7 +100,7 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
100
100
|
description: note.trim() || undefined,
|
|
101
101
|
/*
|
|
102
102
|
* How this application names itself. It becomes the ticket's PREFIX —
|
|
103
|
-
* `app: { name: "
|
|
103
|
+
* `app: { name: "ACME" }` files ACME-100 — so it is the same name on the
|
|
104
104
|
* reporter's list and on the desk's board, whichever realm either of them
|
|
105
105
|
* happens to be looking from.
|
|
106
106
|
*/
|