@mordn/chat-widget 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -23
- package/SECURITY.md +101 -0
- package/dist/chat-store-DERCPwhl.d.mts +278 -0
- package/dist/chat-store-DERCPwhl.d.ts +278 -0
- package/dist/cli/init.js +111 -346
- package/dist/index.d.mts +105 -13
- package/dist/index.d.ts +105 -13
- package/dist/index.js +642 -351
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +597 -303
- package/dist/index.mjs.map +1 -1
- package/dist/server/drizzle/index.d.mts +340 -0
- package/dist/server/drizzle/index.d.ts +340 -0
- package/dist/server/drizzle/index.js +238 -0
- package/dist/server/drizzle/index.js.map +1 -0
- package/dist/server/drizzle/index.mjs +207 -0
- package/dist/server/drizzle/index.mjs.map +1 -0
- package/dist/server/index.d.mts +217 -0
- package/dist/server/index.d.ts +217 -0
- package/dist/server/index.js +370 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +349 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/server/supabase/index.d.mts +50 -0
- package/dist/server/supabase/index.d.ts +50 -0
- package/dist/server/supabase/index.js +111 -0
- package/dist/server/supabase/index.js.map +1 -0
- package/dist/server/supabase/index.mjs +86 -0
- package/dist/server/supabase/index.mjs.map +1 -0
- package/dist/storage-adapter-DD8uqiAP.d.mts +126 -0
- package/dist/storage-adapter-DD8uqiAP.d.ts +126 -0
- package/dist/styles.css +1 -1
- package/package.json +21 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/server/stores/supabase/index.ts","../../../src/server/stores/supabase/storage.ts"],"sourcesContent":["/**\n * Default Supabase Storage adapter — public entry.\n *\n * Imported via `@mordn/chat-widget/server/supabase` so a BYO-storage consumer\n * never pulls `@supabase/supabase-js` into their bundle.\n *\n * import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';\n * createChatHandler({ storage: createSupabaseStorage(), ... });\n */\nimport 'server-only';\n\nexport { createSupabaseStorage, type SupabaseStorageOptions } from './storage';\n","/**\n * Default StorageAdapter implementation, on Supabase Storage.\n *\n * This ships the *correct* attachment security model as the default — the one\n * the original scaffold got wrong. It is one implementation of the\n * `StorageAdapter` interface; a BYO adapter (S3/R2/GCS) is equally valid.\n *\n * The three interface rules, as implemented here:\n *\n * 1. PRIVATE AT REST — the bucket must be created as a *private* bucket in\n * Supabase. We never call `getPublicUrl`. (If the bucket is public, that\n * is a host-app misconfiguration; this adapter never relies on it.)\n *\n * 2. SIGNED, SHORT-LIVED READS — `upload` and `resign` return\n * `createSignedUrl` results with a bounded TTL.\n *\n * 3. USER-NAMESPACED, UNGUESSABLE PATHS — every path is\n * `<userId>/<conversationId>/<randomUUID>/<safeFilename>`. The adapter is\n * bound to one verified user and derives the path itself, so an upload\n * cannot land in another user's namespace. `resign`/`remove` refuse paths\n * outside the bound user's prefix.\n */\n\nimport 'server-only';\nimport { createClient, type SupabaseClient } from '@supabase/supabase-js';\n\nimport type {\n StorageAdapter,\n UploadInput,\n UploadResult,\n} from '../../storage-adapter';\n\nconst DEFAULT_BUCKET = 'chat-attachments';\n// Short by design: the upload URL is used within seconds (model fetch +\n// thumbnail), and history reloads re-sign on demand. One hour is a generous\n// ceiling that still bounds the exposure of a leaked URL.\nconst DEFAULT_SIGNED_TTL_SECONDS = 60 * 60;\n\nexport interface SupabaseStorageOptions {\n /** Supabase project URL. Defaults to `process.env.NEXT_PUBLIC_SUPABASE_URL`. */\n supabaseUrl?: string;\n /**\n * Service-role key. Required — signing + private writes need it. Defaults to\n * `process.env.SUPABASE_SERVICE_ROLE_KEY`. NEVER expose this to the client;\n * this adapter only ever runs server-side (guarded by `server-only`).\n */\n serviceRoleKey?: string;\n /** Storage bucket name. Defaults to `chat-attachments`. Must be PRIVATE. */\n bucket?: string;\n /** Signed-URL TTL in seconds. Defaults to 3600 (1 hour). */\n signedUrlTtlSeconds?: number;\n}\n\n/** Strip anything that could break a storage path; clamp length. */\nfunction safeFilename(name: string): string {\n const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);\n return cleaned.length ? cleaned : 'file';\n}\n\nclass SupabaseStorageAdapter implements StorageAdapter {\n private readonly bucket: string;\n private readonly ttl: number;\n /** Path prefix this adapter is allowed to touch: `<userId>/`. */\n private readonly userPrefix: string;\n\n constructor(\n public readonly userId: string,\n private readonly client: SupabaseClient,\n bucket: string,\n ttl: number,\n ) {\n this.bucket = bucket;\n this.ttl = ttl;\n this.userPrefix = `${userId}/`;\n }\n\n /** Guard: a path is only operable if it lives under the bound user's prefix. */\n private ownsPath(storagePath: string): boolean {\n return storagePath.startsWith(this.userPrefix) && !storagePath.includes('..');\n }\n\n async upload(input: UploadInput): Promise<UploadResult> {\n const token = crypto.randomUUID();\n const filename = safeFilename(input.filename);\n const conversationSegment = input.conversationId\n ? safeFilename(input.conversationId)\n : 'unfiled';\n // Path is fully derived from the bound userId — callers can't inject it.\n const path = `${this.userId}/${conversationSegment}/${token}/${filename}`;\n\n const body =\n input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data);\n\n const { error: uploadError } = await this.client.storage\n .from(this.bucket)\n .upload(path, body, {\n contentType: input.mediaType,\n cacheControl: '3600',\n upsert: false,\n });\n if (uploadError) {\n throw new Error(`[chat-widget] storage upload failed: ${uploadError.message}`);\n }\n\n const url = await this.signOrThrow(path);\n return {\n storagePath: path,\n url,\n filename: input.filename,\n mediaType: input.mediaType,\n size: input.size,\n };\n }\n\n async resign(storagePath: string): Promise<string | null> {\n // Refuse to sign anything outside the bound user's namespace.\n if (!this.ownsPath(storagePath)) return null;\n const { data, error } = await this.client.storage\n .from(this.bucket)\n .createSignedUrl(storagePath, this.ttl);\n if (error || !data?.signedUrl) return null;\n return data.signedUrl;\n }\n\n async remove(storagePath: string): Promise<void> {\n if (!this.ownsPath(storagePath)) return; // never delete outside the user's prefix\n // Supabase remove is idempotent — removing a missing object is not an error.\n await this.client.storage.from(this.bucket).remove([storagePath]);\n }\n\n private async signOrThrow(path: string): Promise<string> {\n const { data, error } = await this.client.storage\n .from(this.bucket)\n .createSignedUrl(path, this.ttl);\n if (error || !data?.signedUrl) {\n throw new Error(\n `[chat-widget] failed to sign URL${error ? `: ${error.message}` : ''}`,\n );\n }\n return data.signedUrl;\n }\n}\n\n/**\n * Create a `StorageAdapterFactory` backed by Supabase Storage.\n *\n * Pass to `createChatHandler({ storage: createSupabaseStorage() })`. Requires\n * a PRIVATE `chat-attachments` bucket (or whatever `bucket` you name) and the\n * service-role key. Each adapter instance is bound to the verified `userId`\n * the handler provides per request; the Supabase client is shared.\n */\nexport function createSupabaseStorage(options: SupabaseStorageOptions = {}) {\n const supabaseUrl = options.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL;\n const serviceRoleKey = options.serviceRoleKey ?? process.env.SUPABASE_SERVICE_ROLE_KEY;\n if (!supabaseUrl || !serviceRoleKey) {\n throw new Error(\n '[chat-widget] createSupabaseStorage needs NEXT_PUBLIC_SUPABASE_URL and ' +\n 'SUPABASE_SERVICE_ROLE_KEY (or explicit options).',\n );\n }\n const bucket = options.bucket ?? DEFAULT_BUCKET;\n const ttl = options.signedUrlTtlSeconds ?? DEFAULT_SIGNED_TTL_SECONDS;\n // One shared client; auth disabled (we use the service-role key directly).\n const client = createClient(supabaseUrl, serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n\n return (userId: string): StorageAdapter =>\n new SupabaseStorageAdapter(userId, client, bucket, ttl);\n}\n"],"mappings":"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","names":["import_server_only"]}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/server/stores/supabase/index.ts
|
|
2
|
+
import "server-only";
|
|
3
|
+
|
|
4
|
+
// src/server/stores/supabase/storage.ts
|
|
5
|
+
import "server-only";
|
|
6
|
+
import { createClient } from "@supabase/supabase-js";
|
|
7
|
+
var DEFAULT_BUCKET = "chat-attachments";
|
|
8
|
+
var DEFAULT_SIGNED_TTL_SECONDS = 60 * 60;
|
|
9
|
+
function safeFilename(name) {
|
|
10
|
+
const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
|
11
|
+
return cleaned.length ? cleaned : "file";
|
|
12
|
+
}
|
|
13
|
+
var SupabaseStorageAdapter = class {
|
|
14
|
+
constructor(userId, client, bucket, ttl) {
|
|
15
|
+
this.userId = userId;
|
|
16
|
+
this.client = client;
|
|
17
|
+
this.bucket = bucket;
|
|
18
|
+
this.ttl = ttl;
|
|
19
|
+
this.userPrefix = `${userId}/`;
|
|
20
|
+
}
|
|
21
|
+
/** Guard: a path is only operable if it lives under the bound user's prefix. */
|
|
22
|
+
ownsPath(storagePath) {
|
|
23
|
+
return storagePath.startsWith(this.userPrefix) && !storagePath.includes("..");
|
|
24
|
+
}
|
|
25
|
+
async upload(input) {
|
|
26
|
+
const token = crypto.randomUUID();
|
|
27
|
+
const filename = safeFilename(input.filename);
|
|
28
|
+
const conversationSegment = input.conversationId ? safeFilename(input.conversationId) : "unfiled";
|
|
29
|
+
const path = `${this.userId}/${conversationSegment}/${token}/${filename}`;
|
|
30
|
+
const body = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data);
|
|
31
|
+
const { error: uploadError } = await this.client.storage.from(this.bucket).upload(path, body, {
|
|
32
|
+
contentType: input.mediaType,
|
|
33
|
+
cacheControl: "3600",
|
|
34
|
+
upsert: false
|
|
35
|
+
});
|
|
36
|
+
if (uploadError) {
|
|
37
|
+
throw new Error(`[chat-widget] storage upload failed: ${uploadError.message}`);
|
|
38
|
+
}
|
|
39
|
+
const url = await this.signOrThrow(path);
|
|
40
|
+
return {
|
|
41
|
+
storagePath: path,
|
|
42
|
+
url,
|
|
43
|
+
filename: input.filename,
|
|
44
|
+
mediaType: input.mediaType,
|
|
45
|
+
size: input.size
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async resign(storagePath) {
|
|
49
|
+
if (!this.ownsPath(storagePath)) return null;
|
|
50
|
+
const { data, error } = await this.client.storage.from(this.bucket).createSignedUrl(storagePath, this.ttl);
|
|
51
|
+
if (error || !data?.signedUrl) return null;
|
|
52
|
+
return data.signedUrl;
|
|
53
|
+
}
|
|
54
|
+
async remove(storagePath) {
|
|
55
|
+
if (!this.ownsPath(storagePath)) return;
|
|
56
|
+
await this.client.storage.from(this.bucket).remove([storagePath]);
|
|
57
|
+
}
|
|
58
|
+
async signOrThrow(path) {
|
|
59
|
+
const { data, error } = await this.client.storage.from(this.bucket).createSignedUrl(path, this.ttl);
|
|
60
|
+
if (error || !data?.signedUrl) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[chat-widget] failed to sign URL${error ? `: ${error.message}` : ""}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return data.signedUrl;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
function createSupabaseStorage(options = {}) {
|
|
69
|
+
const supabaseUrl = options.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
70
|
+
const serviceRoleKey = options.serviceRoleKey ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
71
|
+
if (!supabaseUrl || !serviceRoleKey) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"[chat-widget] createSupabaseStorage needs NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or explicit options)."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const bucket = options.bucket ?? DEFAULT_BUCKET;
|
|
77
|
+
const ttl = options.signedUrlTtlSeconds ?? DEFAULT_SIGNED_TTL_SECONDS;
|
|
78
|
+
const client = createClient(supabaseUrl, serviceRoleKey, {
|
|
79
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
80
|
+
});
|
|
81
|
+
return (userId) => new SupabaseStorageAdapter(userId, client, bucket, ttl);
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
createSupabaseStorage
|
|
85
|
+
};
|
|
86
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/server/stores/supabase/index.ts","../../../src/server/stores/supabase/storage.ts"],"sourcesContent":["/**\n * Default Supabase Storage adapter — public entry.\n *\n * Imported via `@mordn/chat-widget/server/supabase` so a BYO-storage consumer\n * never pulls `@supabase/supabase-js` into their bundle.\n *\n * import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';\n * createChatHandler({ storage: createSupabaseStorage(), ... });\n */\nimport 'server-only';\n\nexport { createSupabaseStorage, type SupabaseStorageOptions } from './storage';\n","/**\n * Default StorageAdapter implementation, on Supabase Storage.\n *\n * This ships the *correct* attachment security model as the default — the one\n * the original scaffold got wrong. It is one implementation of the\n * `StorageAdapter` interface; a BYO adapter (S3/R2/GCS) is equally valid.\n *\n * The three interface rules, as implemented here:\n *\n * 1. PRIVATE AT REST — the bucket must be created as a *private* bucket in\n * Supabase. We never call `getPublicUrl`. (If the bucket is public, that\n * is a host-app misconfiguration; this adapter never relies on it.)\n *\n * 2. SIGNED, SHORT-LIVED READS — `upload` and `resign` return\n * `createSignedUrl` results with a bounded TTL.\n *\n * 3. USER-NAMESPACED, UNGUESSABLE PATHS — every path is\n * `<userId>/<conversationId>/<randomUUID>/<safeFilename>`. The adapter is\n * bound to one verified user and derives the path itself, so an upload\n * cannot land in another user's namespace. `resign`/`remove` refuse paths\n * outside the bound user's prefix.\n */\n\nimport 'server-only';\nimport { createClient, type SupabaseClient } from '@supabase/supabase-js';\n\nimport type {\n StorageAdapter,\n UploadInput,\n UploadResult,\n} from '../../storage-adapter';\n\nconst DEFAULT_BUCKET = 'chat-attachments';\n// Short by design: the upload URL is used within seconds (model fetch +\n// thumbnail), and history reloads re-sign on demand. One hour is a generous\n// ceiling that still bounds the exposure of a leaked URL.\nconst DEFAULT_SIGNED_TTL_SECONDS = 60 * 60;\n\nexport interface SupabaseStorageOptions {\n /** Supabase project URL. Defaults to `process.env.NEXT_PUBLIC_SUPABASE_URL`. */\n supabaseUrl?: string;\n /**\n * Service-role key. Required — signing + private writes need it. Defaults to\n * `process.env.SUPABASE_SERVICE_ROLE_KEY`. NEVER expose this to the client;\n * this adapter only ever runs server-side (guarded by `server-only`).\n */\n serviceRoleKey?: string;\n /** Storage bucket name. Defaults to `chat-attachments`. Must be PRIVATE. */\n bucket?: string;\n /** Signed-URL TTL in seconds. Defaults to 3600 (1 hour). */\n signedUrlTtlSeconds?: number;\n}\n\n/** Strip anything that could break a storage path; clamp length. */\nfunction safeFilename(name: string): string {\n const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);\n return cleaned.length ? cleaned : 'file';\n}\n\nclass SupabaseStorageAdapter implements StorageAdapter {\n private readonly bucket: string;\n private readonly ttl: number;\n /** Path prefix this adapter is allowed to touch: `<userId>/`. */\n private readonly userPrefix: string;\n\n constructor(\n public readonly userId: string,\n private readonly client: SupabaseClient,\n bucket: string,\n ttl: number,\n ) {\n this.bucket = bucket;\n this.ttl = ttl;\n this.userPrefix = `${userId}/`;\n }\n\n /** Guard: a path is only operable if it lives under the bound user's prefix. */\n private ownsPath(storagePath: string): boolean {\n return storagePath.startsWith(this.userPrefix) && !storagePath.includes('..');\n }\n\n async upload(input: UploadInput): Promise<UploadResult> {\n const token = crypto.randomUUID();\n const filename = safeFilename(input.filename);\n const conversationSegment = input.conversationId\n ? safeFilename(input.conversationId)\n : 'unfiled';\n // Path is fully derived from the bound userId — callers can't inject it.\n const path = `${this.userId}/${conversationSegment}/${token}/${filename}`;\n\n const body =\n input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data);\n\n const { error: uploadError } = await this.client.storage\n .from(this.bucket)\n .upload(path, body, {\n contentType: input.mediaType,\n cacheControl: '3600',\n upsert: false,\n });\n if (uploadError) {\n throw new Error(`[chat-widget] storage upload failed: ${uploadError.message}`);\n }\n\n const url = await this.signOrThrow(path);\n return {\n storagePath: path,\n url,\n filename: input.filename,\n mediaType: input.mediaType,\n size: input.size,\n };\n }\n\n async resign(storagePath: string): Promise<string | null> {\n // Refuse to sign anything outside the bound user's namespace.\n if (!this.ownsPath(storagePath)) return null;\n const { data, error } = await this.client.storage\n .from(this.bucket)\n .createSignedUrl(storagePath, this.ttl);\n if (error || !data?.signedUrl) return null;\n return data.signedUrl;\n }\n\n async remove(storagePath: string): Promise<void> {\n if (!this.ownsPath(storagePath)) return; // never delete outside the user's prefix\n // Supabase remove is idempotent — removing a missing object is not an error.\n await this.client.storage.from(this.bucket).remove([storagePath]);\n }\n\n private async signOrThrow(path: string): Promise<string> {\n const { data, error } = await this.client.storage\n .from(this.bucket)\n .createSignedUrl(path, this.ttl);\n if (error || !data?.signedUrl) {\n throw new Error(\n `[chat-widget] failed to sign URL${error ? `: ${error.message}` : ''}`,\n );\n }\n return data.signedUrl;\n }\n}\n\n/**\n * Create a `StorageAdapterFactory` backed by Supabase Storage.\n *\n * Pass to `createChatHandler({ storage: createSupabaseStorage() })`. Requires\n * a PRIVATE `chat-attachments` bucket (or whatever `bucket` you name) and the\n * service-role key. Each adapter instance is bound to the verified `userId`\n * the handler provides per request; the Supabase client is shared.\n */\nexport function createSupabaseStorage(options: SupabaseStorageOptions = {}) {\n const supabaseUrl = options.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL;\n const serviceRoleKey = options.serviceRoleKey ?? process.env.SUPABASE_SERVICE_ROLE_KEY;\n if (!supabaseUrl || !serviceRoleKey) {\n throw new Error(\n '[chat-widget] createSupabaseStorage needs NEXT_PUBLIC_SUPABASE_URL and ' +\n 'SUPABASE_SERVICE_ROLE_KEY (or explicit options).',\n );\n }\n const bucket = options.bucket ?? DEFAULT_BUCKET;\n const ttl = options.signedUrlTtlSeconds ?? DEFAULT_SIGNED_TTL_SECONDS;\n // One shared client; auth disabled (we use the service-role key directly).\n const client = createClient(supabaseUrl, serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n\n return (userId: string): StorageAdapter =>\n new SupabaseStorageAdapter(userId, client, bucket, ttl);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StorageAdapter — the attachment-storage contract for the widget.
|
|
3
|
+
*
|
|
4
|
+
* Attachments (images, PDFs the user drops into chat) are the second
|
|
5
|
+
* pluggable backend, distinct from `ChatStore`. The connection/bucket is the
|
|
6
|
+
* host app's (their Supabase bucket, their S3/R2, or — on the hosted tier —
|
|
7
|
+
* ours), but the *security model* around it is owned by the package and
|
|
8
|
+
* encoded here, because getting it wrong is a data leak and the original
|
|
9
|
+
* scaffold got it wrong (it used a public bucket with permanent URLs).
|
|
10
|
+
*
|
|
11
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
12
|
+
* Three rules every implementation MUST follow. They are the security model.
|
|
13
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* 1. PRIVATE AT REST. Stored objects must NOT be publicly readable. No
|
|
16
|
+
* public bucket, no permanent public URL. The only way to read an object
|
|
17
|
+
* is through a signed, expiring URL this adapter mints.
|
|
18
|
+
*
|
|
19
|
+
* 2. SIGNED, SHORT-LIVED READS. `upload` and `resign` return URLs that
|
|
20
|
+
* expire. A leaked URL stops working; it is not a forever-handle to the
|
|
21
|
+
* file. Expiry is the adapter's choice but should be short (minutes to a
|
|
22
|
+
* few hours) — long enough for the model to fetch the file mid-turn and
|
|
23
|
+
* for the user to see the thumbnail, short enough to bound exposure.
|
|
24
|
+
* History rehydration re-signs on demand (see `resign`), so short expiry
|
|
25
|
+
* costs nothing in UX.
|
|
26
|
+
*
|
|
27
|
+
* 3. USER-NAMESPACED, UNGUESSABLE PATHS. The adapter is *bound to one
|
|
28
|
+
* verified user* (like `ChatStore`). It derives the storage path itself
|
|
29
|
+
* from its bound `userId` — callers never supply the full path — so an
|
|
30
|
+
* upload cannot be aimed into another user's namespace. Paths include a
|
|
31
|
+
* random segment so they're unguessable even if a bucket were
|
|
32
|
+
* accidentally made listable.
|
|
33
|
+
*
|
|
34
|
+
* Because the adapter is user-bound, the IDOR-resistance argument from
|
|
35
|
+
* `ChatStore` applies identically here: there is no parameter through which a
|
|
36
|
+
* foreign user id or a fully-attacker-controlled path can enter.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* A file presented for upload. Framework-agnostic: the router adapts the
|
|
40
|
+
* incoming multipart `File`/`Blob` into this shape so the adapter never has
|
|
41
|
+
* to know about Web `FormData` or Node streams.
|
|
42
|
+
*/
|
|
43
|
+
interface UploadInput {
|
|
44
|
+
/** Raw bytes of the file. */
|
|
45
|
+
data: ArrayBuffer | Uint8Array;
|
|
46
|
+
/** Original filename (used for display + to derive a safe path segment). */
|
|
47
|
+
filename: string;
|
|
48
|
+
/** MIME type the client claimed. The router validates it against the
|
|
49
|
+
* allow-list BEFORE calling upload; the adapter may re-check defensively. */
|
|
50
|
+
mediaType: string;
|
|
51
|
+
/** Size in bytes. The router enforces the size cap before calling upload. */
|
|
52
|
+
size: number;
|
|
53
|
+
/**
|
|
54
|
+
* The conversation this attachment belongs to. Used only as a path segment
|
|
55
|
+
* for organisation — NOT as an ownership signal (ownership comes from the
|
|
56
|
+
* adapter's bound user). Optional; falls back to an "unfiled" segment.
|
|
57
|
+
*/
|
|
58
|
+
conversationId?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The result of a successful upload — exactly the fields the client needs to
|
|
62
|
+
* render the attachment and the system needs to re-sign it later. Shaped to
|
|
63
|
+
* map directly onto an AI SDK file part plus our durable `storagePath`.
|
|
64
|
+
*/
|
|
65
|
+
interface UploadResult {
|
|
66
|
+
/** Durable, opaque pointer for re-signing. Persisted on the message part. */
|
|
67
|
+
storagePath: string;
|
|
68
|
+
/** Freshly-signed, expiring URL for immediate use (model fetch + preview). */
|
|
69
|
+
url: string;
|
|
70
|
+
/** Echoed back for convenience. */
|
|
71
|
+
filename: string;
|
|
72
|
+
mediaType: string;
|
|
73
|
+
size: number;
|
|
74
|
+
}
|
|
75
|
+
interface StorageAdapter {
|
|
76
|
+
/**
|
|
77
|
+
* The user this adapter is bound to. Read-only; set at construction. The
|
|
78
|
+
* adapter uses it to namespace every path it writes.
|
|
79
|
+
*/
|
|
80
|
+
readonly userId: string;
|
|
81
|
+
/**
|
|
82
|
+
* Store a file and return a signed URL plus its durable `storagePath`.
|
|
83
|
+
*
|
|
84
|
+
* The adapter:
|
|
85
|
+
* - derives the path from its bound `userId` + a random segment + a
|
|
86
|
+
* sanitised filename (callers cannot inject an absolute/foreign path),
|
|
87
|
+
* - writes the bytes to private storage,
|
|
88
|
+
* - mints and returns a short-lived signed URL.
|
|
89
|
+
*
|
|
90
|
+
* Throws on storage failure so the router can return a clean 5xx rather
|
|
91
|
+
* than handing the client a half-uploaded attachment.
|
|
92
|
+
*/
|
|
93
|
+
upload(input: UploadInput): Promise<UploadResult>;
|
|
94
|
+
/**
|
|
95
|
+
* Mint a fresh signed URL for an already-stored object, given the
|
|
96
|
+
* `storagePath` returned by a prior `upload`.
|
|
97
|
+
*
|
|
98
|
+
* This is what makes short expiry on `upload` safe: when an old
|
|
99
|
+
* conversation is reloaded, the router calls `resign` for each attachment
|
|
100
|
+
* so the user always gets a live URL. It is a first-class operation, not a
|
|
101
|
+
* TODO — the absence of this method in the original design forced every
|
|
102
|
+
* consumer to reinvent it.
|
|
103
|
+
*
|
|
104
|
+
* Security: the adapter MUST verify the path belongs to its bound user's
|
|
105
|
+
* namespace before signing, and return `null` if it does not (or if the
|
|
106
|
+
* object is missing). A `null` here means "render a broken/expired
|
|
107
|
+
* thumbnail", never "throw away the whole history" — so one missing blob
|
|
108
|
+
* can't take down a conversation load.
|
|
109
|
+
*/
|
|
110
|
+
resign(storagePath: string): Promise<string | null>;
|
|
111
|
+
/**
|
|
112
|
+
* Permanently delete a stored object by `storagePath`. Used when a
|
|
113
|
+
* conversation is deleted. MUST verify the path is in the bound user's
|
|
114
|
+
* namespace before deleting. No-op (does not throw) if the object is
|
|
115
|
+
* already gone — delete is idempotent.
|
|
116
|
+
*/
|
|
117
|
+
remove(storagePath: string): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Constructs a `StorageAdapter` bound to a specific, already-verified user —
|
|
121
|
+
* same trust rules as `ChatStoreFactory`. `userId` must come from the server
|
|
122
|
+
* session, never from request input.
|
|
123
|
+
*/
|
|
124
|
+
type StorageAdapterFactory = (userId: string) => StorageAdapter;
|
|
125
|
+
|
|
126
|
+
export type { StorageAdapter as S, UploadInput as U, StorageAdapterFactory as a, UploadResult as b };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StorageAdapter — the attachment-storage contract for the widget.
|
|
3
|
+
*
|
|
4
|
+
* Attachments (images, PDFs the user drops into chat) are the second
|
|
5
|
+
* pluggable backend, distinct from `ChatStore`. The connection/bucket is the
|
|
6
|
+
* host app's (their Supabase bucket, their S3/R2, or — on the hosted tier —
|
|
7
|
+
* ours), but the *security model* around it is owned by the package and
|
|
8
|
+
* encoded here, because getting it wrong is a data leak and the original
|
|
9
|
+
* scaffold got it wrong (it used a public bucket with permanent URLs).
|
|
10
|
+
*
|
|
11
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
12
|
+
* Three rules every implementation MUST follow. They are the security model.
|
|
13
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* 1. PRIVATE AT REST. Stored objects must NOT be publicly readable. No
|
|
16
|
+
* public bucket, no permanent public URL. The only way to read an object
|
|
17
|
+
* is through a signed, expiring URL this adapter mints.
|
|
18
|
+
*
|
|
19
|
+
* 2. SIGNED, SHORT-LIVED READS. `upload` and `resign` return URLs that
|
|
20
|
+
* expire. A leaked URL stops working; it is not a forever-handle to the
|
|
21
|
+
* file. Expiry is the adapter's choice but should be short (minutes to a
|
|
22
|
+
* few hours) — long enough for the model to fetch the file mid-turn and
|
|
23
|
+
* for the user to see the thumbnail, short enough to bound exposure.
|
|
24
|
+
* History rehydration re-signs on demand (see `resign`), so short expiry
|
|
25
|
+
* costs nothing in UX.
|
|
26
|
+
*
|
|
27
|
+
* 3. USER-NAMESPACED, UNGUESSABLE PATHS. The adapter is *bound to one
|
|
28
|
+
* verified user* (like `ChatStore`). It derives the storage path itself
|
|
29
|
+
* from its bound `userId` — callers never supply the full path — so an
|
|
30
|
+
* upload cannot be aimed into another user's namespace. Paths include a
|
|
31
|
+
* random segment so they're unguessable even if a bucket were
|
|
32
|
+
* accidentally made listable.
|
|
33
|
+
*
|
|
34
|
+
* Because the adapter is user-bound, the IDOR-resistance argument from
|
|
35
|
+
* `ChatStore` applies identically here: there is no parameter through which a
|
|
36
|
+
* foreign user id or a fully-attacker-controlled path can enter.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* A file presented for upload. Framework-agnostic: the router adapts the
|
|
40
|
+
* incoming multipart `File`/`Blob` into this shape so the adapter never has
|
|
41
|
+
* to know about Web `FormData` or Node streams.
|
|
42
|
+
*/
|
|
43
|
+
interface UploadInput {
|
|
44
|
+
/** Raw bytes of the file. */
|
|
45
|
+
data: ArrayBuffer | Uint8Array;
|
|
46
|
+
/** Original filename (used for display + to derive a safe path segment). */
|
|
47
|
+
filename: string;
|
|
48
|
+
/** MIME type the client claimed. The router validates it against the
|
|
49
|
+
* allow-list BEFORE calling upload; the adapter may re-check defensively. */
|
|
50
|
+
mediaType: string;
|
|
51
|
+
/** Size in bytes. The router enforces the size cap before calling upload. */
|
|
52
|
+
size: number;
|
|
53
|
+
/**
|
|
54
|
+
* The conversation this attachment belongs to. Used only as a path segment
|
|
55
|
+
* for organisation — NOT as an ownership signal (ownership comes from the
|
|
56
|
+
* adapter's bound user). Optional; falls back to an "unfiled" segment.
|
|
57
|
+
*/
|
|
58
|
+
conversationId?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The result of a successful upload — exactly the fields the client needs to
|
|
62
|
+
* render the attachment and the system needs to re-sign it later. Shaped to
|
|
63
|
+
* map directly onto an AI SDK file part plus our durable `storagePath`.
|
|
64
|
+
*/
|
|
65
|
+
interface UploadResult {
|
|
66
|
+
/** Durable, opaque pointer for re-signing. Persisted on the message part. */
|
|
67
|
+
storagePath: string;
|
|
68
|
+
/** Freshly-signed, expiring URL for immediate use (model fetch + preview). */
|
|
69
|
+
url: string;
|
|
70
|
+
/** Echoed back for convenience. */
|
|
71
|
+
filename: string;
|
|
72
|
+
mediaType: string;
|
|
73
|
+
size: number;
|
|
74
|
+
}
|
|
75
|
+
interface StorageAdapter {
|
|
76
|
+
/**
|
|
77
|
+
* The user this adapter is bound to. Read-only; set at construction. The
|
|
78
|
+
* adapter uses it to namespace every path it writes.
|
|
79
|
+
*/
|
|
80
|
+
readonly userId: string;
|
|
81
|
+
/**
|
|
82
|
+
* Store a file and return a signed URL plus its durable `storagePath`.
|
|
83
|
+
*
|
|
84
|
+
* The adapter:
|
|
85
|
+
* - derives the path from its bound `userId` + a random segment + a
|
|
86
|
+
* sanitised filename (callers cannot inject an absolute/foreign path),
|
|
87
|
+
* - writes the bytes to private storage,
|
|
88
|
+
* - mints and returns a short-lived signed URL.
|
|
89
|
+
*
|
|
90
|
+
* Throws on storage failure so the router can return a clean 5xx rather
|
|
91
|
+
* than handing the client a half-uploaded attachment.
|
|
92
|
+
*/
|
|
93
|
+
upload(input: UploadInput): Promise<UploadResult>;
|
|
94
|
+
/**
|
|
95
|
+
* Mint a fresh signed URL for an already-stored object, given the
|
|
96
|
+
* `storagePath` returned by a prior `upload`.
|
|
97
|
+
*
|
|
98
|
+
* This is what makes short expiry on `upload` safe: when an old
|
|
99
|
+
* conversation is reloaded, the router calls `resign` for each attachment
|
|
100
|
+
* so the user always gets a live URL. It is a first-class operation, not a
|
|
101
|
+
* TODO — the absence of this method in the original design forced every
|
|
102
|
+
* consumer to reinvent it.
|
|
103
|
+
*
|
|
104
|
+
* Security: the adapter MUST verify the path belongs to its bound user's
|
|
105
|
+
* namespace before signing, and return `null` if it does not (or if the
|
|
106
|
+
* object is missing). A `null` here means "render a broken/expired
|
|
107
|
+
* thumbnail", never "throw away the whole history" — so one missing blob
|
|
108
|
+
* can't take down a conversation load.
|
|
109
|
+
*/
|
|
110
|
+
resign(storagePath: string): Promise<string | null>;
|
|
111
|
+
/**
|
|
112
|
+
* Permanently delete a stored object by `storagePath`. Used when a
|
|
113
|
+
* conversation is deleted. MUST verify the path is in the bound user's
|
|
114
|
+
* namespace before deleting. No-op (does not throw) if the object is
|
|
115
|
+
* already gone — delete is idempotent.
|
|
116
|
+
*/
|
|
117
|
+
remove(storagePath: string): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Constructs a `StorageAdapter` bound to a specific, already-verified user —
|
|
121
|
+
* same trust rules as `ChatStoreFactory`. `userId` must come from the server
|
|
122
|
+
* session, never from request input.
|
|
123
|
+
*/
|
|
124
|
+
type StorageAdapterFactory = (userId: string) => StorageAdapter;
|
|
125
|
+
|
|
126
|
+
export type { StorageAdapter as S, UploadInput as U, StorageAdapterFactory as a, UploadResult as b };
|