@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 +89 -0
- package/bin/gen-key.mjs +92 -0
- package/dist/index.cjs +131 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/sql/001_init.sql +82 -0
- package/sql/README.md +58 -0
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
|
+
[](https://www.npmjs.com/package/@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
|
package/bin/gen-key.mjs
ADDED
|
@@ -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":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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":";AAAA,SAAS,oBAAyC;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,SAAS,aAAa,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;;;ACZO,SAAS,mBAAmB,MAA2C;AAC5E,SAAO,IAAI,oBAAoB,IAAI;AACrC;","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
|
+
}
|
package/sql/001_init.sql
ADDED
|
@@ -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.
|