storage-share-links.ts250 lines · main
1import { randomBytes } from 'node:crypto';
2
3import { newId, NotFoundError } from '@briven/shared';
4import { and, eq, gt, isNull, sql } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import { projectStorageShareLinks, type ProjectStorageShareLink } from '../db/schema.js';
8import { env } from '../env.js';
9import { getFile } from './storage.js';
10
11/**
12 * Tokenized public share-links for storage files (M5, public-link half).
13 *
14 * This is the SECOND sanctioned way to share a file — complementing the
15 * project→project grants (`storage-grants.ts`). A file OWNER mints a signed
16 * PUBLIC link: a URL carrying a cryptographically-random token that ANYONE can
17 * open for a LIMITED TIME, with NO project/auth needed — and can revoke at will.
18 *
19 * SECURITY MODEL (strict-deny by construction):
20 * - `resolveShareLink(token)` — THE ENFORCEMENT — returns `{ projectId, fileId }`
21 * ONLY when an ACTIVE link matches: `token` exact-equals AND `revoked_at IS
22 * NULL` AND `expires_at > now()`. Any miss / expired / revoked / error → null.
23 * It never throws. This is the one function the public no-auth route trusts.
24 * - The token is a ≥32-byte URL-safe (base64url) random string from node
25 * crypto (`randomBytes`). It is the ONLY bearer credential and is NEVER
26 * logged (the MCP tools audit file_id + expiry, never the token).
27 * - A link exposes exactly ONE owned file. `createShareLink` resolves the file
28 * through the owner-scoped `getFile(fileId, projectId)` and rejects anything
29 * the project does not own / that is missing / deleted.
30 * - Only the owner may revoke: `revokeShareLink` scopes its WHERE by projectId,
31 * so project A can never touch project B's link (matches zero rows → deny).
32 * - A link works even for a file that is NOT marked public — that is the whole
33 * point of a private, time-limited link — but ONLY via a valid token.
34 *
35 * The control-plane table is created idempotently on first use (CREATE TABLE IF
36 * NOT EXISTS — same pattern as storage-grants) so the code is safe even before
37 * migration 0051 has been applied.
38 */
39
40/** Token entropy — 32 bytes → ~43 URL-safe base64url chars. */
41const TOKEN_BYTES = 32;
42
43/** Expiry clamps (seconds): min 60s, default 24h, max 30 days. */
44const MIN_EXPIRES_SEC = 60;
45const DEFAULT_EXPIRES_SEC = 24 * 60 * 60;
46const MAX_EXPIRES_SEC = 30 * 24 * 60 * 60;
47
48let tableReady = false;
49async function ensureTable(): Promise<void> {
50 if (tableReady) return;
51 const db = getDb();
52 await db.execute(
53 sql.raw(
54 `CREATE TABLE IF NOT EXISTS "project_storage_share_links" (
55 "id" text PRIMARY KEY NOT NULL,
56 "project_id" text NOT NULL,
57 "file_id" text NOT NULL,
58 "token" text NOT NULL,
59 "expires_at" timestamp with time zone NOT NULL,
60 "created_by" text,
61 "created_at" timestamp with time zone DEFAULT now() NOT NULL,
62 "revoked_at" timestamp with time zone
63 )`,
64 ),
65 );
66 await db.execute(
67 sql.raw(
68 `CREATE UNIQUE INDEX IF NOT EXISTS "project_storage_share_links_token_idx" ON "project_storage_share_links" ("token")`,
69 ),
70 );
71 await db.execute(
72 sql.raw(
73 `CREATE INDEX IF NOT EXISTS "project_storage_share_links_project_idx" ON "project_storage_share_links" ("project_id")`,
74 ),
75 );
76 tableReady = true;
77}
78
79export interface ShareLinkRecord {
80 id: string;
81 projectId: string;
82 fileId: string;
83 /** The bearer token — included so a fresh create can return its URL. Callers
84 * that LIST links may choose to omit it; `toRecord` keeps it for symmetry. */
85 token: string;
86 url: string;
87 expiresAt: string;
88 createdBy: string | null;
89 createdAt: string;
90 revokedAt: string | null;
91}
92
93/** `https://media.<domain>/link/<token>` — built from env like other code does. */
94function shareLinkUrl(token: string): string {
95 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
96 return `https://media.${domain}/link/${token}`;
97}
98
99function toRecord(r: ProjectStorageShareLink): ShareLinkRecord {
100 const iso = (d: Date | null): string | null =>
101 d ? (d instanceof Date ? d : new Date(d)).toISOString() : null;
102 return {
103 id: r.id,
104 projectId: r.projectId,
105 fileId: r.fileId,
106 token: r.token,
107 url: shareLinkUrl(r.token),
108 expiresAt: iso(r.expiresAt) ?? new Date().toISOString(),
109 createdBy: r.createdBy ?? null,
110 createdAt: iso(r.createdAt) ?? new Date().toISOString(),
111 revokedAt: iso(r.revokedAt),
112 };
113}
114
115/** Clamp a requested expiry (seconds) into [min, max], defaulting when unset. */
116export function clampExpiresInSeconds(requested: number | null | undefined): number {
117 if (requested === null || requested === undefined || !Number.isFinite(requested)) {
118 return DEFAULT_EXPIRES_SEC;
119 }
120 const n = Math.floor(requested);
121 if (n < MIN_EXPIRES_SEC) return MIN_EXPIRES_SEC;
122 if (n > MAX_EXPIRES_SEC) return MAX_EXPIRES_SEC;
123 return n;
124}
125
126export interface CreateShareLinkInput {
127 projectId: string;
128 fileId: string;
129 /** Requested lifetime in seconds. Clamped to [60s, 30d]; defaults to 24h. */
130 expiresInSeconds?: number | null;
131 createdBy: string | null;
132}
133
134export interface CreateShareLinkResult {
135 id: string;
136 token: string;
137 url: string;
138 expiresAt: string;
139}
140
141/**
142 * Mint a public share-link for one of the OWNER's files. Validates the file
143 * belongs to `projectId` (owner-scoped `getFile`, which throws NotFoundError if
144 * missing/deleted/not owned), clamps the expiry, generates a random token, and
145 * inserts. Returns `{ id, token, url, expiresAt }`. Every call mints a fresh
146 * link — links are cheap and independently revocable, so we never dedupe.
147 */
148export async function createShareLink(input: CreateShareLinkInput): Promise<CreateShareLinkResult> {
149 // Owner-scoped resolution: throws NotFoundError if the file isn't this
150 // project's live file. This is the only way a projectId can name a fileId.
151 await getFile(input.fileId, input.projectId);
152
153 await ensureTable();
154 const db = getDb();
155
156 const expiresInSeconds = clampExpiresInSeconds(input.expiresInSeconds);
157 const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
158 const token = randomBytes(TOKEN_BYTES).toString('base64url');
159
160 const row = {
161 id: newId('sl'),
162 projectId: input.projectId,
163 fileId: input.fileId,
164 token,
165 expiresAt,
166 createdBy: input.createdBy,
167 };
168 const [inserted] = await db.insert(projectStorageShareLinks).values(row).returning();
169 if (!inserted) throw new Error('storage share-link insert returned nothing');
170 const rec = toRecord(inserted);
171 return { id: rec.id, token: rec.token, url: rec.url, expiresAt: rec.expiresAt };
172}
173
174/**
175 * Revoke a share-link. ONLY the owner can revoke — the WHERE is scoped by
176 * projectId, so any other project presenting the link id matches zero rows and
177 * gets a NotFoundError. Sets revoked_at (soft). Idempotent: re-revoking an
178 * already-revoked link still succeeds (the row is still owned by the project).
179 */
180export async function revokeShareLink(
181 projectId: string,
182 linkId: string,
183): Promise<ShareLinkRecord> {
184 await ensureTable();
185 const db = getDb();
186 const [row] = await db
187 .update(projectStorageShareLinks)
188 .set({ revokedAt: new Date() })
189 .where(
190 and(
191 eq(projectStorageShareLinks.id, linkId),
192 eq(projectStorageShareLinks.projectId, projectId),
193 ),
194 )
195 .returning();
196 if (!row) throw new NotFoundError('storage_share_link', linkId);
197 return toRecord(row);
198}
199
200/** Every share-link the project owns (active + revoked + expired), newest first. */
201export async function listShareLinks(projectId: string): Promise<ShareLinkRecord[]> {
202 await ensureTable();
203 const db = getDb();
204 const rows = await db
205 .select()
206 .from(projectStorageShareLinks)
207 .where(eq(projectStorageShareLinks.projectId, projectId));
208 return rows.map(toRecord).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
209}
210
211/**
212 * THE ENFORCEMENT. Resolve a bearer token to the file it grants — but ONLY when
213 * an ACTIVE link matches: exact `token` AND `revoked_at IS NULL` AND
214 * `expires_at > now()`. Any miss / expired / revoked / bad input / error → null.
215 * Never throws. This is the sole check the public no-auth `/link/:token` route
216 * relies on, so it fails CLOSED on everything.
217 */
218export async function resolveShareLink(
219 token: string,
220): Promise<{ projectId: string; fileId: string } | null> {
221 try {
222 if (typeof token !== 'string' || token.length === 0) return null;
223
224 await ensureTable();
225 const db = getDb();
226
227 // The three predicates ARE the security contract, enforced in SQL:
228 // token exact-equals · revoked_at IS NULL · expires_at > now()
229 const [row] = await db
230 .select({
231 projectId: projectStorageShareLinks.projectId,
232 fileId: projectStorageShareLinks.fileId,
233 })
234 .from(projectStorageShareLinks)
235 .where(
236 and(
237 eq(projectStorageShareLinks.token, token),
238 isNull(projectStorageShareLinks.revokedAt),
239 gt(projectStorageShareLinks.expiresAt, new Date()),
240 ),
241 )
242 .limit(1);
243
244 if (!row || !row.projectId || !row.fileId) return null;
245 return { projectId: row.projectId, fileId: row.fileId };
246 } catch {
247 // STRICT-DENY: any unexpected failure denies access.
248 return null;
249 }
250}