@web-remarq/cloud 0.1.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/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @web-remarq/cloud
2
+
3
+ Cloud storage adapter for [web-remarq](../core/). Sync annotations across team members through Supabase.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@web-remarq/cloud)](https://www.npmjs.com/package/@web-remarq/cloud) [![license](https://img.shields.io/npm/l/@web-remarq/cloud)](../../LICENSE)
6
+
7
+ ## What it does
8
+
9
+ Replaces the default `LocalStorageAdapter` with a Supabase-backed one. Annotations made by anyone with the same project key sync to a shared backend, so designers and developers see the same set of markers across browsers and devices. Self-host Supabase — the free tier is enough for a team.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @web-remarq/cloud @supabase/supabase-js
15
+ ```
16
+
17
+ `@supabase/supabase-js` is a peer dependency — bring your own version so it isn't duplicated in your bundle.
18
+
19
+ ## Setup (10 minutes)
20
+
21
+ ### 1. Create a Supabase project
22
+
23
+ Go to [dashboard.supabase.com](https://dashboard.supabase.com), create a new project on the free tier, and save the `Project URL` and `anon public` key from Settings → API.
24
+
25
+ ### 2. Apply the schema
26
+
27
+ Copy the contents of [`sql/001_init.sql`](./sql/001_init.sql) into Supabase Studio → SQL Editor → New query → Run. Creates `projects`, `annotations`, RLS policies, and the `current_project_id()` helper.
28
+
29
+ ### 3. Generate a project key
30
+
31
+ ```sh
32
+ npx @web-remarq/cloud gen-key --name "My App"
33
+ ```
34
+
35
+ Save the printed `pk_...` key — it can't be recovered. Paste the generated `insert into projects ...` snippet into Supabase Studio → SQL Editor → Run.
36
+
37
+ ### 4. Wire it up
38
+
39
+ ```ts
40
+ import { WebRemarq } from 'web-remarq'
41
+ import { createCloudStorage } from '@web-remarq/cloud'
42
+
43
+ WebRemarq.init({
44
+ storage: createCloudStorage({
45
+ supabaseUrl: import.meta.env.VITE_SUPABASE_URL,
46
+ supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
47
+ projectKey: import.meta.env.VITE_REMARQ_PROJECT_KEY,
48
+ })
49
+ })
50
+ ```
51
+
52
+ ## API
53
+
54
+ ### `createCloudStorage(options): StorageAdapter`
55
+
56
+ Returns a `StorageAdapter` you can pass to `WebRemarq.init({ storage })`.
57
+
58
+ Options:
59
+
60
+ - `supabaseUrl` (required) — your Supabase project URL
61
+ - `supabaseAnonKey` (required) — anon/publishable key (safe to ship to the browser — RLS gates everything)
62
+ - `projectKey` (required) — `pk_...` key you generated in step 3
63
+ - `onError` (`'throw' | 'memory-fallback'`, default `'throw'`) — what to do when a Supabase call fails
64
+
65
+ ### `generateProjectKey(): string`
66
+
67
+ Generates a `pk_<32 random chars>` key. Browser-safe (uses Web Crypto).
68
+
69
+ ### `hashProjectKey(key): Promise<string>`
70
+
71
+ SHA-256 hex (64 chars) of the key. This is what the database stores.
72
+
73
+ ## Security
74
+
75
+ - The anon key is **safe** in the browser. Supabase RLS gates every operation by the project key sent in the `x-remarq-project-key` header.
76
+ - The project key acts like a shared password — anyone with it has full read/write access to your annotations. **Treat it as a secret.**
77
+ - The database only ever stores the SHA-256 hash of the project key, never the plaintext.
78
+ - If a key leaks, generate a new one (step 3) and delete the old project row in Supabase Studio.
79
+
80
+ ## Limits (cloud-0.1.0 MVP)
81
+
82
+ - One project key per team — no user accounts yet
83
+ - No realtime sync — refresh the page to pick up other people's changes (coming in cloud-0.2.0 with MCP server + team UX)
84
+ - No web dashboard — manage annotations via Supabase Studio for now
85
+ - Two tabs editing the same annotation: last write wins
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { webcrypto } from 'node:crypto'
4
+
5
+ const PREFIX = 'pk_'
6
+ const KEY_LENGTH = 32
7
+ const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
8
+
9
+ function generateProjectKey() {
10
+ const bytes = new Uint8Array(KEY_LENGTH)
11
+ webcrypto.getRandomValues(bytes)
12
+ let key = PREFIX
13
+ for (let i = 0; i < KEY_LENGTH; i++) {
14
+ key += ALPHABET[bytes[i] % ALPHABET.length]
15
+ }
16
+ return key
17
+ }
18
+
19
+ async function hashProjectKey(key) {
20
+ const data = new TextEncoder().encode(key)
21
+ const buf = await webcrypto.subtle.digest('SHA-256', data)
22
+ return Array.from(new Uint8Array(buf))
23
+ .map((b) => b.toString(16).padStart(2, '0'))
24
+ .join('')
25
+ }
26
+
27
+ function parseFlag(argv, flag) {
28
+ const i = argv.indexOf(flag)
29
+ if (i === -1) return undefined
30
+ return argv[i + 1]
31
+ }
32
+
33
+ function sqlString(value) {
34
+ return `'${value.replace(/'/g, "''")}'`
35
+ }
36
+
37
+ function printUsage(stream) {
38
+ stream.write(
39
+ [
40
+ 'Usage: web-remarq-cloud <command> [options]',
41
+ '',
42
+ 'Commands:',
43
+ ' gen-key --name "<project name>" [--origin "<url>"]',
44
+ ' Generate a project key, print its sha256 hash and an',
45
+ ' insert-snippet to register the project in Supabase.',
46
+ '',
47
+ ].join('\n')
48
+ )
49
+ }
50
+
51
+ async function runGenKey(argv) {
52
+ const name = parseFlag(argv, '--name')
53
+ const origin = parseFlag(argv, '--origin')
54
+
55
+ if (!name) {
56
+ process.stderr.write('Error: --name is required.\n\n')
57
+ printUsage(process.stderr)
58
+ process.exit(1)
59
+ }
60
+
61
+ const key = generateProjectKey()
62
+ const hash = await hashProjectKey(key)
63
+ const originSql = origin ? sqlString(origin) : 'null'
64
+
65
+ process.stdout.write(
66
+ [
67
+ `Project key: ${key}`,
68
+ `Hash (sha256): ${hash}`,
69
+ '',
70
+ 'Store the project key securely — it will not be shown again.',
71
+ '',
72
+ 'Run this in Supabase SQL Editor:',
73
+ '',
74
+ ` insert into projects (name, origin, secret_key_hash)`,
75
+ ` values (${sqlString(name)}, ${originSql}, '${hash}');`,
76
+ '',
77
+ ].join('\n')
78
+ )
79
+ }
80
+
81
+ async function main() {
82
+ const [, , command, ...rest] = process.argv
83
+
84
+ if (command === 'gen-key') {
85
+ await runGenKey(rest)
86
+ return
87
+ }
88
+
89
+ printUsage(process.stdout)
90
+ }
91
+
92
+ main()
package/dist/index.cjs ADDED
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ CloudStorageAdapter: () => CloudStorageAdapter,
24
+ createCloudStorage: () => createCloudStorage,
25
+ generateProjectKey: () => generateProjectKey,
26
+ hashProjectKey: () => hashProjectKey
27
+ });
28
+ module.exports = __toCommonJS(src_exports);
29
+
30
+ // src/cloud-storage-adapter.ts
31
+ var import_supabase_js = require("@supabase/supabase-js");
32
+ function rowToAnnotation(row) {
33
+ var _a;
34
+ return {
35
+ id: row.id,
36
+ comment: row.comment,
37
+ fingerprint: row.fingerprint,
38
+ route: row.route,
39
+ viewport: row.viewport,
40
+ viewportBucket: row.viewport_bucket,
41
+ timestamp: row.timestamp_ms,
42
+ status: row.status,
43
+ lifecycle: (_a = row.lifecycle) != null ? _a : []
44
+ };
45
+ }
46
+ function annotationToRow(a) {
47
+ return {
48
+ id: a.id,
49
+ route: a.route,
50
+ viewport: a.viewport,
51
+ viewport_bucket: a.viewportBucket,
52
+ fingerprint: a.fingerprint,
53
+ comment: a.comment,
54
+ status: a.status,
55
+ timestamp_ms: a.timestamp,
56
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
57
+ };
58
+ }
59
+ var CloudStorageAdapter = class {
60
+ constructor(opts) {
61
+ this.isMemoryOnly = false;
62
+ var _a;
63
+ this.onError = (_a = opts.onError) != null ? _a : "throw";
64
+ this.client = (0, import_supabase_js.createClient)(opts.supabaseUrl, opts.supabaseAnonKey, {
65
+ global: {
66
+ headers: { "x-remarq-project-key": opts.projectKey }
67
+ },
68
+ auth: { persistSession: false }
69
+ });
70
+ }
71
+ async load() {
72
+ const { data, error } = await this.client.from("annotations").select("*").order("timestamp_ms", { ascending: true });
73
+ if (error) {
74
+ return this.handleError(error, { version: 1, annotations: [] });
75
+ }
76
+ const rows = data != null ? data : [];
77
+ return { version: 1, annotations: rows.map(rowToAnnotation) };
78
+ }
79
+ async save(annotation) {
80
+ const row = annotationToRow(annotation);
81
+ const { error } = await this.client.from("annotations").upsert(row, { onConflict: "id" });
82
+ if (error) this.handleError(error, void 0);
83
+ }
84
+ async remove(id) {
85
+ const { error } = await this.client.from("annotations").delete().eq("id", id);
86
+ if (error) this.handleError(error, void 0);
87
+ }
88
+ async clear() {
89
+ const { error } = await this.client.from("annotations").delete().neq("id", "__never_matches__");
90
+ if (error) this.handleError(error, void 0);
91
+ }
92
+ handleError(error, fallback) {
93
+ if (this.onError === "throw") {
94
+ throw error;
95
+ }
96
+ console.warn("[web-remarq cloud]", error);
97
+ return fallback;
98
+ }
99
+ };
100
+
101
+ // src/project-key.ts
102
+ var PREFIX = "pk_";
103
+ var KEY_LENGTH = 32;
104
+ var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
105
+ function generateProjectKey() {
106
+ const bytes = new Uint8Array(KEY_LENGTH);
107
+ crypto.getRandomValues(bytes);
108
+ let key = PREFIX;
109
+ for (let i = 0; i < KEY_LENGTH; i++) {
110
+ key += ALPHABET[bytes[i] % ALPHABET.length];
111
+ }
112
+ return key;
113
+ }
114
+ async function hashProjectKey(key) {
115
+ const data = new TextEncoder().encode(key);
116
+ const buf = await crypto.subtle.digest("SHA-256", data);
117
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
118
+ }
119
+
120
+ // src/index.ts
121
+ function createCloudStorage(opts) {
122
+ return new CloudStorageAdapter(opts);
123
+ }
124
+ // Annotate the CommonJS export names for ESM import in node:
125
+ 0 && (module.exports = {
126
+ CloudStorageAdapter,
127
+ createCloudStorage,
128
+ generateProjectKey,
129
+ hashProjectKey
130
+ });
131
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/cloud-storage-adapter.ts","../src/project-key.ts"],"sourcesContent":["import type { StorageAdapter } from 'web-remarq'\nimport { CloudStorageAdapter } from './cloud-storage-adapter'\nimport type { CloudStorageOptions } from './types'\n\nexport { generateProjectKey, hashProjectKey } from './project-key'\nexport { CloudStorageAdapter } from './cloud-storage-adapter'\nexport type { CloudStorageOptions } from './types'\n\nexport function createCloudStorage(opts: CloudStorageOptions): StorageAdapter {\n return new CloudStorageAdapter(opts)\n}\n","import { createClient, type SupabaseClient } from '@supabase/supabase-js'\nimport type {\n Annotation,\n AnnotationEvent,\n AnnotationStatus,\n AnnotationStore,\n ElementFingerprint,\n StorageAdapter,\n} from 'web-remarq'\nimport type { CloudStorageOptions } from './types'\n\ninterface AnnotationRow {\n id: string\n project_id?: string\n route: string\n viewport: string\n viewport_bucket: number\n fingerprint: ElementFingerprint\n comment: string\n status: AnnotationStatus\n timestamp_ms: number\n lifecycle?: AnnotationEvent[]\n created_at?: string\n updated_at?: string\n}\n\ntype AnnotationWriteRow = Omit<AnnotationRow, 'project_id' | 'created_at'>\n\nfunction rowToAnnotation(row: AnnotationRow): Annotation {\n return {\n id: row.id,\n comment: row.comment,\n fingerprint: row.fingerprint,\n route: row.route,\n viewport: row.viewport,\n viewportBucket: row.viewport_bucket,\n timestamp: row.timestamp_ms,\n status: row.status,\n lifecycle: row.lifecycle ?? [],\n }\n}\n\nfunction annotationToRow(a: Annotation): AnnotationWriteRow {\n // lifecycle is intentionally NOT written here — cloud-0.1.0 schema doesn't\n // have a lifecycle column. Will be added in cloud-0.1.1 alongside SQL migration.\n // Until then, lifecycle history doesn't survive across browser sessions for\n // cloud users; migrateAnnotation synthesizes a created event on load.\n return {\n id: a.id,\n route: a.route,\n viewport: a.viewport,\n viewport_bucket: a.viewportBucket,\n fingerprint: a.fingerprint,\n comment: a.comment,\n status: a.status,\n timestamp_ms: a.timestamp,\n updated_at: new Date().toISOString(),\n }\n}\n\nexport class CloudStorageAdapter implements StorageAdapter {\n readonly isMemoryOnly = false\n private client: SupabaseClient\n private onError: 'throw' | 'memory-fallback'\n\n constructor(opts: CloudStorageOptions) {\n this.onError = opts.onError ?? 'throw'\n this.client = createClient(opts.supabaseUrl, opts.supabaseAnonKey, {\n global: {\n headers: { 'x-remarq-project-key': opts.projectKey },\n },\n auth: { persistSession: false },\n })\n }\n\n async load(): Promise<AnnotationStore> {\n const { data, error } = await this.client\n .from('annotations')\n .select('*')\n .order('timestamp_ms', { ascending: true })\n\n if (error) {\n return this.handleError<AnnotationStore>(error, { version: 1, annotations: [] })\n }\n\n const rows = (data ?? []) as AnnotationRow[]\n return { version: 1, annotations: rows.map(rowToAnnotation) }\n }\n\n async save(annotation: Annotation): Promise<void> {\n const row = annotationToRow(annotation)\n const { error } = await this.client\n .from('annotations')\n .upsert(row, { onConflict: 'id' })\n if (error) this.handleError<void>(error, undefined)\n }\n\n async remove(id: string): Promise<void> {\n const { error } = await this.client.from('annotations').delete().eq('id', id)\n if (error) this.handleError<void>(error, undefined)\n }\n\n async clear(): Promise<void> {\n const { error } = await this.client\n .from('annotations')\n .delete()\n .neq('id', '__never_matches__')\n if (error) this.handleError<void>(error, undefined)\n }\n\n private handleError<T>(error: unknown, fallback: T): T {\n if (this.onError === 'throw') {\n throw error\n }\n console.warn('[web-remarq cloud]', error)\n return fallback\n }\n}\n","const PREFIX = 'pk_'\nconst KEY_LENGTH = 32\nconst ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\n\nexport function generateProjectKey(): string {\n const bytes = new Uint8Array(KEY_LENGTH)\n crypto.getRandomValues(bytes)\n let key = PREFIX\n for (let i = 0; i < KEY_LENGTH; i++) {\n key += ALPHABET[bytes[i] % ALPHABET.length]\n }\n return key\n}\n\nexport async function hashProjectKey(key: string): Promise<string> {\n const data = new TextEncoder().encode(key)\n const buf = await crypto.subtle.digest('SHA-256', data)\n return Array.from(new Uint8Array(buf))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAAkD;AA4BlD,SAAS,gBAAgB,KAAgC;AA5BzD;AA6BE,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,SAAS,IAAI;AAAA,IACb,aAAa,IAAI;AAAA,IACjB,OAAO,IAAI;AAAA,IACX,UAAU,IAAI;AAAA,IACd,gBAAgB,IAAI;AAAA,IACpB,WAAW,IAAI;AAAA,IACf,QAAQ,IAAI;AAAA,IACZ,YAAW,SAAI,cAAJ,YAAiB,CAAC;AAAA,EAC/B;AACF;AAEA,SAAS,gBAAgB,GAAmC;AAK1D,SAAO;AAAA,IACL,IAAI,EAAE;AAAA,IACN,OAAO,EAAE;AAAA,IACT,UAAU,EAAE;AAAA,IACZ,iBAAiB,EAAE;AAAA,IACnB,aAAa,EAAE;AAAA,IACf,SAAS,EAAE;AAAA,IACX,QAAQ,EAAE;AAAA,IACV,cAAc,EAAE;AAAA,IAChB,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,EACrC;AACF;AAEO,IAAM,sBAAN,MAAoD;AAAA,EAKzD,YAAY,MAA2B;AAJvC,SAAS,eAAe;AA7D1B;AAkEI,SAAK,WAAU,UAAK,YAAL,YAAgB;AAC/B,SAAK,aAAS,iCAAa,KAAK,aAAa,KAAK,iBAAiB;AAAA,MACjE,QAAQ;AAAA,QACN,SAAS,EAAE,wBAAwB,KAAK,WAAW;AAAA,MACrD;AAAA,MACA,MAAM,EAAE,gBAAgB,MAAM;AAAA,IAChC,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,OAAiC;AACrC,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,KAAK,OAChC,KAAK,aAAa,EAClB,OAAO,GAAG,EACV,MAAM,gBAAgB,EAAE,WAAW,KAAK,CAAC;AAE5C,QAAI,OAAO;AACT,aAAO,KAAK,YAA6B,OAAO,EAAE,SAAS,GAAG,aAAa,CAAC,EAAE,CAAC;AAAA,IACjF;AAEA,UAAM,OAAQ,sBAAQ,CAAC;AACvB,WAAO,EAAE,SAAS,GAAG,aAAa,KAAK,IAAI,eAAe,EAAE;AAAA,EAC9D;AAAA,EAEA,MAAM,KAAK,YAAuC;AAChD,UAAM,MAAM,gBAAgB,UAAU;AACtC,UAAM,EAAE,MAAM,IAAI,MAAM,KAAK,OAC1B,KAAK,aAAa,EAClB,OAAO,KAAK,EAAE,YAAY,KAAK,CAAC;AACnC,QAAI,MAAO,MAAK,YAAkB,OAAO,MAAS;AAAA,EACpD;AAAA,EAEA,MAAM,OAAO,IAA2B;AACtC,UAAM,EAAE,MAAM,IAAI,MAAM,KAAK,OAAO,KAAK,aAAa,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE;AAC5E,QAAI,MAAO,MAAK,YAAkB,OAAO,MAAS;AAAA,EACpD;AAAA,EAEA,MAAM,QAAuB;AAC3B,UAAM,EAAE,MAAM,IAAI,MAAM,KAAK,OAC1B,KAAK,aAAa,EAClB,OAAO,EACP,IAAI,MAAM,mBAAmB;AAChC,QAAI,MAAO,MAAK,YAAkB,OAAO,MAAS;AAAA,EACpD;AAAA,EAEQ,YAAe,OAAgB,UAAgB;AACrD,QAAI,KAAK,YAAY,SAAS;AAC5B,YAAM;AAAA,IACR;AACA,YAAQ,KAAK,sBAAsB,KAAK;AACxC,WAAO;AAAA,EACT;AACF;;;ACrHA,IAAM,SAAS;AACf,IAAM,aAAa;AACnB,IAAM,WAAW;AAEV,SAAS,qBAA6B;AAC3C,QAAM,QAAQ,IAAI,WAAW,UAAU;AACvC,SAAO,gBAAgB,KAAK;AAC5B,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,YAAY,KAAK;AACnC,WAAO,SAAS,MAAM,CAAC,IAAI,SAAS,MAAM;AAAA,EAC5C;AACA,SAAO;AACT;AAEA,eAAsB,eAAe,KAA8B;AACjE,QAAM,OAAO,IAAI,YAAY,EAAE,OAAO,GAAG;AACzC,QAAM,MAAM,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI;AACtD,SAAO,MAAM,KAAK,IAAI,WAAW,GAAG,CAAC,EAClC,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAC1C,KAAK,EAAE;AACZ;;;AFZO,SAAS,mBAAmB,MAA2C;AAC5E,SAAO,IAAI,oBAAoB,IAAI;AACrC;","names":[]}
@@ -0,0 +1,27 @@
1
+ import { StorageAdapter, AnnotationStore, Annotation } from 'web-remarq';
2
+
3
+ interface CloudStorageOptions {
4
+ supabaseUrl: string;
5
+ supabaseAnonKey: string;
6
+ projectKey: string;
7
+ onError?: 'throw' | 'memory-fallback';
8
+ }
9
+
10
+ declare function generateProjectKey(): string;
11
+ declare function hashProjectKey(key: string): Promise<string>;
12
+
13
+ declare class CloudStorageAdapter implements StorageAdapter {
14
+ readonly isMemoryOnly = false;
15
+ private client;
16
+ private onError;
17
+ constructor(opts: CloudStorageOptions);
18
+ load(): Promise<AnnotationStore>;
19
+ save(annotation: Annotation): Promise<void>;
20
+ remove(id: string): Promise<void>;
21
+ clear(): Promise<void>;
22
+ private handleError;
23
+ }
24
+
25
+ declare function createCloudStorage(opts: CloudStorageOptions): StorageAdapter;
26
+
27
+ export { CloudStorageAdapter, type CloudStorageOptions, createCloudStorage, generateProjectKey, hashProjectKey };
@@ -0,0 +1,27 @@
1
+ import { StorageAdapter, AnnotationStore, Annotation } from 'web-remarq';
2
+
3
+ interface CloudStorageOptions {
4
+ supabaseUrl: string;
5
+ supabaseAnonKey: string;
6
+ projectKey: string;
7
+ onError?: 'throw' | 'memory-fallback';
8
+ }
9
+
10
+ declare function generateProjectKey(): string;
11
+ declare function hashProjectKey(key: string): Promise<string>;
12
+
13
+ declare class CloudStorageAdapter implements StorageAdapter {
14
+ readonly isMemoryOnly = false;
15
+ private client;
16
+ private onError;
17
+ constructor(opts: CloudStorageOptions);
18
+ load(): Promise<AnnotationStore>;
19
+ save(annotation: Annotation): Promise<void>;
20
+ remove(id: string): Promise<void>;
21
+ clear(): Promise<void>;
22
+ private handleError;
23
+ }
24
+
25
+ declare function createCloudStorage(opts: CloudStorageOptions): StorageAdapter;
26
+
27
+ export { CloudStorageAdapter, type CloudStorageOptions, createCloudStorage, generateProjectKey, hashProjectKey };
package/dist/index.js ADDED
@@ -0,0 +1,101 @@
1
+ // src/cloud-storage-adapter.ts
2
+ import { createClient } from "@supabase/supabase-js";
3
+ function rowToAnnotation(row) {
4
+ var _a;
5
+ return {
6
+ id: row.id,
7
+ comment: row.comment,
8
+ fingerprint: row.fingerprint,
9
+ route: row.route,
10
+ viewport: row.viewport,
11
+ viewportBucket: row.viewport_bucket,
12
+ timestamp: row.timestamp_ms,
13
+ status: row.status,
14
+ lifecycle: (_a = row.lifecycle) != null ? _a : []
15
+ };
16
+ }
17
+ function annotationToRow(a) {
18
+ return {
19
+ id: a.id,
20
+ route: a.route,
21
+ viewport: a.viewport,
22
+ viewport_bucket: a.viewportBucket,
23
+ fingerprint: a.fingerprint,
24
+ comment: a.comment,
25
+ status: a.status,
26
+ timestamp_ms: a.timestamp,
27
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
28
+ };
29
+ }
30
+ var CloudStorageAdapter = class {
31
+ constructor(opts) {
32
+ this.isMemoryOnly = false;
33
+ var _a;
34
+ this.onError = (_a = opts.onError) != null ? _a : "throw";
35
+ this.client = createClient(opts.supabaseUrl, opts.supabaseAnonKey, {
36
+ global: {
37
+ headers: { "x-remarq-project-key": opts.projectKey }
38
+ },
39
+ auth: { persistSession: false }
40
+ });
41
+ }
42
+ async load() {
43
+ const { data, error } = await this.client.from("annotations").select("*").order("timestamp_ms", { ascending: true });
44
+ if (error) {
45
+ return this.handleError(error, { version: 1, annotations: [] });
46
+ }
47
+ const rows = data != null ? data : [];
48
+ return { version: 1, annotations: rows.map(rowToAnnotation) };
49
+ }
50
+ async save(annotation) {
51
+ const row = annotationToRow(annotation);
52
+ const { error } = await this.client.from("annotations").upsert(row, { onConflict: "id" });
53
+ if (error) this.handleError(error, void 0);
54
+ }
55
+ async remove(id) {
56
+ const { error } = await this.client.from("annotations").delete().eq("id", id);
57
+ if (error) this.handleError(error, void 0);
58
+ }
59
+ async clear() {
60
+ const { error } = await this.client.from("annotations").delete().neq("id", "__never_matches__");
61
+ if (error) this.handleError(error, void 0);
62
+ }
63
+ handleError(error, fallback) {
64
+ if (this.onError === "throw") {
65
+ throw error;
66
+ }
67
+ console.warn("[web-remarq cloud]", error);
68
+ return fallback;
69
+ }
70
+ };
71
+
72
+ // src/project-key.ts
73
+ var PREFIX = "pk_";
74
+ var KEY_LENGTH = 32;
75
+ var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
76
+ function generateProjectKey() {
77
+ const bytes = new Uint8Array(KEY_LENGTH);
78
+ crypto.getRandomValues(bytes);
79
+ let key = PREFIX;
80
+ for (let i = 0; i < KEY_LENGTH; i++) {
81
+ key += ALPHABET[bytes[i] % ALPHABET.length];
82
+ }
83
+ return key;
84
+ }
85
+ async function hashProjectKey(key) {
86
+ const data = new TextEncoder().encode(key);
87
+ const buf = await crypto.subtle.digest("SHA-256", data);
88
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
89
+ }
90
+
91
+ // src/index.ts
92
+ function createCloudStorage(opts) {
93
+ return new CloudStorageAdapter(opts);
94
+ }
95
+ export {
96
+ CloudStorageAdapter,
97
+ createCloudStorage,
98
+ generateProjectKey,
99
+ hashProjectKey
100
+ };
101
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cloud-storage-adapter.ts","../src/project-key.ts","../src/index.ts"],"sourcesContent":["import { createClient, type SupabaseClient } from '@supabase/supabase-js'\nimport type {\n Annotation,\n AnnotationEvent,\n AnnotationStatus,\n AnnotationStore,\n ElementFingerprint,\n StorageAdapter,\n} from 'web-remarq'\nimport type { CloudStorageOptions } from './types'\n\ninterface AnnotationRow {\n id: string\n project_id?: string\n route: string\n viewport: string\n viewport_bucket: number\n fingerprint: ElementFingerprint\n comment: string\n status: AnnotationStatus\n timestamp_ms: number\n lifecycle?: AnnotationEvent[]\n created_at?: string\n updated_at?: string\n}\n\ntype AnnotationWriteRow = Omit<AnnotationRow, 'project_id' | 'created_at'>\n\nfunction rowToAnnotation(row: AnnotationRow): Annotation {\n return {\n id: row.id,\n comment: row.comment,\n fingerprint: row.fingerprint,\n route: row.route,\n viewport: row.viewport,\n viewportBucket: row.viewport_bucket,\n timestamp: row.timestamp_ms,\n status: row.status,\n lifecycle: row.lifecycle ?? [],\n }\n}\n\nfunction annotationToRow(a: Annotation): AnnotationWriteRow {\n // lifecycle is intentionally NOT written here — cloud-0.1.0 schema doesn't\n // have a lifecycle column. Will be added in cloud-0.1.1 alongside SQL migration.\n // Until then, lifecycle history doesn't survive across browser sessions for\n // cloud users; migrateAnnotation synthesizes a created event on load.\n return {\n id: a.id,\n route: a.route,\n viewport: a.viewport,\n viewport_bucket: a.viewportBucket,\n fingerprint: a.fingerprint,\n comment: a.comment,\n status: a.status,\n timestamp_ms: a.timestamp,\n updated_at: new Date().toISOString(),\n }\n}\n\nexport class CloudStorageAdapter implements StorageAdapter {\n readonly isMemoryOnly = false\n private client: SupabaseClient\n private onError: 'throw' | 'memory-fallback'\n\n constructor(opts: CloudStorageOptions) {\n this.onError = opts.onError ?? 'throw'\n this.client = createClient(opts.supabaseUrl, opts.supabaseAnonKey, {\n global: {\n headers: { 'x-remarq-project-key': opts.projectKey },\n },\n auth: { persistSession: false },\n })\n }\n\n async load(): Promise<AnnotationStore> {\n const { data, error } = await this.client\n .from('annotations')\n .select('*')\n .order('timestamp_ms', { ascending: true })\n\n if (error) {\n return this.handleError<AnnotationStore>(error, { version: 1, annotations: [] })\n }\n\n const rows = (data ?? []) as AnnotationRow[]\n return { version: 1, annotations: rows.map(rowToAnnotation) }\n }\n\n async save(annotation: Annotation): Promise<void> {\n const row = annotationToRow(annotation)\n const { error } = await this.client\n .from('annotations')\n .upsert(row, { onConflict: 'id' })\n if (error) this.handleError<void>(error, undefined)\n }\n\n async remove(id: string): Promise<void> {\n const { error } = await this.client.from('annotations').delete().eq('id', id)\n if (error) this.handleError<void>(error, undefined)\n }\n\n async clear(): Promise<void> {\n const { error } = await this.client\n .from('annotations')\n .delete()\n .neq('id', '__never_matches__')\n if (error) this.handleError<void>(error, undefined)\n }\n\n private handleError<T>(error: unknown, fallback: T): T {\n if (this.onError === 'throw') {\n throw error\n }\n console.warn('[web-remarq cloud]', error)\n return fallback\n }\n}\n","const PREFIX = 'pk_'\nconst KEY_LENGTH = 32\nconst ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\n\nexport function generateProjectKey(): string {\n const bytes = new Uint8Array(KEY_LENGTH)\n crypto.getRandomValues(bytes)\n let key = PREFIX\n for (let i = 0; i < KEY_LENGTH; i++) {\n key += ALPHABET[bytes[i] % ALPHABET.length]\n }\n return key\n}\n\nexport async function hashProjectKey(key: string): Promise<string> {\n const data = new TextEncoder().encode(key)\n const buf = await crypto.subtle.digest('SHA-256', data)\n return Array.from(new Uint8Array(buf))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n","import type { StorageAdapter } from 'web-remarq'\nimport { CloudStorageAdapter } from './cloud-storage-adapter'\nimport type { CloudStorageOptions } from './types'\n\nexport { generateProjectKey, hashProjectKey } from './project-key'\nexport { CloudStorageAdapter } from './cloud-storage-adapter'\nexport type { CloudStorageOptions } from './types'\n\nexport function createCloudStorage(opts: CloudStorageOptions): StorageAdapter {\n return new CloudStorageAdapter(opts)\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@web-remarq/cloud",
3
+ "version": "0.1.1",
4
+ "description": "Cloud storage adapter for web-remarq — Supabase-backed sync",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "sql",
20
+ "bin"
21
+ ],
22
+ "bin": {
23
+ "web-remarq-cloud": "./bin/gen-key.mjs"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/DPostnik/web-remarq.git"
32
+ },
33
+ "homepage": "https://github.com/DPostnik/web-remarq",
34
+ "bugs": {
35
+ "url": "https://github.com/DPostnik/web-remarq/issues"
36
+ },
37
+ "license": "MIT",
38
+ "peerDependencies": {
39
+ "web-remarq": ">=0.7.0",
40
+ "@supabase/supabase-js": ">=2.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@supabase/supabase-js": "^2.0.0",
44
+ "tsup": "^8.5.1",
45
+ "typescript": "^5.9.3"
46
+ }
47
+ }
@@ -0,0 +1,82 @@
1
+ -- 001_init.sql
2
+
3
+ -- digest() из pgcrypto. В Supabase живёт в схеме `extensions`.
4
+ create extension if not exists pgcrypto;
5
+
6
+ -- Projects: один project = один независимый scope аннотаций.
7
+ -- secret_key_hash — sha256 от 'pk_<32 random chars>'. Никогда не храним plaintext.
8
+ create table projects (
9
+ id uuid primary key default gen_random_uuid(),
10
+ name text not null,
11
+ origin text, -- свободное поле (https://staging.example.com), не идентификатор
12
+ secret_key_hash text not null unique,
13
+ created_at timestamptz not null default now()
14
+ );
15
+
16
+ -- Helper: достаёт project_id по ключу из заголовка.
17
+ -- SECURITY DEFINER + explicit search_path: иначе SELECT projects внутри функции
18
+ -- рекурсивно идёт через RLS policy, которая снова зовёт эту функцию → stack overflow.
19
+ -- `extensions` нужен чтобы найти digest() из pgcrypto.
20
+ create or replace function current_project_id() returns uuid
21
+ language plpgsql stable
22
+ security definer
23
+ set search_path = public, extensions, pg_catalog
24
+ as $$
25
+ declare
26
+ key_header text;
27
+ key_hash text;
28
+ project uuid;
29
+ begin
30
+ key_header := current_setting('request.headers', true)::json->>'x-remarq-project-key';
31
+ if key_header is null then return null; end if;
32
+ key_hash := encode(digest(key_header, 'sha256'), 'hex');
33
+ select id into project from projects where secret_key_hash = key_hash limit 1;
34
+ return project;
35
+ end;
36
+ $$;
37
+
38
+ -- Annotations: формат зеркалит web-remarq Annotation тип.
39
+ -- project_id заполняется default-ом из заголовка — клиент не должен его знать.
40
+ -- RLS WITH CHECK дополнительно валидирует что default совпал с current_project_id().
41
+ -- fingerprint целиком в JSONB — не нормализуем (нам этого не надо в MVP).
42
+ create table annotations (
43
+ id text primary key, -- web-remarq id (не uuid! shortid из клиента)
44
+ project_id uuid not null default current_project_id() references projects(id) on delete cascade,
45
+ route text not null,
46
+ viewport text not null,
47
+ viewport_bucket integer not null,
48
+ fingerprint jsonb not null,
49
+ comment text not null,
50
+ status text not null default 'pending',
51
+ timestamp_ms bigint not null, -- web-remarq timestamp (ms)
52
+ created_at timestamptz not null default now(),
53
+ updated_at timestamptz not null default now()
54
+ );
55
+
56
+ create index annotations_project_route_idx on annotations(project_id, route);
57
+ create index annotations_project_status_idx on annotations(project_id, status);
58
+
59
+ -- RLS: client передаёт project key plaintext в заголовке `x-remarq-project-key`,
60
+ -- мы проверяем sha256(key) == secret_key_hash через current_setting().
61
+ -- Это безопасно потому что:
62
+ -- (a) ключ передаётся только по HTTPS,
63
+ -- (b) на сервере остаётся только хеш,
64
+ -- (c) anon key Supabase ограничен RLS-политиками.
65
+
66
+ alter table projects enable row level security;
67
+ alter table annotations enable row level security;
68
+
69
+ -- Projects: видны только самим себе (через ключ); insert/update — через service role (out of scope MVP).
70
+ create policy projects_select on projects
71
+ for select using (id = current_project_id());
72
+
73
+ -- Annotations: полный CRUD когда совпадает project_id.
74
+ create policy annotations_select on annotations
75
+ for select using (project_id = current_project_id());
76
+ create policy annotations_insert on annotations
77
+ for insert with check (project_id = current_project_id());
78
+ create policy annotations_update on annotations
79
+ for update using (project_id = current_project_id())
80
+ with check (project_id = current_project_id());
81
+ create policy annotations_delete on annotations
82
+ for delete using (project_id = current_project_id());
package/sql/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Supabase setup for `@web-remarq/cloud`
2
+
3
+ Run this once per Supabase project. Takes ~5 minutes.
4
+
5
+ ## 1. Apply the schema
6
+
7
+ 1. Open your Supabase project → **SQL Editor** → **New query**.
8
+ 2. Copy the entire contents of [`001_init.sql`](./001_init.sql) into the editor.
9
+ 3. Click **Run**. You should see `Success. No rows returned.`
10
+
11
+ This creates two tables (`projects`, `annotations`), enables row-level security,
12
+ and installs the `current_project_id()` helper that gates all access by the
13
+ `x-remarq-project-key` header.
14
+
15
+ ## 2. Verify the schema
16
+
17
+ In a new query, run:
18
+
19
+ ```sql
20
+ select * from projects;
21
+ select * from annotations;
22
+ ```
23
+
24
+ Both should return zero rows (not an error). If either errors with
25
+ `relation does not exist`, step 1 did not complete — re-run it.
26
+
27
+ ## 3. Generate a project key
28
+
29
+ From your local machine:
30
+
31
+ ```sh
32
+ npx @web-remarq/cloud gen-key --name "My App"
33
+ ```
34
+
35
+ Optionally pass `--origin "https://staging.example.com"` to tag the project
36
+ with a human-readable origin (free-form, not used for auth).
37
+
38
+ The script prints three things:
39
+
40
+ - The project key (`pk_...`) — your clients use this at runtime.
41
+ - Its sha256 hash — what Supabase stores.
42
+ - A ready-to-paste `insert` snippet.
43
+
44
+ ## 4. Register the project in Supabase
45
+
46
+ 1. Copy the printed `insert into projects ...` snippet.
47
+ 2. Paste it into the SQL Editor → **Run**.
48
+ 3. Confirm with `select id, name, origin, created_at from projects;` — your
49
+ project row should appear.
50
+
51
+ ## 5. Store the project key
52
+
53
+ Save the printed `pk_...` value in a password manager (1Password, Bitwarden,
54
+ etc.). It is **not recoverable** — only the hash is stored server-side. If you
55
+ lose it, generate a new one and replace the row in `projects`.
56
+
57
+ You are now ready to call `createCloudStorage({ projectKey: 'pk_...', ... })`
58
+ from your app. See the package README for the runtime API.