visual-feedback 0.0.0-stage → 0.1.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.
@@ -0,0 +1,124 @@
1
+ # Recipe: an S3-compatible bucket (S3, R2, Railway Buckets)
2
+
3
+ Each report becomes two objects in a private bucket, named and shaped as in the local recipe
4
+ (docs/recipes/local.md): `feedback/<id>/report.json`, whose `image` is the image's name, and
5
+ `feedback/<id>/image.png` or `image.webp`.
6
+
7
+ The bucket stays private: nothing is public, and people read reports only through your own route,
8
+ behind your own admin check. `report.context` comes from the browser (docs/API.md), so an admin view
9
+ must not show it as proof of who sent a report. The images show the screen as the user saw it, with nothing masked
10
+ (SPEC §7).
11
+
12
+ ## 1. Create the bucket, install, configure
13
+
14
+ Create the bucket with public access off (on S3, keep Block Public Access on), then check that an
15
+ object URL opened without a key is refused.
16
+
17
+ ```sh
18
+ npm install aws4fetch
19
+ ```
20
+
21
+ Set these on the server. Never send them to the browser.
22
+
23
+ | variable | for example |
24
+ |---|---|
25
+ | `S3_ENDPOINT` | `https://s3.eu-west-2.amazonaws.com`, `https://<account>.r2.cloudflarestorage.com`, or your Railway Bucket's endpoint |
26
+ | `S3_REGION` | `eu-west-2`; optional, `auto` when unset, which R2 takes and AWS does not |
27
+ | `S3_BUCKET` | `feedback` |
28
+ | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | a key that may read and write this bucket only |
29
+
30
+ ## 2. The route
31
+
32
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
33
+ App Router route. As written, anyone who can reach `POST` can write to the bucket: before production,
34
+ put it behind your own session check and a rate limit. A report already in the bucket is never
35
+ replaced: the route checks for it first, and every write is create-only (`If-None-Match: *`, which
36
+ S3 and R2 support). On a store that ignores that header, only the first check protects it.
37
+
38
+ ```ts
39
+ // app/api/feedback/route.ts
40
+ import { AwsClient } from "aws4fetch";
41
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
42
+
43
+ // Replace with your own admin check, from your session. Until then, nobody can read.
44
+ async function canRead(_request: Request): Promise<boolean> {
45
+ return false;
46
+ }
47
+
48
+ function bucket() {
49
+ const { S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY } = process.env;
50
+ if (!S3_ENDPOINT || !S3_BUCKET || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) {
51
+ throw new Error("Set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.");
52
+ }
53
+ const client = new AwsClient({
54
+ accessKeyId: S3_ACCESS_KEY_ID,
55
+ secretAccessKey: S3_SECRET_ACCESS_KEY,
56
+ region: S3_REGION || "auto",
57
+ service: "s3",
58
+ // aws4fetch retries 5xx about 50 s by default; the user would wait on "Sending…" that long.
59
+ retries: 2,
60
+ });
61
+ const base = `${S3_ENDPOINT.replace(/\/+$/, "")}/${S3_BUCKET}/feedback`;
62
+ return { client, url: (id: string, name: string) => `${base}/${id}/${name}` };
63
+ }
64
+
65
+ export async function POST(request: Request): Promise<Response> {
66
+ try {
67
+ const { report, image } = await parseFeedbackRequest(request);
68
+ const { client, url } = bucket();
69
+ // A report already stored under this id stays: a retry, or a reused id, changes nothing.
70
+ const stored = await client.fetch(url(report.id, "report.json"), { method: "HEAD" });
71
+ if (stored.ok) return new Response(null, { status: 204 });
72
+ // S3 answers 403 for a missing key when the key may not list; any other answer is unclear.
73
+ if (stored.status !== 403 && stored.status !== 404) {
74
+ throw new Error(`The bucket answered ${stored.status} for report.json.`);
75
+ }
76
+ const name = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
77
+ // The image first: a report.json in the bucket then always has its image beside it.
78
+ const puts: [string, BodyInit, string][] = [
79
+ ...(image && name ? [[name, image, image.type] as [string, BodyInit, string]] : []),
80
+ ["report.json", JSON.stringify({ ...report, image: name }), "application/json"],
81
+ ];
82
+ for (const [file, body, type] of puts) {
83
+ const put = await client.fetch(url(report.id, file), {
84
+ method: "PUT",
85
+ body,
86
+ // Create-only: S3 and R2 answer 412 and keep the object a request stored first.
87
+ headers: { "content-type": type, "if-none-match": "*" },
88
+ });
89
+ if (!put.ok && put.status !== 412) {
90
+ throw new Error(`The bucket answered ${put.status} for ${file}.`);
91
+ }
92
+ }
93
+ return new Response(null, { status: 204 });
94
+ } catch (error) {
95
+ if (error instanceof FeedbackRequestError) {
96
+ return new Response(error.message, { status: error.status });
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ // GET /api/feedback?id=<report id>&file=report.json | image.png | image.webp
103
+ export async function GET(request: Request): Promise<Response> {
104
+ if (!(await canRead(request))) return new Response(null, { status: 403 });
105
+ const query = new URL(request.url).searchParams;
106
+ const id = query.get("id") ?? "";
107
+ const file = query.get("file") ?? "";
108
+ // Only a report id and a known file name, so the key cannot leave feedback/<id>/.
109
+ if (!/^[0-9a-f-]{36}$/.test(id) || !["report.json", "image.png", "image.webp"].includes(file)) {
110
+ return new Response(null, { status: 400 });
111
+ }
112
+ const { client, url } = bucket();
113
+ const object = await client.fetch(url(id, file));
114
+ // S3 answers 403, not 404, for a missing key when the key may not list the bucket.
115
+ const missing = object.status === 403 || object.status === 404;
116
+ if (!object.ok) return new Response(null, { status: missing ? 404 : 502 });
117
+ return new Response(object.body, {
118
+ headers: {
119
+ "content-type": object.headers.get("content-type") ?? "application/octet-stream",
120
+ "cache-control": "private, no-store",
121
+ },
122
+ });
123
+ }
124
+ ```
@@ -0,0 +1,124 @@
1
+ # Recipe: Supabase
2
+
3
+ Each report becomes one row in a table and, when it has one, one image in the private Storage
4
+ bucket `feedback`, named `<id>/image.png` or `<id>/image.webp`. The row has the Postgres recipe's columns
5
+ (docs/recipes/postgres.md), except that `user_id` is the Supabase user's uuid and the image is in
6
+ Storage instead of `bytea`. Row-level security lets
7
+ only your app's admins read either, and nobody but your server can write.
8
+
9
+ `user_id` is the proof of who sent a report; `report.context` is not (docs/API.md). The images show
10
+ the screen as the user saw it, with nothing masked (SPEC §7).
11
+
12
+ ## 1. Install and create the table, the bucket and the policies
13
+
14
+ ```sh
15
+ npm install @supabase/supabase-js
16
+ ```
17
+
18
+ Run this in the SQL editor. An admin is a user whose `app_metadata.role` is `admin`. Only your
19
+ server can set it, with the service role:
20
+ `supabase.auth.admin.updateUserById(id, { app_metadata: { role: "admin" } })`. A change, removal
21
+ included, reaches the user's token only at their next sign-in or token refresh (within an hour by
22
+ default). Never use `user_metadata` for this: users can edit their own.
23
+
24
+ ```sql
25
+ create table feedback_reports (
26
+ id uuid primary key,
27
+ user_id uuid not null references auth.users (id) on delete cascade,
28
+ created_at timestamptz not null,
29
+ report jsonb not null,
30
+ image_type text
31
+ );
32
+ alter table feedback_reports enable row level security;
33
+ grant select on feedback_reports to authenticated;
34
+ grant all on feedback_reports to service_role;
35
+ create policy "Admins read feedback" on feedback_reports for select to authenticated
36
+ using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
37
+
38
+ insert into storage.buckets (id, name, public) values ('feedback', 'feedback', false);
39
+ create policy "Admins read feedback images" on storage.objects for select to authenticated
40
+ using (bucket_id = 'feedback' and (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
41
+ ```
42
+
43
+ No policy allows an insert, an update or a delete, so only the service role, on your server, writes.
44
+ Deleting a user deletes their rows, which list their report ids, so remove their images under
45
+ `<id>/` first.
46
+
47
+ Check it: with the anon key, `select` on `feedback_reports` returns an empty list or a permission
48
+ error, never a row, and a download from `feedback` is refused.
49
+
50
+ An admin screen reads with the admin's own session, never the service role key:
51
+ `from("feedback_reports").select()` for the rows, filtered and paged as the screen needs, and
52
+ `storage.from("feedback").createSignedUrl("<id>/image.png", 60)` for an image, `image.webp` when
53
+ `image_type` is `image/webp`. A signed URL works for anyone who holds it for those 60 seconds: show
54
+ it, never share it.
55
+
56
+ Set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` on the server. The service role key bypasses
57
+ row-level security: never send it to the browser.
58
+
59
+ ## 2. The route
60
+
61
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
62
+ App Router route. As written, it refuses every send until you fill in `currentUser`; then any
63
+ signed-in user can send, so put a rate limit in front of it before production.
64
+
65
+ ```ts
66
+ // app/api/feedback/route.ts
67
+ import { createClient } from "@supabase/supabase-js";
68
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
69
+
70
+ // Replace with your own session: the signed-in user's Supabase id (a uuid), for example from
71
+ // supabase.auth.getUser() in @supabase/ssr. Until then, nobody can send. Never use report.context.
72
+ async function currentUser(_request: Request): Promise<string | null> {
73
+ return null;
74
+ }
75
+
76
+ function supabase() {
77
+ const { SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY } = process.env;
78
+ if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
79
+ throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.");
80
+ }
81
+ return createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
82
+ auth: { persistSession: false, autoRefreshToken: false },
83
+ });
84
+ }
85
+
86
+ export async function POST(request: Request): Promise<Response> {
87
+ const user = await currentUser(request);
88
+ if (!user) return new Response(null, { status: 401 });
89
+ try {
90
+ const { report, image } = await parseFeedbackRequest(request);
91
+ const db = supabase();
92
+ // The image first, so a row never lacks its image.
93
+ if (image) {
94
+ const name = `${report.id}/image.${image.type === "image/png" ? "png" : "webp"}`;
95
+ const upload = await db.storage
96
+ .from("feedback")
97
+ .upload(name, image, { contentType: image.type, upsert: false });
98
+ // A resend finds its image there (409); the id comes from the browser, so never replace it.
99
+ const error = upload.error;
100
+ const stored = error && "statusCode" in error && error.statusCode === "409";
101
+ if (error && !stored) throw error;
102
+ }
103
+ const { image: _, ...stored } = report;
104
+ // A "Try again" sends the same id, so the row it already wrote stays as it is.
105
+ const insert = await db.from("feedback_reports").upsert(
106
+ {
107
+ id: report.id,
108
+ user_id: user,
109
+ created_at: report.createdAt,
110
+ report: stored,
111
+ image_type: image?.type ?? null,
112
+ },
113
+ { onConflict: "id", ignoreDuplicates: true },
114
+ );
115
+ if (insert.error) throw insert.error;
116
+ return new Response(null, { status: 204 });
117
+ } catch (error) {
118
+ if (error instanceof FeedbackRequestError) {
119
+ return new Response(error.message, { status: error.status });
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ ```
@@ -0,0 +1,123 @@
1
+ # Recipe: Vercel Blob
2
+
3
+ Each report becomes private blobs, named and shaped as in the S3 recipe (docs/recipes/s3.md):
4
+ `feedback/<id>/report.json` and `feedback/<id>/image.png` or `image.webp`. People read them only
5
+ through your own route, behind your own admin check. `report.context` comes from the browser
6
+ (docs/API.md), so an admin view must not show it as proof of who sent a report. The images show the
7
+ screen as the user saw it, with nothing masked (SPEC §7).
8
+
9
+ It uses `fetch` and Vercel Blob's REST API, so it needs no package. The URLs, headers and token
10
+ shape are the ones `@vercel/blob` 2.8.1 sends; if Vercel changes its API, compare with that client.
11
+
12
+ ## 1. Create the store
13
+
14
+ Create a Blob store with private access in your Vercel project. That sets `BLOB_READ_WRITE_TOKEN`
15
+ on the server. Never send it to the browser. Check it: open
16
+ `https://<store id>.private.blob.vercel-storage.com/feedback/<id>/report.json` without the token,
17
+ where the store id is the part after `vercel_blob_rw_` in the token, and Vercel refuses it.
18
+
19
+ The route takes images up to 3 MB, so the whole request stays under Vercel's 4.5 MB body limit. A
20
+ larger one is refused with 413 and the widget shows its send error; a smaller `capture.maxWidth`
21
+ (docs/API.md) keeps screenshots under it.
22
+
23
+ ## 2. The route
24
+
25
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
26
+ App Router route. As written, anyone who can reach `POST` can write: before production, put it
27
+ behind your own session check and a rate limit.
28
+
29
+ ```ts
30
+ // app/api/feedback/route.ts
31
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
32
+
33
+ // Replace with your own admin check, from your session. Until then, nobody can read.
34
+ async function canRead(_request: Request): Promise<boolean> {
35
+ return false;
36
+ }
37
+
38
+ function blob() {
39
+ const token = process.env.BLOB_READ_WRITE_TOKEN;
40
+ // A read-write token is vercel_blob_rw_<store id>_<secret>.
41
+ const store = token?.split("_")[3];
42
+ if (!token?.startsWith("vercel_blob_rw_") || !store) {
43
+ throw new Error("Set BLOB_READ_WRITE_TOKEN.");
44
+ }
45
+ const auth = { authorization: `Bearer ${token}` };
46
+ const path = (id: string, name: string) => `feedback/${id}/${name}`;
47
+ return {
48
+ // Next.js 14 caches a fetch in a route by default; a cached 404 would hide a stored report.
49
+ read: (id: string, name: string) =>
50
+ fetch(`https://${store}.private.blob.vercel-storage.com/${path(id, name)}`, {
51
+ headers: auth,
52
+ cache: "no-store",
53
+ }),
54
+ async put(id: string, name: string, body: BodyInit, type: string) {
55
+ const query = new URLSearchParams({ pathname: path(id, name) });
56
+ const put = await fetch(`https://vercel.com/api/blob/?${query}`, {
57
+ method: "PUT",
58
+ body,
59
+ headers: {
60
+ ...auth,
61
+ "x-api-version": "12",
62
+ "x-vercel-blob-access": "private",
63
+ "x-content-type": type,
64
+ "x-add-random-suffix": "0",
65
+ "x-allow-overwrite": "1",
66
+ },
67
+ });
68
+ if (!put.ok) throw new Error(`Vercel Blob answered ${put.status} for ${name}.`);
69
+ },
70
+ };
71
+ }
72
+
73
+ export async function POST(request: Request): Promise<Response> {
74
+ try {
75
+ // Vercel refuses a function request body over 4.5 MB; 3 MB plus the report stays under it.
76
+ const { report, image } = await parseFeedbackRequest(request, { maxImageBytes: 3_000_000 });
77
+ const store = blob();
78
+ // The id comes from the browser: once a report is stored, a later request does not replace it.
79
+ // Two requests with one id at the same moment still can; the check and the write are separate.
80
+ const stored = await store.read(report.id, "report.json");
81
+ await stored.body?.cancel();
82
+ if (stored.ok) return new Response(null, { status: 204 });
83
+ // Only a 404 means "not stored"; on any other answer, writing could replace a report.
84
+ if (stored.status !== 404) {
85
+ throw new Error(`Vercel Blob answered ${stored.status} for report.json.`);
86
+ }
87
+ const name = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
88
+ // The image first: a report.json then always has its image beside it.
89
+ if (image && name) await store.put(report.id, name, image, image.type);
90
+ const json = JSON.stringify({ ...report, image: name });
91
+ await store.put(report.id, "report.json", json, "application/json");
92
+ return new Response(null, { status: 204 });
93
+ } catch (error) {
94
+ if (error instanceof FeedbackRequestError) {
95
+ return new Response(error.message, { status: error.status });
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ // GET /api/feedback?id=<report id>&file=report.json | image.png | image.webp
102
+ export async function GET(request: Request): Promise<Response> {
103
+ if (!(await canRead(request))) return new Response(null, { status: 403 });
104
+ const query = new URL(request.url).searchParams;
105
+ const id = query.get("id") ?? "";
106
+ const file = query.get("file") ?? "";
107
+ // Only a report id and a known file name, so the read cannot leave feedback/<id>/.
108
+ if (!/^[0-9a-f-]{36}$/.test(id) || !["report.json", "image.png", "image.webp"].includes(file)) {
109
+ return new Response(null, { status: 400 });
110
+ }
111
+ const object = await blob().read(id, file);
112
+ if (!object.ok) {
113
+ await object.body?.cancel();
114
+ return new Response(null, { status: object.status === 404 ? 404 : 502 });
115
+ }
116
+ return new Response(object.body, {
117
+ headers: {
118
+ "content-type": object.headers.get("content-type") ?? "application/octet-stream",
119
+ "cache-control": "private, no-store",
120
+ },
121
+ });
122
+ }
123
+ ```
package/package.json CHANGED
@@ -1,6 +1,100 @@
1
1
  {
2
2
  "name": "visual-feedback",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A small button lets your users screenshot the page, mark it up and write a note. You receive one report and store it where you want.",
5
+ "keywords": [
6
+ "feedback",
7
+ "bug-report",
8
+ "screenshot",
9
+ "annotate",
10
+ "widget",
11
+ "react",
12
+ "nextjs"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Doru Moraru",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/dorumrr/visual-feedback.git"
19
+ },
20
+ "homepage": "https://github.com/dorumrr/visual-feedback#readme",
21
+ "bugs": "https://github.com/dorumrr/visual-feedback/issues",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.mts",
27
+ "import": "./dist/index.mjs"
28
+ },
29
+ "./server": {
30
+ "types": "./dist/server.d.mts",
31
+ "import": "./dist/server.mjs"
32
+ },
33
+ "./react": {
34
+ "types": "./dist/react.d.mts",
35
+ "import": "./dist/react.mjs"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "types": "./dist/index.d.mts",
40
+ "files": [
41
+ "dist",
42
+ "AGENTS.md",
43
+ "CHANGELOG.md",
44
+ "docs/API.md",
45
+ "docs/SPEC.md",
46
+ "docs/THEMING.md",
47
+ "docs/recipes"
48
+ ],
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown",
54
+ "lint": "biome check .",
55
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tests",
56
+ "test": "vitest run",
57
+ "test:edge": "deno test --no-config --sloppy-imports tests/server/parse.deno.ts",
58
+ "test:e2e": "playwright test",
59
+ "test:react": "playwright test -c tests/react/playwright.config.ts",
60
+ "test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
61
+ "size": "size-limit",
62
+ "publint": "publint --strict",
63
+ "check": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run test:e2e && npm run test:react && npm run test:pack && npm run size && npm run publint",
64
+ "prepack": "npm run --silent build -- --logLevel warn",
65
+ "dev": "node tests/dev.mjs",
66
+ "demo": "node tests/demo.mjs"
67
+ },
68
+ "devDependencies": {
69
+ "@axe-core/playwright": "^4.13.0",
70
+ "@biomejs/biome": "^2.5.15",
71
+ "@electric-sql/pglite": "0.5.8",
72
+ "@electric-sql/pglite-socket": "^0.2.11",
73
+ "@playwright/test": "^1.64.0",
74
+ "@size-limit/preset-small-lib": "^14.2.0",
75
+ "@supabase/supabase-js": "^2.117.3",
76
+ "@types/node": "^22.20.5",
77
+ "@types/react": "^19.3.0",
78
+ "aws4fetch": "^1.0.20",
79
+ "next": "^16.4.0",
80
+ "postgres": "^3.4.9",
81
+ "publint": "^0.3.25",
82
+ "react": "^19.3.0",
83
+ "react-dom": "^19.3.0",
84
+ "size-limit": "^14.2.0",
85
+ "tsdown": "^0.23.0",
86
+ "typescript": "^7.0.2",
87
+ "vitest": "^5.0.3"
88
+ },
89
+ "peerDependencies": {
90
+ "react": ">=18"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "react": {
94
+ "optional": true
95
+ }
96
+ },
97
+ "dependencies": {
98
+ "@zumer/snapdom": "3.3.0"
99
+ }
100
+ }