From 1cb76c7115a99a642774b15dc6adfe7339030bee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 08:40:44 +0000 Subject: [PATCH 1/6] feat(auth): scoped tokens narrowed to tags and/or file IDs Extend adr_ API keys with optional tag/file-id targets (migration 0011), enforce them across REST, MCP, and content-link minting, and surface minting/listing in the dashboard and a new `adrive keys` CLI command. Scoped tokens read and edit only in-scope files and cannot create new files, sites, or tags. Co-authored-by: Ben Davis --- apps/web/migrations/0011_api_key_targets.sql | 6 + .../src/lib/components/auth/ApiKeys.svelte | 128 ++++++++++-- apps/web/src/lib/dashboard/api.ts | 15 +- apps/web/src/lib/dashboard/parse.ts | 13 +- apps/web/src/lib/server/mcp/server.test.ts | 3 +- apps/web/src/lib/server/mcp/server.ts | 25 ++- .../lib/server/routes/scoped-tokens.test.ts | 195 ++++++++++++++++++ apps/web/src/lib/server/services/auth.ts | 86 +++++++- apps/web/src/lib/server/token-scope.test.ts | 56 +++++ apps/web/src/lib/server/token-scope.ts | 93 +++++++++ apps/web/src/routes/api/auth/keys/+server.ts | 29 ++- .../src/routes/api/auth/keys/[id]/+server.ts | 12 +- apps/web/src/routes/api/files/+server.ts | 6 +- apps/web/src/routes/api/files/[id]/+server.ts | 12 +- .../routes/api/files/[id]/content/+server.ts | 4 +- .../src/routes/api/files/[id]/link/+server.ts | 4 +- .../routes/api/files/[id]/preview/+server.ts | 4 +- .../src/routes/api/files/[id]/tags/+server.ts | 9 +- .../routes/api/files/[id]/versions/+server.ts | 2 + apps/web/src/routes/api/search/+server.ts | 5 +- .../src/routes/api/sites/sessions/+server.ts | 9 +- apps/web/src/routes/api/tags/+server.ts | 9 +- apps/web/src/routes/api/tags/[id]/+server.ts | 17 +- apps/web/src/routes/settings/+page.svelte | 2 +- packages/cli/src/cli-smoke.test.ts | 84 ++++++++ packages/cli/src/commands/keys.ts | 149 +++++++++++++ packages/cli/src/main.ts | 2 + packages/shared/src/index.ts | 11 +- 28 files changed, 933 insertions(+), 57 deletions(-) create mode 100644 apps/web/migrations/0011_api_key_targets.sql create mode 100644 apps/web/src/lib/server/routes/scoped-tokens.test.ts create mode 100644 apps/web/src/lib/server/token-scope.test.ts create mode 100644 apps/web/src/lib/server/token-scope.ts create mode 100644 packages/cli/src/commands/keys.ts diff --git a/apps/web/migrations/0011_api_key_targets.sql b/apps/web/migrations/0011_api_key_targets.sql new file mode 100644 index 0000000..3ba6573 --- /dev/null +++ b/apps/web/migrations/0011_api_key_targets.sql @@ -0,0 +1,6 @@ +-- Scoped tokens narrow a full-drive adr_ key to a set of tags and/or explicit +-- file IDs. Both columns hold a JSON array of ids; NULL means "no restriction" +-- on that axis. A key is unrestricted (full drive) only when both are NULL. +-- Existing keys keep NULL for both and stay full-drive. +ALTER TABLE api_keys ADD COLUMN allowed_tag_ids TEXT; +ALTER TABLE api_keys ADD COLUMN allowed_file_ids TEXT; diff --git a/apps/web/src/lib/components/auth/ApiKeys.svelte b/apps/web/src/lib/components/auth/ApiKeys.svelte index 4960048..05cb7ef 100644 --- a/apps/web/src/lib/components/auth/ApiKeys.svelte +++ b/apps/web/src/lib/components/auth/ApiKeys.svelte @@ -1,5 +1,5 @@

API keys

Read/write keys have full access to this drive; read-only keys can list and - download but not change anything. Store them like passwords. + download but not change anything. Scope a token to specific tags or file IDs + to hand out narrow, revocable access. Store them like passwords.

{ event.preventDefault(); void create(); }} > - - - +
+ + + + +
+
+ Limit scope (optional) +
+ {#if tags.length > 0} +
+ {#each tags as tag (tag.id)} + + {/each} +
+ {/if} + +

+ A scoped token only reads and edits files that carry one of the chosen + tags or appear in the file list. Leave both empty for a full-drive key. +

+
+
{#if created} @@ -144,6 +231,9 @@ : ''} {key.revokedAt ? ' · revoked' : ''}

+ {#if scopeSummary(key)} +

{scopeSummary(key)}

+ {/if} {#if !key.revokedAt}
- +
diff --git a/packages/cli/src/cli-smoke.test.ts b/packages/cli/src/cli-smoke.test.ts index 2d1ac94..378a299 100644 --- a/packages/cli/src/cli-smoke.test.ts +++ b/packages/cli/src/cli-smoke.test.ts @@ -20,6 +20,7 @@ let deviceAuthorizations = 0; let authChecks = 0; let uploadedContentLength: string | undefined; let linkUrlOverride: string | undefined; +let keyCreateBody: unknown; const deviceApiKey = 'adr_login123_123456789012345678901234'; @@ -223,6 +224,55 @@ beforeAll(async () => { } return; } + if (request.method === 'POST' && request.url === '/api/auth/keys') { + const chunks: Array = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + keyCreateBody = JSON.parse(Buffer.concat(chunks).toString()); + response.statusCode = 201; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + key: { + id: 'key-scoped', + name: 'scoped agent', + prefix: 'abcd1234', + scope: 'read-only', + createdAt: file.createdAt, + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + allowedTagIds: ['tag-a'], + allowedFileIds: null + }, + token: 'adr_scoped01_123456789012345678901234' + }) + ); + }); + return; + } + if (request.method === 'GET' && request.url === '/api/auth/keys') { + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + keys: [ + { + id: 'key-scoped', + name: 'scoped agent', + prefix: 'abcd1234', + scope: 'read-only', + createdAt: file.createdAt, + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + allowedTagIds: ['tag-a'], + allowedFileIds: null + } + ] + }) + ); + return; + } if (request.method === 'PUT' && request.url === '/api/files/boom/tags') { // A 500 with a non-JSON body — the CLI falls back to a status hint. response.statusCode = 500; @@ -540,6 +590,40 @@ describe('CLI stream and JSON contracts', () => { ); }); + it('mints a scoped token and forwards its tag/file targets', async () => { + keyCreateBody = undefined; + const result = await run([ + 'keys', + 'create', + 'scoped agent', + '--scope', + 'read-only', + '--tags', + 'tag-a, tag-b', + '--files', + 'file-1' + ]); + expect(result.status).toBe(0); + expect(result.stderr.toString()).toBe(''); + expect(result.stdout.toString()).toContain( + 'adr_scoped01_123456789012345678901234' + ); + expect(keyCreateBody).toMatchObject({ + name: 'scoped agent', + scope: 'read-only', + allowedTagIds: ['tag-a', 'tag-b'], + allowedFileIds: ['file-1'] + }); + }); + + it('lists keys as machine-parseable JSON', async () => { + const result = await run(['--json', 'keys', 'list']); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + keys: [{ id: 'key-scoped', allowedTagIds: ['tag-a'] }] + }); + }); + it('accepts update as an alias of upgrade', async () => { const help = await run(['--help']); expect(help.status).toBe(0); diff --git a/packages/cli/src/commands/keys.ts b/packages/cli/src/commands/keys.ts new file mode 100644 index 0000000..5f84ec8 --- /dev/null +++ b/packages/cli/src/commands/keys.ts @@ -0,0 +1,149 @@ +import { + ApiKeyCreateResponseSchema, + ApiKeyListResponseSchema +} from '@adrive/shared'; +import { Console, Effect, Option } from 'effect'; +import { Argument, Command, Flag } from 'effect/unstable/cli'; +import { HttpBody, HttpClient } from 'effect/unstable/http'; +import { loadConfig } from '../config.ts'; +import { CliFailure } from '../errors.ts'; +import { apiRequest, decodeBody, ensureOk } from '../http.ts'; +import { emit, wantsJson } from '../output.ts'; + +const splitIds = (value: Option.Option): ReadonlyArray => + Option.match(value, { + onNone: () => [], + onSome: (raw) => + raw + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter((entry) => entry !== '') + }); + +export const keysList = Command.make('list', {}, () => + Effect.gen(function* () { + const config = yield* loadConfig; + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + apiRequest('GET', `${config.endpoint}/api/auth/keys`, config.apiKey) + ) + .pipe(Effect.flatMap(ensureOk)); + const result = yield* decodeBody(ApiKeyListResponseSchema, response); + if (wantsJson()) { + yield* emit(result); + } else { + for (const key of result.keys) { + const scope = + key.allowedTagIds || key.allowedFileIds + ? `${key.scope} · scoped` + : key.scope; + yield* Console.log( + `${key.id}\t${key.name}\tadr_${key.prefix}_…\t${scope}${key.revokedAt ? '\trevoked' : ''}` + ); + } + } + }) +).pipe(Command.withDescription('List API keys and scoped tokens')); + +export const keysCreate = Command.make( + 'create', + { + name: Argument.string('name'), + scope: Flag.string('scope').pipe( + Flag.optional, + Flag.withDescription('read-only or read-write (default read-write)') + ), + expires: Flag.string('expires').pipe( + Flag.optional, + Flag.withDescription('Future ISO-8601 expiry timestamp') + ), + tags: Flag.string('tags').pipe( + Flag.optional, + Flag.withDescription('Scope to these tag IDs (comma or space separated)') + ), + files: Flag.string('files').pipe( + Flag.optional, + Flag.withDescription('Scope to these file IDs (comma or space separated)') + ) + }, + ({ name, scope, expires, tags, files }) => + Effect.gen(function* () { + const config = yield* loadConfig; + const client = yield* HttpClient.HttpClient; + const scopeValue = Option.getOrUndefined(scope); + if ( + scopeValue !== undefined && + scopeValue !== 'read-only' && + scopeValue !== 'read-write' + ) { + return yield* new CliFailure({ + message: '--scope must be read-only or read-write' + }); + } + const allowedTagIds = splitIds(tags); + const allowedFileIds = splitIds(files); + const response = yield* client + .execute( + apiRequest( + 'POST', + `${config.endpoint}/api/auth/keys`, + config.apiKey, + { + body: HttpBody.jsonUnsafe({ + name, + ...(scopeValue !== undefined ? { scope: scopeValue } : {}), + ...(Option.isSome(expires) + ? { expiresAt: expires.value } + : {}), + ...(allowedTagIds.length > 0 + ? { allowedTagIds } + : {}), + ...(allowedFileIds.length > 0 + ? { allowedFileIds } + : {}) + }) + } + ) + ) + .pipe(Effect.flatMap(ensureOk)); + const result = yield* decodeBody(ApiKeyCreateResponseSchema, response); + if (wantsJson()) { + yield* emit(result); + } else { + yield* Console.log(result.token); + yield* Console.log( + `${result.key.id} · ${result.key.name} · ${result.key.scope}${result.key.allowedTagIds || result.key.allowedFileIds ? ' · scoped' : ''}` + ); + } + }) +).pipe( + Command.withDescription('Mint an API key (optionally scoped to tags/files)') +); + +export const keysRevoke = Command.make( + 'revoke', + { id: Argument.string('id') }, + ({ id }) => + Effect.gen(function* () { + const config = yield* loadConfig; + const client = yield* HttpClient.HttpClient; + yield* client + .execute( + apiRequest( + 'DELETE', + `${config.endpoint}/api/auth/keys/${encodeURIComponent(id)}`, + config.apiKey + ) + ) + .pipe(Effect.flatMap(ensureOk)); + yield* emit( + wantsJson() ? { id, status: 'revoked' } : `Revoked key ${id}` + ); + }) +).pipe(Command.withDescription('Revoke an API key')); + +export const keys = Command.make('keys').pipe( + Command.withDescription('Manage API keys and scoped tokens'), + Command.withSubcommands([keysList, keysCreate, keysRevoke]) +); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index ae7c48b..258374d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -5,6 +5,7 @@ import { Effect } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; import { login, whoami } from './commands/auth.ts'; import { get, list, put, rename, status } from './commands/files.ts'; +import { keys } from './commands/keys.ts'; import { site } from './commands/sites.ts'; import { tag } from './commands/tags.ts'; import { upgrade } from './commands/upgrade.ts'; @@ -25,6 +26,7 @@ const root = Command.make('adrive', { put, get, rename, + keys, site, tag, upgrade diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ace89a7..5189224 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -319,7 +319,12 @@ export type ApiKeyScope = typeof ApiKeyScopeSchema.Type; export const ApiKeyCreateSchema = Schema.Struct({ name: Schema.String, scope: Schema.optional(ApiKeyScopeSchema), - expiresAt: Schema.optional(Schema.NullOr(Schema.String)) + expiresAt: Schema.optional(Schema.NullOr(Schema.String)), + // Scoped tokens: a key restricted to these tag ids and/or file ids can + // only read and modify files that carry one of the tags or appear in the + // file list. Omit or pass null/empty for a full-drive key. + allowedTagIds: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + allowedFileIds: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))) }); export const ApiKeySchema = Schema.Struct({ @@ -330,7 +335,9 @@ export const ApiKeySchema = Schema.Struct({ createdAt: Schema.String, expiresAt: Schema.NullOr(Schema.String), lastUsedAt: Schema.NullOr(Schema.String), - revokedAt: Schema.NullOr(Schema.String) + revokedAt: Schema.NullOr(Schema.String), + allowedTagIds: Schema.NullOr(Schema.Array(Schema.String)), + allowedFileIds: Schema.NullOr(Schema.Array(Schema.String)) }); export type ApiKey = typeof ApiKeySchema.Type; From 04fbf99db607620223fdc7e5a3cf9be2f7e89045 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 08:54:26 +0000 Subject: [PATCH 2/6] feat(shares): durable, revocable private links Add a file_shares table (migration 0012) and a Shares service backing an optionally-passworded, days-long share link that works on the cookie-less content origin via /f/?s=. Follows the current version, is revocable, and never needs the dashboard session cookie. The 15-minute HMAC grant is unchanged for dashboard previews. Wire dashboard, REST, MCP (create/list/revoke_share), and a new `adrive share` CLI command. Co-authored-by: Ben Davis --- apps/web/migrations/0012_file_shares.sql | 24 ++ .../src/lib/components/FileDetailView.svelte | 77 +++-- .../src/lib/components/auth/ApiKeys.svelte | 7 +- .../lib/components/files/FileShares.svelte | 146 ++++++++ apps/web/src/lib/dashboard/api.ts | 45 +++ apps/web/src/lib/dashboard/parse.ts | 37 +++ apps/web/src/lib/server/edge.ts | 4 +- apps/web/src/lib/server/layer.ts | 3 + apps/web/src/lib/server/mcp/server.ts | 71 +++- apps/web/src/lib/server/routes/shares.test.ts | 132 ++++++++ apps/web/src/lib/server/services/shares.ts | 313 ++++++++++++++++++ .../web/src/lib/server/share-password-page.ts | 60 ++++ apps/web/src/lib/server/token-crypto.ts | 48 +++ .../routes/api/files/[id]/shares/+server.ts | 60 ++++ .../files/[id]/shares/[shareId]/+server.ts | 23 ++ apps/web/src/routes/f/[id]/+server.ts | 95 ++++-- packages/cli/src/commands/shares.ts | 133 ++++++++ packages/cli/src/main.ts | 2 + packages/shared/src/index.ts | 45 +++ 19 files changed, 1256 insertions(+), 69 deletions(-) create mode 100644 apps/web/migrations/0012_file_shares.sql create mode 100644 apps/web/src/lib/components/files/FileShares.svelte create mode 100644 apps/web/src/lib/server/routes/shares.test.ts create mode 100644 apps/web/src/lib/server/services/shares.ts create mode 100644 apps/web/src/lib/server/share-password-page.ts create mode 100644 apps/web/src/lib/server/token-crypto.ts create mode 100644 apps/web/src/routes/api/files/[id]/shares/+server.ts create mode 100644 apps/web/src/routes/api/files/[id]/shares/[shareId]/+server.ts create mode 100644 packages/cli/src/commands/shares.ts diff --git a/apps/web/migrations/0012_file_shares.sql b/apps/web/migrations/0012_file_shares.sql new file mode 100644 index 0000000..6921d19 --- /dev/null +++ b/apps/web/migrations/0012_file_shares.sql @@ -0,0 +1,24 @@ +-- Durable private links: a revocable, optionally passworded share of a single +-- file that works on the cookie-less content origin. The 15-minute HMAC grant +-- stays as-is for dashboard previews; a share is the "open on my phone later" +-- or "send to one person" path. The token is stored hashed (prefix for lookup, +-- SHA-256 of the full secret); the plaintext token is shown once at creation. +-- A share follows the file's current version and stops resolving once the file +-- is trashed, expired, or the share is revoked or past expires_at. +CREATE TABLE file_shares ( + id TEXT PRIMARY KEY, + file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, + token_prefix TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + password_hash TEXT, + label TEXT, + created_at TEXT NOT NULL, + expires_at TEXT, + last_accessed_at TEXT, + revoked_at TEXT +); + +CREATE INDEX file_shares_file_idx + ON file_shares(file_id, revoked_at); +CREATE INDEX file_shares_expiry_idx + ON file_shares(expires_at); diff --git a/apps/web/src/lib/components/FileDetailView.svelte b/apps/web/src/lib/components/FileDetailView.svelte index ccedb2f..2442f57 100644 --- a/apps/web/src/lib/components/FileDetailView.svelte +++ b/apps/web/src/lib/components/FileDetailView.svelte @@ -15,6 +15,7 @@ import Icon from './ui/Icon.svelte'; import FileName from './files/FileName.svelte'; import FilePreview from './files/FilePreview.svelte'; + import FileShares from './files/FileShares.svelte'; import FileSidebar from './files/FileSidebar.svelte'; import { resource } from 'runed'; import { untrack } from 'svelte'; @@ -368,41 +369,47 @@ contentOrigin={detail.current.contentOrigin} ondownload={() => void openLink()} /> - void loadOlderVersions()} - availableTags={detail.current.availableTags} - {busy} - oncopy={() => resolveLink()} - ondownload={() => void openLink()} - onvisibility={(value) => - void update( - { action: 'visibility', public: value }, - value ? 'File is public' : 'File is private' - )} - onexpiration={(expiresAt) => - update( - { action: 'expiration', expiresAt }, - expiresAt ? 'Expiration updated' : 'Expiration removed' - )} - ontag={(tag) => void toggleTag(tag)} - oncreatetag={addTag} - onversion={(file) => void putVersion(file)} - oncopyversion={(version) => resolveLink(version)} - onopenversion={(version) => void openLink(version)} - onrestoreversion={(version) => - void update( - { action: 'restore-version', version }, - `Version ${version} restored as a new version` - )} - onreindex={() => - void update({ action: 'reindex' }, 'Reindexing queued')} - ontrash={() => void update({ action: 'trash' }, 'File moved to trash')} - onrestore={() => void update({ action: 'restore' }, 'File restored')} - /> +
+ {#if detail.current.file.kind === 'file' && !detail.current.file.public && !detail.current.file.deletedAt} + + {/if} + void loadOlderVersions()} + availableTags={detail.current.availableTags} + {busy} + oncopy={() => resolveLink()} + ondownload={() => void openLink()} + onvisibility={(value) => + void update( + { action: 'visibility', public: value }, + value ? 'File is public' : 'File is private' + )} + onexpiration={(expiresAt) => + update( + { action: 'expiration', expiresAt }, + expiresAt ? 'Expiration updated' : 'Expiration removed' + )} + ontag={(tag) => void toggleTag(tag)} + oncreatetag={addTag} + onversion={(file) => void putVersion(file)} + oncopyversion={(version) => resolveLink(version)} + onopenversion={(version) => void openLink(version)} + onrestoreversion={(version) => + void update( + { action: 'restore-version', version }, + `Version ${version} restored as a new version` + )} + onreindex={() => + void update({ action: 'reindex' }, 'Reindexing queued')} + ontrash={() => + void update({ action: 'trash' }, 'File moved to trash')} + onrestore={() => void update({ action: 'restore' }, 'File restored')} + /> +
{/if} diff --git a/apps/web/src/lib/components/auth/ApiKeys.svelte b/apps/web/src/lib/components/auth/ApiKeys.svelte index 05cb7ef..4f5381b 100644 --- a/apps/web/src/lib/components/auth/ApiKeys.svelte +++ b/apps/web/src/lib/components/auth/ApiKeys.svelte @@ -155,7 +155,9 @@
- Limit scope (optional) + Limit scope (optional)
{#if tags.length > 0}
@@ -180,7 +182,8 @@ />

A scoped token only reads and edits files that carry one of the chosen - tags or appear in the file list. Leave both empty for a full-drive key. + tags or appear in the file list. Leave both empty for a full-drive + key.

diff --git a/apps/web/src/lib/components/files/FileShares.svelte b/apps/web/src/lib/components/files/FileShares.svelte new file mode 100644 index 0000000..af71d32 --- /dev/null +++ b/apps/web/src/lib/components/files/FileShares.svelte @@ -0,0 +1,146 @@ + + +
+

Durable links

+

+ A revocable link that works on your phone or for one recipient, lasting days + instead of the 15-minute preview link. +

+ +
+ +
+ + +
+
+ + {#if createdUrl} +
+

+ Copy this link now. The secret is shown only once. +

+ {createdUrl} + createdUrl} + /> +
+ {/if} + + {#if shares.loading && shares.current === undefined} +

Loading links…

+ {:else if activeShares.length} +
    + {#each activeShares as share (share.id)} +
  • + + {share.hasPassword ? 'password · ' : ''}{share.expiresAt + ? `expires ${formatDate(share.expiresAt)}` + : 'no expiry'} + + +
  • + {/each} +
+ {:else if !shares.error} +

No durable links yet.

+ {/if} +
diff --git a/apps/web/src/lib/dashboard/api.ts b/apps/web/src/lib/dashboard/api.ts index 9f927d7..1769ab9 100644 --- a/apps/web/src/lib/dashboard/api.ts +++ b/apps/web/src/lib/dashboard/api.ts @@ -3,6 +3,7 @@ import type { FileDetailResponse, FileListResponse, FileMutation, + FileShareCreate, Tag, TagCreate, TagUpdate @@ -14,6 +15,8 @@ import { parseFileDetailResponse, parseFileListResponse, parseFileMutationResponse, + parseFileShareCreateResponse, + parseFileShareListResponse, parseFileTagsResponse, parseSessionsRevokedResponse, parseTagResponse, @@ -426,6 +429,48 @@ export const mutateFile = async ( return json(parseFileMutationResponse, response); }; +export const listShares = async ( + token: string, + fileId: string, + signal?: AbortSignal +) => { + const response = await request( + `/api/files/${encodeURIComponent(fileId)}/shares`, + token, + { signal } + ); + return json(parseFileShareListResponse, response); +}; + +export const createShare = async ( + token: string, + fileId: string, + input: FileShareCreate +) => { + const response = await request( + `/api/files/${encodeURIComponent(fileId)}/shares`, + token, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + } + ); + return json(parseFileShareCreateResponse, response); +}; + +export const revokeShare = async ( + token: string, + fileId: string, + shareId: string +) => { + await request( + `/api/files/${encodeURIComponent(fileId)}/shares/${encodeURIComponent(shareId)}`, + token, + { method: 'DELETE' } + ); +}; + export const createTag = async (token: string, input: TagCreate) => { const response = await request('/api/tags', token, { method: 'POST', diff --git a/apps/web/src/lib/dashboard/parse.ts b/apps/web/src/lib/dashboard/parse.ts index beba362..b1b516d 100644 --- a/apps/web/src/lib/dashboard/parse.ts +++ b/apps/web/src/lib/dashboard/parse.ts @@ -13,6 +13,9 @@ import type { FileDetailResponse, FileListResponse, FileMutationResponse, + FileShare, + FileShareCreateResponse, + FileShareListResponse, FileSummary, FileTagsResponse, FileVersion, @@ -284,3 +287,37 @@ export const parseSessionsRevokedResponse = ( revoked: integer(record.revoked, 'sessions.revoked') }; }; + +const parseFileShare = (value: unknown, path = 'share'): FileShare => { + const record = requireRecord(value, path); + return { + id: text(record.id, `${path}.id`), + fileId: text(record.fileId, `${path}.fileId`), + label: maybeString(record.label, `${path}.label`), + hasPassword: flag(record.hasPassword, `${path}.hasPassword`), + createdAt: text(record.createdAt, `${path}.createdAt`), + expiresAt: maybeString(record.expiresAt, `${path}.expiresAt`), + lastAccessedAt: maybeString(record.lastAccessedAt, `${path}.lastAccessedAt`), + revokedAt: maybeString(record.revokedAt, `${path}.revokedAt`) + }; +}; + +export const parseFileShareListResponse = ( + value: unknown +): FileShareListResponse => { + const record = requireRecord(value, 'shares'); + return { + shares: list(record.shares, parseFileShare, 'shares.shares'), + contentOrigin: text(record.contentOrigin, 'shares.contentOrigin') + }; +}; + +export const parseFileShareCreateResponse = ( + value: unknown +): FileShareCreateResponse => { + const record = requireRecord(value, 'share'); + return { + share: parseFileShare(record.share, 'share.share'), + url: text(record.url, 'share.url') + }; +}; diff --git a/apps/web/src/lib/server/edge.ts b/apps/web/src/lib/server/edge.ts index 1671ef5..7831013 100644 --- a/apps/web/src/lib/server/edge.ts +++ b/apps/web/src/lib/server/edge.ts @@ -29,6 +29,7 @@ import type { Embedder, VectorIndex } from './services/semantic'; import type { Indexing } from './services/indexing'; import type { Lifecycle } from './services/lifecycle'; import type { GrantSecrets } from './services/grant-secrets'; +import type { Shares } from './services/shares'; export type AppServices = | SqlClient.SqlClient @@ -44,7 +45,8 @@ export type AppServices = | VectorIndex | Indexing | Lifecycle - | GrantSecrets; + | GrantSecrets + | Shares; export const isAppError = (failure: unknown): failure is AppError => failure instanceof InvalidRequest || diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index 63dde74..c91f6de 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -13,6 +13,7 @@ import { SemanticBindingsLive } from './services/semantic'; import { IndexingLive } from './services/indexing'; import { LifecycleLive } from './services/lifecycle'; import { GrantSecretsLive } from './services/grant-secrets'; +import { SharesLive } from './services/shares'; const SqlLive = Layer.unwrap(Effect.map(Db, (db) => D1.layer({ db }))); @@ -30,6 +31,7 @@ export const requestLayer = (env: Env) => { const auth = AuthLive.pipe(Layer.provide(infrastructure)); const authGuard = AuthGuardLive().pipe(Layer.provide(bindings)); const grantSecrets = GrantSecretsLive.pipe(Layer.provide(infrastructure)); + const shares = SharesLive.pipe(Layer.provide(infrastructure)); const tags = TagsLive.pipe(Layer.provide(infrastructure)); const search = SearchLive.pipe( Layer.provide(Layer.merge(infrastructure, semantic)) @@ -51,6 +53,7 @@ export const requestLayer = (env: Env) => { auth, authGuard, grantSecrets, + shares, tags, search, sites, diff --git a/apps/web/src/lib/server/mcp/server.ts b/apps/web/src/lib/server/mcp/server.ts index d115837..59389fc 100644 --- a/apps/web/src/lib/server/mcp/server.ts +++ b/apps/web/src/lib/server/mcp/server.ts @@ -18,6 +18,7 @@ import { Blobs } from '../services/blobs'; import { Files } from '../services/files'; import { Indexing } from '../services/indexing'; import { Search } from '../services/search'; +import { Shares } from '../services/shares'; import { Sites } from '../services/sites'; import { Tags } from '../services/tags'; import type { AppServices } from '../edge'; @@ -39,7 +40,8 @@ export const READ_TOOL_NAMES = [ 'list_files', 'search_files', 'get_file', - 'list_tags' + 'list_tags', + 'list_shares' ] as const; export const WRITE_TOOL_NAMES = [ @@ -49,7 +51,9 @@ export const WRITE_TOOL_NAMES = [ 'update_tag', 'delete_tag', 'set_file_tags', - 'publish_site' + 'publish_site', + 'create_share', + 'revoke_share' ] as const; export interface McpServerInput { @@ -311,6 +315,23 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { }) ) ); + + server.registerTool( + 'list_shares', + { + description: 'List durable private share links for a file', + inputSchema: z.object({ file_id: z.string() }) + }, + async ({ file_id }) => + toolValue( + env, + Effect.gen(function* () { + const shares = yield* Shares; + yield* assertFileInScope(credential, file_id); + return { shares: yield* shares.list(file_id) }; + }) + ) + ); }; const registerWriteTools = (server: McpServer, input: McpServerInput) => { @@ -600,4 +621,50 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { return jsonResult(value); } ); + + server.registerTool( + 'create_share', + { + description: + 'Create a durable private link for a file: a revocable URL that works on the content origin, follows the current version, and can carry a password and expiry (default 7 days).', + inputSchema: z.object({ + file_id: z.string(), + password: z.string().nullable().optional(), + expires_in_days: z.number().nullable().optional(), + label: z.string().nullable().optional() + }) + }, + async ({ file_id, password, expires_in_days, label }) => + toolValue( + env, + Effect.gen(function* () { + const shares = yield* Shares; + yield* assertFileInScope(credential, file_id); + const created = yield* shares.create(file_id, { + password: password ?? null, + expiresInDays: expires_in_days, + label: label ?? null + }); + return { share: created.share, url: created.url }; + }) + ) + ); + + server.registerTool( + 'revoke_share', + { + description: 'Revoke a durable private link by share id', + inputSchema: z.object({ file_id: z.string(), share_id: z.string() }) + }, + async ({ file_id, share_id }) => + toolValue( + env, + Effect.gen(function* () { + const shares = yield* Shares; + yield* assertFileInScope(credential, file_id); + yield* shares.revoke(file_id, share_id); + return { ok: true as const, id: share_id }; + }) + ) + ); }; diff --git a/apps/web/src/lib/server/routes/shares.test.ts b/apps/web/src/lib/server/routes/shares.test.ts new file mode 100644 index 0000000..fdf501f --- /dev/null +++ b/apps/web/src/lib/server/routes/shares.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$app/server', async () => { + const { mockGetRequestEvent } = await import('../test/route-context.js'); + return mockGetRequestEvent(); +}); + +import { + call, + createRouteContext, + type RouteTestContext +} from '../test/route-context'; +import { login, uploadFile } from '../test/helpers'; + +const createShare = async ( + ctx: RouteTestContext, + fileId: string, + body: Record +) => { + const { POST } = await import( + '../../../routes/api/files/[id]/shares/+server.js' + ); + const response = await call( + POST, + ctx.event({ + method: 'POST', + path: `/api/files/${fileId}/shares`, + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + params: { id: fileId } + }) + ); + if (response.status !== 201) { + throw new Error(`Share create failed: ${response.status}`); + } + return (await response.json()) as { + share: { id: string }; + url: string; + }; +}; + +const serveShare = async ( + ctx: RouteTestContext, + fileId: string, + query: string +) => { + const { GET } = await import('../../../routes/f/[id]/+server.js'); + return call( + GET, + ctx.event({ path: `/f/${fileId}${query}`, params: { id: fileId } }) + ); +}; + +const tokenOf = (url: string) => new URL(url).searchParams.get('s') ?? ''; + +describe('durable private links (local platform)', () => { + let shared: RouteTestContext | undefined; + const setup = async () => (shared ??= await createRouteContext()); + + it('serves a token-only durable link and stops after revoke', async () => { + const ctx = await setup(); + await login(ctx); + const file = await uploadFile(ctx, { + name: 'durable-open.txt', + content: 'durable body', + isPublic: false + }); + const created = await createShare(ctx, file.id, { expiresInDays: 7 }); + const token = tokenOf(created.url); + expect(token).not.toBe(''); + + const served = await serveShare(ctx, file.id, `?s=${token}`); + expect(served.status).toBe(200); + expect(served.headers.get('cache-control')).toBe('private, no-store'); + expect(await served.text()).toBe('durable body'); + + const { DELETE } = await import( + '../../../routes/api/files/[id]/shares/[shareId]/+server.js' + ); + const revoked = await call( + DELETE, + ctx.event({ + method: 'DELETE', + path: `/api/files/${file.id}/shares/${created.share.id}`, + params: { id: file.id, shareId: created.share.id } + }) + ); + expect(revoked.status).toBe(204); + + await expect( + serveShare(ctx, file.id, `?s=${token}`) + ).rejects.toMatchObject({ status: 404 }); + }); + + it('gates a passworded durable link behind the correct password', async () => { + const ctx = await setup(); + await login(ctx); + const file = await uploadFile(ctx, { + name: 'durable-locked.txt', + content: 'locked body', + isPublic: false + }); + const created = await createShare(ctx, file.id, { + password: 'open-sesame', + expiresInDays: 7 + }); + const token = tokenOf(created.url); + + // No password: a prompt page, not the bytes. + const prompt = await serveShare(ctx, file.id, `?s=${token}`); + expect(prompt.status).toBe(200); + expect(prompt.headers.get('content-type')).toContain('text/html'); + expect(await prompt.text()).toContain('password'); + + // Wrong password: 401 prompt. + const wrong = await serveShare( + ctx, + file.id, + `?s=${token}&p=${encodeURIComponent('nope')}` + ); + expect(wrong.status).toBe(401); + + // Correct password: the file bytes. + const ok = await serveShare( + ctx, + file.id, + `?s=${token}&p=${encodeURIComponent('open-sesame')}` + ); + expect(ok.status).toBe(200); + expect(await ok.text()).toBe('locked body'); + }); +}); diff --git a/apps/web/src/lib/server/services/shares.ts b/apps/web/src/lib/server/services/shares.ts new file mode 100644 index 0000000..d18595d --- /dev/null +++ b/apps/web/src/lib/server/services/shares.ts @@ -0,0 +1,313 @@ +import { + SHARE_TOKEN_PATTERN, + type FileShare, + type FileShareCreate +} from '@adrive/shared'; +import { Context, Effect, Layer, Schema } from 'effect'; +import { AppConfig } from '../config'; +import { InvalidRequest, NotFound, StorageError } from '../errors'; +import { shouldTouchLastUsed } from '../auth-policy'; +import { + constantTimeEqualHex, + randomHex, + randomToken, + sha256Hex +} from '../token-crypto'; +import { Db } from './bindings'; + +// A share follows the current version and lasts a week unless the caller picks +// another lifetime; a personal operator can pass a different span or turn +// expiry off per share. +export const DEFAULT_SHARE_TTL_DAYS = 7; +const MAX_SHARE_TTL_DAYS = 3650; + +const ShareRow = Schema.Struct({ + id: Schema.String, + file_id: Schema.String, + label: Schema.NullOr(Schema.String), + password_hash: Schema.NullOr(Schema.String), + created_at: Schema.String, + expires_at: Schema.NullOr(Schema.String), + last_accessed_at: Schema.NullOr(Schema.String), + revoked_at: Schema.NullOr(Schema.String) +}); + +const ResolveRow = Schema.Struct({ + id: Schema.String, + file_id: Schema.String, + token_hash: Schema.String, + password_hash: Schema.NullOr(Schema.String), + last_accessed_at: Schema.NullOr(Schema.String) +}); + +const decodeRows = (schema: Schema.Codec, rows: unknown) => { + const decoded = Schema.decodeUnknownOption(Schema.Array(schema))(rows); + return decoded._tag === 'Some' ? decoded.value : []; +}; + +const toShare = (row: typeof ShareRow.Type): FileShare => ({ + id: row.id, + fileId: row.file_id, + label: row.label, + hasPassword: row.password_hash !== null, + createdAt: row.created_at, + expiresAt: row.expires_at, + lastAccessedAt: row.last_accessed_at, + revokedAt: row.revoked_at +}); + +export interface ResolvedShare { + readonly id: string; + readonly fileId: string; + readonly passwordHash: string | null; +} + +export interface SharesShape { + readonly create: ( + fileId: string, + input: FileShareCreate + ) => Effect.Effect< + { readonly share: FileShare; readonly token: string; readonly url: string }, + InvalidRequest | NotFound | StorageError + >; + readonly list: ( + fileId: string + ) => Effect.Effect, StorageError>; + readonly revoke: ( + fileId: string, + id: string + ) => Effect.Effect; + // Content-origin lookup: returns the live, unrevoked, unexpired share for a + // token or null. Never throws NotFound so the caller controls the response. + readonly resolve: ( + token: string + ) => Effect.Effect; + readonly checkPassword: ( + share: ResolvedShare, + supplied: string + ) => Effect.Effect; + readonly shareUrl: (fileId: string, token: string) => string; +} + +export class Shares extends Context.Service()( + 'app/Shares' +) {} + +const passwordHashFor = (shareId: string, password: string) => + sha256Hex(`${shareId}\n${password}`); + +const resolveExpiresAt = (input: FileShareCreate, now: Date) => { + if (input.expiresInDays === null) return null; + const days = input.expiresInDays ?? DEFAULT_SHARE_TTL_DAYS; + if (!Number.isFinite(days) || days <= 0 || days > MAX_SHARE_TTL_DAYS) { + throw new InvalidRequest({ + status: 400, + message: `Share lifetime must be between 1 and ${MAX_SHARE_TTL_DAYS} days` + }); + } + return new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); +}; + +const makeShares = Effect.gen(function* () { + const db = yield* Db; + const config = yield* AppConfig; + + const shareUrl = (fileId: string, token: string) => + `${config.contentOrigin}/f/${encodeURIComponent(fileId)}?s=${token}`; + + return Shares.of({ + shareUrl, + create: Effect.fn('Shares.create')(function* (fileId, input) { + const target = yield* Effect.tryPromise({ + try: () => + db + .prepare( + `SELECT is_site FROM files + WHERE id = ? AND deleted_at IS NULL LIMIT 1` + ) + .bind(fileId) + .first<{ is_site: number }>(), + catch: (cause) => + new StorageError({ operation: 'find file to share', cause }) + }); + if (!target) return yield* new NotFound({ id: fileId }); + if (target.is_site === 1) { + return yield* new InvalidRequest({ + status: 400, + message: 'Sites are already public at their /s/ URL' + }); + } + const now = new Date(); + const expiresAt = yield* Effect.try({ + try: () => resolveExpiresAt(input, now), + catch: (cause) => + cause instanceof InvalidRequest + ? cause + : new InvalidRequest({ + status: 400, + message: 'Share lifetime is invalid' + }) + }); + const id = crypto.randomUUID(); + const prefix = randomHex(4); + const secret = randomToken(); + const token = `${prefix}_${secret}`; + const tokenHash = yield* Effect.promise(() => sha256Hex(token)); + const password = + input.password === undefined || input.password === null + ? null + : input.password; + if (password !== null && password.length === 0) { + return yield* new InvalidRequest({ + status: 400, + message: 'Share password cannot be empty' + }); + } + const passwordHash = + password === null + ? null + : yield* Effect.promise(() => passwordHashFor(id, password)); + const label = + input.label === undefined || input.label === null + ? null + : input.label.trim().slice(0, 200) || null; + const createdAt = now.toISOString(); + yield* Effect.tryPromise({ + try: () => + db + .prepare( + `INSERT INTO file_shares ( + id, file_id, token_prefix, token_hash, password_hash, + label, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + id, + fileId, + prefix, + tokenHash, + passwordHash, + label, + createdAt, + expiresAt + ) + .run(), + catch: (cause) => + new StorageError({ operation: 'create file share', cause }) + }); + return { + share: { + id, + fileId, + label, + hasPassword: passwordHash !== null, + createdAt, + expiresAt, + lastAccessedAt: null, + revokedAt: null + }, + token, + url: shareUrl(fileId, token) + }; + }), + list: Effect.fn('Shares.list')(function* (fileId) { + const rows = yield* Effect.tryPromise({ + try: async () => { + const result = await db + .prepare( + `SELECT id, file_id, label, password_hash, created_at, + expires_at, last_accessed_at, revoked_at + FROM file_shares + WHERE file_id = ? + ORDER BY created_at DESC, id` + ) + .bind(fileId) + .all(); + if (!result.success) { + throw new Error(result.error ?? 'list file shares'); + } + return result.results; + }, + catch: (cause) => + new StorageError({ operation: 'list file shares', cause }) + }); + return decodeRows(ShareRow, rows).map(toShare); + }), + revoke: Effect.fn('Shares.revoke')(function* (fileId, id) { + const result = yield* Effect.tryPromise({ + try: () => + db + .prepare( + `UPDATE file_shares SET revoked_at = ? + WHERE id = ? AND file_id = ? AND revoked_at IS NULL` + ) + .bind(new Date().toISOString(), id, fileId) + .run(), + catch: (cause) => + new StorageError({ operation: 'revoke file share', cause }) + }); + if (result.meta.changes !== 1) { + return yield* new NotFound({ id }); + } + }), + resolve: Effect.fn('Shares.resolve')(function* (token) { + const match = SHARE_TOKEN_PATTERN.exec(token); + if (!match) return null; + const now = new Date(); + const nowIso = now.toISOString(); + const rows = yield* Effect.tryPromise({ + try: async () => { + const result = await db + .prepare( + `SELECT id, file_id, token_hash, password_hash, last_accessed_at + FROM file_shares + WHERE token_prefix = ? AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + LIMIT 1` + ) + .bind(match[1], nowIso) + .all(); + if (!result.success) { + throw new Error(result.error ?? 'resolve share'); + } + return result.results; + }, + catch: (cause) => + new StorageError({ operation: 'resolve share', cause }) + }); + const row = decodeRows(ResolveRow, rows)[0]; + if (!row) return null; + const actualHash = yield* Effect.promise(() => sha256Hex(token)); + if (!constantTimeEqualHex(actualHash, row.token_hash)) return null; + if (shouldTouchLastUsed(row.last_accessed_at, now)) { + yield* Effect.tryPromise({ + try: () => + db + .prepare( + `UPDATE file_shares SET last_accessed_at = ? WHERE id = ?` + ) + .bind(nowIso, row.id) + .run(), + catch: (cause) => + new StorageError({ operation: 'touch share access', cause }) + }); + } + return { + id: row.id, + fileId: row.file_id, + passwordHash: row.password_hash + }; + }), + checkPassword: (share, supplied) => + Effect.gen(function* () { + if (share.passwordHash === null) return true; + if (supplied.length === 0) return false; + const hash = yield* Effect.promise(() => + passwordHashFor(share.id, supplied) + ); + return constantTimeEqualHex(hash, share.passwordHash); + }) + }); +}); + +export const SharesLive = Layer.effect(Shares, makeShares); diff --git a/apps/web/src/lib/server/share-password-page.ts b/apps/web/src/lib/server/share-password-page.ts new file mode 100644 index 0000000..694bad3 --- /dev/null +++ b/apps/web/src/lib/server/share-password-page.ts @@ -0,0 +1,60 @@ +// A tiny, script-free password prompt for a passworded durable share. Served +// from the cookie-less content origin: the form re-GETs the same URL with the +// entered password, so no dashboard session cookie is ever involved. Uses a +// locked-down CSP and never caches. + +const escapeHtml = (value: string) => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +export const sharePasswordPage = (url: URL, attempted: boolean) => { + const token = url.searchParams.get('s') ?? ''; + const action = escapeHtml(url.pathname); + const error = attempted + ? '

That password did not match. Try again.

' + : ''; + const body = ` + + + + +Password required + + + +
+

This file is password protected

+
+ + + + +${error} +
+
+ +`; + return new Response(body, { + status: attempted ? 401 : 200, + headers: { + 'Cache-Control': 'private, no-store', + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff' + } + }); +}; diff --git a/apps/web/src/lib/server/token-crypto.ts b/apps/web/src/lib/server/token-crypto.ts new file mode 100644 index 0000000..1d49b2e --- /dev/null +++ b/apps/web/src/lib/server/token-crypto.ts @@ -0,0 +1,48 @@ +// Small shared crypto helpers for opaque secret tokens (share links, and +// available to other stored-secret features). Mirrors the inline helpers in +// services/auth.ts: a URL-safe random secret with a short lookup prefix, plus +// SHA-256 hashing and a constant-time hex compare. + +export const randomToken = (bytes = 32) => { + const value = new Uint8Array(bytes); + crypto.getRandomValues(value); + return btoa(String.fromCharCode(...value)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); +}; + +export const randomHex = (bytes: number) => { + const value = new Uint8Array(bytes); + crypto.getRandomValues(value); + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); +}; + +const toHex = (buffer: ArrayBuffer) => + Array.from(new Uint8Array(buffer), (byte) => + byte.toString(16).padStart(2, '0') + ).join(''); + +export const sha256Hex = (value: string) => + crypto.subtle + .digest('SHA-256', new TextEncoder().encode(value)) + .then(toHex); + +const hexBytes = (value: string) => { + const normalized = /^[0-9a-f]{64}$/i.test(value) ? value : '0'.repeat(64); + return Uint8Array.from({ length: 32 }, (_, index) => + Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16) + ); +}; + +export const constantTimeEqualHex = (left: string, right: string) => { + const a = hexBytes(left); + const b = hexBytes(right); + let difference = a.length ^ b.length; + for (let index = 0; index < a.length; index += 1) { + difference |= a[index]! ^ (b[index] ?? 0); + } + return difference === 0; +}; diff --git a/apps/web/src/routes/api/files/[id]/shares/+server.ts b/apps/web/src/routes/api/files/[id]/shares/+server.ts new file mode 100644 index 0000000..3469a12 --- /dev/null +++ b/apps/web/src/routes/api/files/[id]/shares/+server.ts @@ -0,0 +1,60 @@ +import { FileShareCreateSchema, type FileShareCreate } from '@adrive/shared'; +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { AppConfig } from '$lib/server/config'; +import { runEdge } from '$lib/server/edge'; +import { decodeJson } from '$lib/server/request-json'; +import { + Auth, + authorizeRequest, + authorizeWriteRequest +} from '$lib/server/services/auth'; +import { assertFileInScope } from '$lib/server/token-scope'; +import { Shares } from '$lib/server/services/shares'; + +const readOptions = (request: Request) => + request.body === null || request.headers.get('content-length') === '0' + ? Effect.succeed({} satisfies FileShareCreate) + : decodeJson(request, FileShareCreateSchema, 'Share options are invalid'); + +export const GET: RequestHandler = ({ cookies, params, request, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const shares = yield* Shares; + const config = yield* AppConfig; + const credential = yield* authorizeRequest(auth, request, url, cookies); + yield* assertFileInScope(credential, params.id); + return Response.json( + { + shares: yield* shares.list(params.id), + contentOrigin: config.contentOrigin + }, + { headers: { 'Cache-Control': 'private, no-store' } } + ); + }) + ); + +export const POST: RequestHandler = ({ cookies, params, request, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const shares = yield* Shares; + const credential = yield* authorizeWriteRequest( + auth, + request, + url, + cookies + ); + yield* assertFileInScope(credential, params.id); + const input = yield* readOptions(request); + const created = yield* shares.create(params.id, input); + return Response.json( + { share: created.share, url: created.url }, + { + status: 201, + headers: { 'Cache-Control': 'private, no-store' } + } + ); + }) + ); diff --git a/apps/web/src/routes/api/files/[id]/shares/[shareId]/+server.ts b/apps/web/src/routes/api/files/[id]/shares/[shareId]/+server.ts new file mode 100644 index 0000000..c180dc3 --- /dev/null +++ b/apps/web/src/routes/api/files/[id]/shares/[shareId]/+server.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdge } from '$lib/server/edge'; +import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { assertFileInScope } from '$lib/server/token-scope'; +import { Shares } from '$lib/server/services/shares'; + +export const DELETE: RequestHandler = ({ cookies, params, request, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const shares = yield* Shares; + const credential = yield* authorizeWriteRequest( + auth, + request, + url, + cookies + ); + yield* assertFileInScope(credential, params.id); + yield* shares.revoke(params.id, params.shareId); + return new Response(null, { status: 204 }); + }) + ); diff --git a/apps/web/src/routes/f/[id]/+server.ts b/apps/web/src/routes/f/[id]/+server.ts index 6a41853..a625b01 100644 --- a/apps/web/src/routes/f/[id]/+server.ts +++ b/apps/web/src/routes/f/[id]/+server.ts @@ -21,7 +21,10 @@ import { runEdge } from '$lib/server/edge'; import { NotFound, StorageError } from '$lib/server/errors'; import { Blobs } from '$lib/server/services/blobs'; import { Files } from '$lib/server/services/files'; +import type { FileContent } from '$lib/server/services/files/types'; import { GrantSecrets } from '$lib/server/services/grant-secrets'; +import { Shares } from '$lib/server/services/shares'; +import { sharePasswordPage } from '$lib/server/share-password-page'; const requestedVersion = (url: URL) => { const value = url.searchParams.get('v'); @@ -35,42 +38,76 @@ const serveFile: RequestHandler = ({ params, platform, request, url }) => Effect.gen(function* () { const config = yield* AppConfig; const files = yield* Files; - const grantSecrets = yield* GrantSecrets; - const version = requestedVersion(url); - if (version === null) return yield* new NotFound({ id: params.id }); - const hasGrant = url.searchParams.has('e') && url.searchParams.has('g'); - const thumbnailSource = url.searchParams.get('purpose') === 'thumbnail'; - if (thumbnailSource && !hasGrant) { - return yield* new NotFound({ id: params.id }); - } - const content = yield* files.findContent(params.id, version, hasGrant); - const privateResponse = hasGrant || !content.file.public; - const dashboardPreview = - (content.file.contentType === 'application/pdf' || - content.file.contentType.startsWith('text/html')) && - url.searchParams.get('preview') === 'dashboard'; + + // Resolve the content to serve and whether the response is private. + // A durable share token (`?s=`) is a self-contained, revocable link + // that follows the file's current version; otherwise fall back to the + // public/versioned/HMAC-grant path used by dashboards and previews. + const shareToken = url.searchParams.get('s'); + let content: FileContent; + let privateResponse: boolean; + let pinnedVersion: boolean; let verifiedThumbnailSource = false; - if (!content.file.public || hasGrant) { - const expiresAtSeconds = Number(url.searchParams.get('e')); - const signature = url.searchParams.get('g') ?? ''; - const granted = yield* grantSecrets.verify({ - contentOrigin: config.contentOrigin, - requestOrigin: url.origin, - fileId: params.id, - version: content.file.version, - expiresAtSeconds, - signature, - purpose: thumbnailSource ? 'thumbnail-source' : undefined - }); - if (!granted) return yield* new NotFound({ id: params.id }); - verifiedThumbnailSource = thumbnailSource; + let dashboardPreview = false; + + if (shareToken !== null) { + const shares = yield* Shares; + const share = yield* shares.resolve(shareToken); + if (!share || share.fileId !== params.id) { + return yield* new NotFound({ id: params.id }); + } + if (share.passwordHash !== null) { + const supplied = url.searchParams.get('p'); + const unlocked = + supplied !== null && + (yield* shares.checkPassword(share, supplied)); + if (!unlocked) { + return sharePasswordPage(url, supplied !== null); + } + } + content = yield* files.findContent(params.id); + privateResponse = true; + pinnedVersion = false; + } else { + const grantSecrets = yield* GrantSecrets; + const version = requestedVersion(url); + if (version === null) return yield* new NotFound({ id: params.id }); + const hasGrant = + url.searchParams.has('e') && url.searchParams.has('g'); + const thumbnailSource = + url.searchParams.get('purpose') === 'thumbnail'; + if (thumbnailSource && !hasGrant) { + return yield* new NotFound({ id: params.id }); + } + content = yield* files.findContent(params.id, version, hasGrant); + privateResponse = hasGrant || !content.file.public; + dashboardPreview = + (content.file.contentType === 'application/pdf' || + content.file.contentType.startsWith('text/html')) && + url.searchParams.get('preview') === 'dashboard'; + if (!content.file.public || hasGrant) { + const expiresAtSeconds = Number(url.searchParams.get('e')); + const signature = url.searchParams.get('g') ?? ''; + const granted = yield* grantSecrets.verify({ + contentOrigin: config.contentOrigin, + requestOrigin: url.origin, + fileId: params.id, + version: content.file.version, + expiresAtSeconds, + signature, + purpose: thumbnailSource ? 'thumbnail-source' : undefined + }); + if (!granted) return yield* new NotFound({ id: params.id }); + verifiedThumbnailSource = thumbnailSource; + } + pinnedVersion = version !== undefined; } + const blobs = yield* Blobs; const range = request.method === 'HEAD' ? null : yield* decodeRangeHeader(request.headers.get('range')); - const pinnedVersion = version !== undefined; const cacheControl = fileCacheControl(privateResponse, pinnedVersion); const ifNoneMatch = request.headers.get('if-none-match'); const cacheRequest = fileContentCacheRequest(url); diff --git a/packages/cli/src/commands/shares.ts b/packages/cli/src/commands/shares.ts new file mode 100644 index 0000000..3f1e023 --- /dev/null +++ b/packages/cli/src/commands/shares.ts @@ -0,0 +1,133 @@ +import { + FileShareCreateResponseSchema, + FileShareListResponseSchema +} from '@adrive/shared'; +import { Console, Effect, Option } from 'effect'; +import { Argument, Command, Flag } from 'effect/unstable/cli'; +import { HttpBody, HttpClient } from 'effect/unstable/http'; +import { loadConfig } from '../config.ts'; +import { CliFailure } from '../errors.ts'; +import { apiRequest, decodeBody, ensureOk } from '../http.ts'; +import { emit, wantsJson } from '../output.ts'; + +export const shareCreate = Command.make( + 'create', + { + fileId: Argument.string('file-id'), + password: Flag.string('password').pipe( + Flag.optional, + Flag.withDescription('Require this password to view the link') + ), + expiresDays: Flag.string('expires-days').pipe( + Flag.optional, + Flag.withDescription('Lifetime in days (default 7)') + ), + noExpiry: Flag.boolean('no-expiry').pipe( + Flag.withDescription('Never expire this link') + ), + label: Flag.string('label').pipe(Flag.optional) + }, + ({ fileId, password, expiresDays, noExpiry, label }) => + Effect.gen(function* () { + const config = yield* loadConfig; + const client = yield* HttpClient.HttpClient; + let expiresInDays: number | null | undefined; + if (noExpiry) { + expiresInDays = null; + } else if (Option.isSome(expiresDays)) { + const parsed = Number(expiresDays.value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return yield* new CliFailure({ + message: '--expires-days must be a positive number' + }); + } + expiresInDays = parsed; + } + const body: Record = {}; + if (Option.isSome(password)) body.password = password.value; + if (expiresInDays !== undefined) body.expiresInDays = expiresInDays; + if (Option.isSome(label)) body.label = label.value; + const response = yield* client + .execute( + apiRequest( + 'POST', + `${config.endpoint}/api/files/${encodeURIComponent(fileId)}/shares`, + config.apiKey, + { body: HttpBody.jsonUnsafe(body) } + ) + ) + .pipe(Effect.flatMap(ensureOk)); + const result = yield* decodeBody( + FileShareCreateResponseSchema, + response + ); + if (wantsJson()) { + yield* emit(result); + } else { + yield* Console.log(result.url); + yield* Console.log( + `${result.share.id}${result.share.hasPassword ? ' · password' : ''}${result.share.expiresAt ? ` · expires ${result.share.expiresAt}` : ' · no expiry'}` + ); + } + }) +).pipe( + Command.withDescription('Create a durable private link for a file') +); + +export const shareList = Command.make( + 'list', + { fileId: Argument.string('file-id') }, + ({ fileId }) => + Effect.gen(function* () { + const config = yield* loadConfig; + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + apiRequest( + 'GET', + `${config.endpoint}/api/files/${encodeURIComponent(fileId)}/shares`, + config.apiKey + ) + ) + .pipe(Effect.flatMap(ensureOk)); + const result = yield* decodeBody(FileShareListResponseSchema, response); + if (wantsJson()) { + yield* emit(result); + } else { + for (const share of result.shares) { + yield* Console.log( + `${share.id}\t${share.hasPassword ? 'password' : 'open'}\t${share.expiresAt ?? 'no-expiry'}${share.revokedAt ? '\trevoked' : ''}` + ); + } + } + }) +).pipe(Command.withDescription('List durable private links for a file')); + +export const shareRevoke = Command.make( + 'revoke', + { fileId: Argument.string('file-id'), shareId: Argument.string('share-id') }, + ({ fileId, shareId }) => + Effect.gen(function* () { + const config = yield* loadConfig; + const client = yield* HttpClient.HttpClient; + yield* client + .execute( + apiRequest( + 'DELETE', + `${config.endpoint}/api/files/${encodeURIComponent(fileId)}/shares/${encodeURIComponent(shareId)}`, + config.apiKey + ) + ) + .pipe(Effect.flatMap(ensureOk)); + yield* emit( + wantsJson() + ? { id: shareId, status: 'revoked' } + : `Revoked share ${shareId}` + ); + }) +).pipe(Command.withDescription('Revoke a durable private link')); + +export const share = Command.make('share').pipe( + Command.withDescription('Manage durable private links'), + Command.withSubcommands([shareCreate, shareList, shareRevoke]) +); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 258374d..0af89eb 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -6,6 +6,7 @@ import { Command, Flag } from 'effect/unstable/cli'; import { login, whoami } from './commands/auth.ts'; import { get, list, put, rename, status } from './commands/files.ts'; import { keys } from './commands/keys.ts'; +import { share } from './commands/shares.ts'; import { site } from './commands/sites.ts'; import { tag } from './commands/tags.ts'; import { upgrade } from './commands/upgrade.ts'; @@ -27,6 +28,7 @@ const root = Command.make('adrive', { get, rename, keys, + share, site, tag, upgrade diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5189224..a3a6827 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -300,6 +300,48 @@ export const SiteCommitResponseSchema = Schema.Struct({ export type SiteCommitResponse = typeof SiteCommitResponseSchema.Type; +// Durable private links (shares). A share follows the file's current version, +// works on the cookie-less content origin, is revocable, and can carry an +// optional password and expiry. +export const FileShareCreateSchema = Schema.Struct({ + // Optional viewer password. Omit or null for a token-only link. + password: Schema.optional(Schema.NullOr(Schema.String)), + // Lifetime in days. Omit for the default (7 days); pass null for no expiry. + expiresInDays: Schema.optional(Schema.NullOr(Schema.Number)), + label: Schema.optional(Schema.NullOr(Schema.String)) +}); + +export type FileShareCreate = typeof FileShareCreateSchema.Type; + +export const FileShareSchema = Schema.Struct({ + id: Schema.String, + fileId: Schema.String, + label: Schema.NullOr(Schema.String), + hasPassword: Schema.Boolean, + createdAt: Schema.String, + expiresAt: Schema.NullOr(Schema.String), + lastAccessedAt: Schema.NullOr(Schema.String), + revokedAt: Schema.NullOr(Schema.String) +}); + +export type FileShare = typeof FileShareSchema.Type; + +export const FileShareListResponseSchema = Schema.Struct({ + shares: Schema.Array(FileShareSchema), + contentOrigin: Schema.String +}); + +export type FileShareListResponse = typeof FileShareListResponseSchema.Type; + +// The `url` carries the one-time secret token and is never returned again. +export const FileShareCreateResponseSchema = Schema.Struct({ + share: FileShareSchema, + url: Schema.String +}); + +export type FileShareCreateResponse = + typeof FileShareCreateResponseSchema.Type; + export const AuthCheckResponseSchema = Schema.Struct({ ok: Schema.Literal(true) }); @@ -385,3 +427,6 @@ export const ErrorResponseSchema = Schema.Struct({ }); export const API_KEY_PATTERN = /^adr_([A-Za-z0-9]{8})_([A-Za-z0-9_-]{24,})$/; + +// Durable share tokens carried in the `?s=` query param of a content link. +export const SHARE_TOKEN_PATTERN = /^([A-Za-z0-9]{8})_([A-Za-z0-9_-]{24,})$/; From ce011c0c2298d7e8dd1196c64c9df5b3dc23059c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 09:05:06 +0000 Subject: [PATCH 3/6] feat(sites): publish existing drive files as a site Add POST /api/sites/publish and Sites.publishFromFiles: select files by ID and/or tag and turn them into a live /s// site by copying their current bytes server-side (no client re-upload). When no index.html is selected, a gallery/listing index is generated; an existing index.html is used as-is. Reuses the site session/commit state machine, serving, grants, and cache. Wired into the dashboard bulk bar, MCP (publish_files_site), and the CLI (adrive site publish). Co-authored-by: Ben Davis --- apps/web/src/lib/components/Dashboard.svelte | 66 ++++++- .../components/dashboard/BulkActionBar.svelte | 5 + apps/web/src/lib/dashboard/api.ts | 11 ++ apps/web/src/lib/dashboard/parse.ts | 13 ++ apps/web/src/lib/server/mcp/server.ts | 40 ++++ .../lib/server/routes/publish-files.test.ts | 111 +++++++++++ apps/web/src/lib/server/services/sites.ts | 7 +- .../src/lib/server/services/sites/publish.ts | 187 ++++++++++++++++++ .../src/lib/server/services/sites/types.ts | 12 +- apps/web/src/lib/server/site-gallery.ts | 128 ++++++++++++ .../src/routes/api/sites/publish/+server.ts | 57 ++++++ packages/cli/src/commands/sites.ts | 74 ++++++- packages/shared/src/index.ts | 13 ++ 13 files changed, 719 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/server/routes/publish-files.test.ts create mode 100644 apps/web/src/lib/server/services/sites/publish.ts create mode 100644 apps/web/src/lib/server/site-gallery.ts create mode 100644 apps/web/src/routes/api/sites/publish/+server.ts diff --git a/apps/web/src/lib/components/Dashboard.svelte b/apps/web/src/lib/components/Dashboard.svelte index 59a2ed0..7e0d99e 100644 --- a/apps/web/src/lib/components/Dashboard.svelte +++ b/apps/web/src/lib/components/Dashboard.svelte @@ -2,7 +2,7 @@ import type { DashboardFile } from '@adrive/shared'; import { browser } from '$app/environment'; import { page } from '$app/state'; - import type { FileListPayload } from '$lib/dashboard/api'; + import { publishSite, type FileListPayload } from '$lib/dashboard/api'; import { createFileList, resolveFileLink @@ -20,7 +20,9 @@ import DashboardHeader from './dashboard/DashboardHeader.svelte'; import FileListing from './dashboard/FileListing.svelte'; import SearchFilterBar from './dashboard/SearchFilterBar.svelte'; + import Button from './ui/Button.svelte'; import Confirm from './ui/Confirm.svelte'; + import Modal from './ui/Modal.svelte'; import TagManager from './tags/TagManager.svelte'; import DropOverlay from './upload/DropOverlay.svelte'; import UploadDialog from './upload/UploadDialog.svelte'; @@ -188,6 +190,39 @@ if (!tag) return; await selection.addSelectedTag(tag); }; + + let publishOpen = $state(false); + let publishName = $state(''); + let publishBusy = $state(false); + + const openPublish = () => { + if (selection.selectedFiles.length === 0) return; + publishName = ''; + publishOpen = true; + }; + + const publishSelectedSite = async () => { + const fileIds = selection.selectedFiles + .filter((file) => file.kind === 'file') + .map((file) => file.id); + if (fileIds.length === 0 || publishBusy) return; + publishBusy = true; + try { + const result = await publishSite(session.token, { + fileIds, + displayName: publishName.trim() || 'Published files' + }); + toasts.success(`Published ${result.assetCount} files as a site`); + publishOpen = false; + selection.clear(); + await files.list.refetch(); + window.open(result.url, '_blank', 'noopener'); + } catch (cause) { + toasts.error(cause, 'Could not publish the site'); + } finally { + publishBusy = false; + } + }; void selection.mutateSelected(label, mutation)} onbulkpurge={() => (trash.bulkPurgeOpen = true)} + onpublishsite={openPublish} onclear={selection.clear} /> @@ -376,4 +412,32 @@ busy={trash.purging} onconfirm={trash.purgeAllTrash} /> + +
{ + event.preventDefault(); + void publishSelectedSite(); + }} + > + +
+ + +
+
+
{/if} diff --git a/apps/web/src/lib/components/dashboard/BulkActionBar.svelte b/apps/web/src/lib/components/dashboard/BulkActionBar.svelte index 4e00d44..56adb44 100644 --- a/apps/web/src/lib/components/dashboard/BulkActionBar.svelte +++ b/apps/web/src/lib/components/dashboard/BulkActionBar.svelte @@ -11,6 +11,7 @@ onbulktag, onmutate, onbulkpurge, + onpublishsite, onclear }: { selectedCount: number; @@ -21,6 +22,7 @@ onbulktag: (tagId: string) => void; onmutate: (label: string, mutation: FileMutation) => void; onbulkpurge: () => void; + onpublishsite: () => void; onclear: () => void; } = $props(); @@ -72,6 +74,9 @@ > Private +

- The limit is {formatBytes(maxUploadBytes)} per file. Choose smaller files - or reduce their size. + The limit is {formatBytes(maxStagedUploadBytes)} per file. Choose smaller + files or reduce their size.

    {#each rejected.slice(0, 3) as file (file)} diff --git a/apps/web/src/lib/dashboard/api.ts b/apps/web/src/lib/dashboard/api.ts index 705d1d5..6552766 100644 --- a/apps/web/src/lib/dashboard/api.ts +++ b/apps/web/src/lib/dashboard/api.ts @@ -399,6 +399,67 @@ export const uploadFile = async ( return parseUploadResponse(body); }; +// Staged/resumable upload for files above the one-shot cap. Opens a session, +// PUTs each slice of the File (no full buffering), then finalizes. Progress is +// reported per completed part; a failure or cancel aborts the session so no +// partial upload lingers server-side. +export const uploadFileStaged = async ( + token: string, + file: File, + isPublic: boolean, + tagNames: ReadonlyArray = [], + expiresAt: string | null = null, + options: UploadOptions = {} +) => { + const created = await request('/api/uploads', token, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: file.name, + sizeBytes: file.size, + contentType: file.type || 'application/octet-stream', + public: isPublic, + tags: tagNames, + expiresAt + }), + signal: options.signal + }); + const session = (await created.json()) as { + sessionId: string; + partSize: number; + partCount: number; + }; + try { + for (let partNumber = 1; partNumber <= session.partCount; partNumber += 1) { + const start = (partNumber - 1) * session.partSize; + const end = Math.min(start + session.partSize, file.size); + await request( + `/api/uploads/${encodeURIComponent(session.sessionId)}/parts/${partNumber}`, + token, + { method: 'PUT', body: file.slice(start, end), signal: options.signal } + ); + options.onProgress?.(end, file.size); + } + } catch (cause) { + try { + await request( + `/api/uploads/${encodeURIComponent(session.sessionId)}`, + token, + { method: 'DELETE' } + ); + } catch { + // Best-effort cleanup; the scheduled sweep aborts stale sessions. + } + throw cause; + } + const finished = await request( + `/api/uploads/${encodeURIComponent(session.sessionId)}/complete`, + token, + { method: 'POST', signal: options.signal } + ); + return json(parseUploadResponse, finished); +}; + export const uploadVersion = async (token: string, id: string, file: File) => { const response = await request( `/api/files/${encodeURIComponent(id)}/versions`, diff --git a/apps/web/src/lib/dashboard/drag-upload.svelte.ts b/apps/web/src/lib/dashboard/drag-upload.svelte.ts index b83df00..8a2b850 100644 --- a/apps/web/src/lib/dashboard/drag-upload.svelte.ts +++ b/apps/web/src/lib/dashboard/drag-upload.svelte.ts @@ -10,6 +10,7 @@ type DragUploadDeps = { readonly toasts: Toasts; readonly uploads: UploadManager; readonly maxUploadBytes: () => number; + readonly maxStagedUploadBytes: () => number; readonly uploadOpen: () => boolean; readonly closeUpload: () => void; readonly trashed: () => boolean; @@ -20,6 +21,7 @@ export const createDragUpload = ({ toasts, uploads, maxUploadBytes, + maxStagedUploadBytes, uploadOpen, closeUpload, trashed @@ -59,7 +61,7 @@ export const createDragUpload = ({ } const { accepted, rejected } = partitionUploadFiles( selected, - maxUploadBytes() + maxStagedUploadBytes() ); if (rejected.length > 0) { toasts.error( @@ -73,7 +75,8 @@ export const createDragUpload = ({ token: session.token, public: true, tags: [], - expiresAt: null + expiresAt: null, + maxUploadBytes: maxUploadBytes() }); }; const onDragEnter = (event: DragEvent) => { diff --git a/apps/web/src/lib/dashboard/file-list.svelte.test.ts b/apps/web/src/lib/dashboard/file-list.svelte.test.ts index 8506c2e..3a85618 100644 --- a/apps/web/src/lib/dashboard/file-list.svelte.test.ts +++ b/apps/web/src/lib/dashboard/file-list.svelte.test.ts @@ -38,6 +38,7 @@ const listing = (files: ReadonlyArray): FileListPayload => ({ tags: [], contentOrigin: 'https://files.example', maxUploadBytes: 1_024, + maxStagedUploadBytes: 524_288_000, semantic: { enabled: false, indexedChunks: 0, diff --git a/apps/web/src/lib/dashboard/file-list.svelte.ts b/apps/web/src/lib/dashboard/file-list.svelte.ts index c7125cb..d015d29 100644 --- a/apps/web/src/lib/dashboard/file-list.svelte.ts +++ b/apps/web/src/lib/dashboard/file-list.svelte.ts @@ -25,6 +25,7 @@ const emptyList = { tags: [] as ReadonlyArray, contentOrigin: '', maxUploadBytes: 0, + maxStagedUploadBytes: 0, semantic: { enabled: false, indexedChunks: 0, @@ -139,7 +140,9 @@ export const createFileList = ({ tags: list.current.tags, semantic: list.current.semantic, contentOrigin: list.current.contentOrigin || next.contentOrigin, - maxUploadBytes: list.current.maxUploadBytes || next.maxUploadBytes + maxUploadBytes: list.current.maxUploadBytes || next.maxUploadBytes, + maxStagedUploadBytes: + list.current.maxStagedUploadBytes || next.maxStagedUploadBytes }); } catch (cause) { toasts.error(cause, 'Could not load more files'); diff --git a/apps/web/src/lib/dashboard/parse.test.ts b/apps/web/src/lib/dashboard/parse.test.ts index ea2fb16..43a9303 100644 --- a/apps/web/src/lib/dashboard/parse.test.ts +++ b/apps/web/src/lib/dashboard/parse.test.ts @@ -49,6 +49,7 @@ const listResponse = { tags: [tag], contentOrigin: 'https://files.example', maxUploadBytes: 99_614_720, + maxStagedUploadBytes: 524_288_000, semantic: { enabled: false, indexedChunks: 0, diff --git a/apps/web/src/lib/dashboard/parse.ts b/apps/web/src/lib/dashboard/parse.ts index f4c9b48..5dd5795 100644 --- a/apps/web/src/lib/dashboard/parse.ts +++ b/apps/web/src/lib/dashboard/parse.ts @@ -173,6 +173,10 @@ export const parseFileListResponse = (value: unknown): FileListResponse => { tags: list(record.tags, parseTag, 'files.tags'), contentOrigin: text(record.contentOrigin, 'files.contentOrigin'), maxUploadBytes: integer(record.maxUploadBytes, 'files.maxUploadBytes'), + maxStagedUploadBytes: integer( + record.maxStagedUploadBytes, + 'files.maxStagedUploadBytes' + ), semantic: parseSemanticStatus(record.semantic, 'files.semantic') }; }; diff --git a/apps/web/src/lib/dashboard/uploads.svelte.test.ts b/apps/web/src/lib/dashboard/uploads.svelte.test.ts index 13f34c6..a965fb4 100644 --- a/apps/web/src/lib/dashboard/uploads.svelte.test.ts +++ b/apps/web/src/lib/dashboard/uploads.svelte.test.ts @@ -9,7 +9,8 @@ const { uploadFileMock } = vi.hoisted(() => ({ })); vi.mock('./api', () => ({ - uploadFile: uploadFileMock + uploadFile: uploadFileMock, + uploadFileStaged: vi.fn() })); const uploadResponse = { @@ -38,7 +39,8 @@ const defaults = { token: 'secret-token', public: true, tags: [], - expiresAt: null + expiresAt: null, + maxUploadBytes: 100_000_000 }; const file = (name = 'report.txt', contents = 'report') => diff --git a/apps/web/src/lib/dashboard/uploads.svelte.ts b/apps/web/src/lib/dashboard/uploads.svelte.ts index e57c5e1..dae24c9 100644 --- a/apps/web/src/lib/dashboard/uploads.svelte.ts +++ b/apps/web/src/lib/dashboard/uploads.svelte.ts @@ -1,5 +1,5 @@ import type { Tag } from '@adrive/shared'; -import { uploadFile } from './api'; +import { uploadFile, uploadFileStaged } from './api'; export type UploadItem = { readonly id: string; @@ -16,6 +16,9 @@ type UploadDefaults = { readonly public: boolean; readonly tags: ReadonlyArray; readonly expiresAt: string | null; + // Files larger than this one-shot cap upload through the staged multipart + // flow instead of a single PUT. + readonly maxUploadBytes: number; }; type StoredUploadDefaults = Omit & { @@ -80,7 +83,8 @@ export class UploadManager { token: defaults.token, public: defaults.public, tagNames: defaults.tags.map((tag) => tag.name), - expiresAt: defaults.expiresAt + expiresAt: defaults.expiresAt, + maxUploadBytes: defaults.maxUploadBytes }); return item; }); @@ -188,7 +192,9 @@ export class UploadManager { const controller = new AbortController(); this.#controllers.set(id, controller); try { - const result = await uploadFile( + const upload = + file.size > defaults.maxUploadBytes ? uploadFileStaged : uploadFile; + const result = await upload( defaults.token, file, defaults.public, diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index a5c6c12..4633e89 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -5,6 +5,7 @@ export interface AppConfigShape { readonly dashboardOrigin: string; readonly contentOrigin: string; readonly maxUploadBytes: number; + readonly maxStagedUploadBytes: number; readonly maxTotalBytes: number; readonly passcode: string; readonly semanticSearch: 'off' | 'auto' | 'required'; @@ -37,6 +38,24 @@ export const configFromEnv = (env: Env) => { if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes <= 0) { throw new Error('MAX_UPLOAD_BYTES must be a positive safe integer'); } + // Per-file ceiling for the staged/resumable multipart flow. Defaults to + // 500 MiB and must be at least the one-shot cap so staged uploads are + // never smaller than a single PUT. + const rawMaxStagedUploadBytes = env.MAX_STAGED_UPLOAD_BYTES as + | string + | undefined; + const maxStagedUploadBytes = + rawMaxStagedUploadBytes === undefined || rawMaxStagedUploadBytes === '' + ? 500 * 1024 * 1024 + : Number(rawMaxStagedUploadBytes); + if ( + !Number.isSafeInteger(maxStagedUploadBytes) || + maxStagedUploadBytes < maxUploadBytes + ) { + throw new Error( + 'MAX_STAGED_UPLOAD_BYTES must be a safe integer at least MAX_UPLOAD_BYTES' + ); + } // Global cap on stored bytes across all live file versions. Defaults to // 100 GiB when unset so a leaked credential cannot fill the bucket. const rawMaxTotalBytes = env.MAX_TOTAL_BYTES as string | undefined; @@ -63,6 +82,7 @@ export const configFromEnv = (env: Env) => { return { ...origins, maxUploadBytes, + maxStagedUploadBytes, maxTotalBytes, passcode: env.PASSCODE, semanticSearch, diff --git a/apps/web/src/lib/server/edge.ts b/apps/web/src/lib/server/edge.ts index 7831013..01b90ee 100644 --- a/apps/web/src/lib/server/edge.ts +++ b/apps/web/src/lib/server/edge.ts @@ -30,6 +30,7 @@ import type { Indexing } from './services/indexing'; import type { Lifecycle } from './services/lifecycle'; import type { GrantSecrets } from './services/grant-secrets'; import type { Shares } from './services/shares'; +import type { Uploads } from './services/uploads'; export type AppServices = | SqlClient.SqlClient @@ -46,7 +47,8 @@ export type AppServices = | Indexing | Lifecycle | GrantSecrets - | Shares; + | Shares + | Uploads; export const isAppError = (failure: unknown): failure is AppError => failure instanceof InvalidRequest || diff --git a/apps/web/src/lib/server/layer.ts b/apps/web/src/lib/server/layer.ts index c91f6de..775c47c 100644 --- a/apps/web/src/lib/server/layer.ts +++ b/apps/web/src/lib/server/layer.ts @@ -14,6 +14,7 @@ import { IndexingLive } from './services/indexing'; import { LifecycleLive } from './services/lifecycle'; import { GrantSecretsLive } from './services/grant-secrets'; import { SharesLive } from './services/shares'; +import { UploadsLive } from './services/uploads'; const SqlLive = Layer.unwrap(Effect.map(Db, (db) => D1.layer({ db }))); @@ -37,6 +38,9 @@ export const requestLayer = (env: Env) => { Layer.provide(Layer.merge(infrastructure, semantic)) ); const sites = SitesLive.pipe(Layer.provide(infrastructure)); + const uploads = UploadsLive.pipe( + Layer.provide(Layer.mergeAll(infrastructure, tags)) + ); const files = FilesLive.pipe( Layer.provide(Layer.mergeAll(infrastructure, tags)) ); @@ -44,7 +48,9 @@ export const requestLayer = (env: Env) => { Layer.provide(Layer.mergeAll(infrastructure, semantic)) ); const lifecycle = LifecycleLive.pipe( - Layer.provide(Layer.mergeAll(infrastructure, auth, sites, files, indexing)) + Layer.provide( + Layer.mergeAll(infrastructure, auth, sites, files, indexing, uploads) + ) ); return Layer.mergeAll( @@ -57,6 +63,7 @@ export const requestLayer = (env: Env) => { tags, search, sites, + uploads, files, indexing, lifecycle diff --git a/apps/web/src/lib/server/mcp/server.test.ts b/apps/web/src/lib/server/mcp/server.test.ts index 722218f..e398421 100644 --- a/apps/web/src/lib/server/mcp/server.test.ts +++ b/apps/web/src/lib/server/mcp/server.test.ts @@ -9,7 +9,7 @@ import { const env = { DASHBOARD_ORIGIN: 'https://drive.example.com' -} as Env; +} as unknown as Env; const ctx = { waitUntil: (promise: Promise) => { diff --git a/apps/web/src/lib/server/mcp/server.ts b/apps/web/src/lib/server/mcp/server.ts index a138efd..e276b1e 100644 --- a/apps/web/src/lib/server/mcp/server.ts +++ b/apps/web/src/lib/server/mcp/server.ts @@ -176,6 +176,7 @@ const registerReadTools = (server: McpServer, input: McpServerInput) => { totalBytes, tags: (yield* tags.list).length, maxUploadBytes: config.maxUploadBytes, + maxStagedUploadBytes: config.maxStagedUploadBytes, mcpMaxUploadBytes: MCP_MAX_UPLOAD_BYTES, contentOrigin: config.contentOrigin, semantic: yield* indexing.status diff --git a/apps/web/src/lib/server/routes/staged-upload.test.ts b/apps/web/src/lib/server/routes/staged-upload.test.ts new file mode 100644 index 0000000..76d4cc9 --- /dev/null +++ b/apps/web/src/lib/server/routes/staged-upload.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$app/server', async () => { + const { mockGetRequestEvent } = await import('../test/route-context.js'); + return mockGetRequestEvent(); +}); + +import { + call, + createRouteContext, + type RouteTestContext +} from '../test/route-context'; +import { login } from '../test/helpers'; + +const createSession = async ( + ctx: RouteTestContext, + body: Record +) => { + const { POST } = await import('../../../routes/api/uploads/+server.js'); + const response = await call( + POST, + ctx.event({ + method: 'POST', + path: '/api/uploads', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' } + }) + ); + if (response.status !== 201) { + throw new Error(`Create failed: ${response.status} ${await response.text()}`); + } + return (await response.json()) as { + sessionId: string; + fileId: string; + partSize: number; + partCount: number; + }; +}; + +const uploadPart = async ( + ctx: RouteTestContext, + sessionId: string, + partNumber: number, + body: string +) => { + const { PUT } = await import( + '../../../routes/api/uploads/[id]/parts/[part]/+server.js' + ); + return call( + PUT, + ctx.event({ + method: 'PUT', + path: `/api/uploads/${sessionId}/parts/${partNumber}`, + body, + headers: { 'content-type': 'application/octet-stream' }, + params: { id: sessionId, part: String(partNumber) } + }) + ); +}; + +const complete = async (ctx: RouteTestContext, sessionId: string) => { + const { POST } = await import( + '../../../routes/api/uploads/[id]/complete/+server.js' + ); + return call( + POST, + ctx.event({ + method: 'POST', + path: `/api/uploads/${sessionId}/complete`, + params: { id: sessionId } + }) + ); +}; + +describe('staged/resumable upload (local platform)', () => { + let shared: RouteTestContext | undefined; + const setup = async () => (shared ??= await createRouteContext()); + + it('finalizes a staged upload into a normal, servable file', async () => { + const ctx = await setup(); + await login(ctx); + const payload = 'hello staged world'; + const session = await createSession(ctx, { + name: 'staged.txt', + sizeBytes: payload.length, + contentType: 'text/plain', + public: true + }); + expect(session.partCount).toBe(1); + + const part = await uploadPart(ctx, session.sessionId, 1, payload); + expect(part.status).toBe(201); + + const finished = await complete(ctx, session.sessionId); + expect(finished.status).toBe(201); + const body = (await finished.json()) as { + file: { id: string; sizeBytes: number; public: boolean }; + }; + expect(body.file.id).toBe(session.fileId); + expect(body.file.sizeBytes).toBe(payload.length); + await ctx.drainWaitUntil(); + + const { GET } = await import('../../../routes/f/[id]/+server.js'); + const served = await call( + GET, + ctx.event({ path: `/f/${session.fileId}`, params: { id: session.fileId } }) + ); + expect(served.status).toBe(200); + expect(await served.text()).toBe(payload); + }); + + it('rejects completion before every part is uploaded', async () => { + const ctx = await setup(); + await login(ctx); + const session = await createSession(ctx, { + name: 'incomplete.bin', + sizeBytes: 24, + contentType: 'application/octet-stream' + }); + await expect(complete(ctx, session.sessionId)).rejects.toMatchObject({ + status: 409 + }); + }); + + it('aborts a staged upload and refuses later completion', async () => { + const ctx = await setup(); + await login(ctx); + const session = await createSession(ctx, { + name: 'doomed.bin', + sizeBytes: 10, + contentType: 'application/octet-stream' + }); + const { DELETE } = await import('../../../routes/api/uploads/[id]/+server.js'); + const aborted = await call( + DELETE, + ctx.event({ + method: 'DELETE', + path: `/api/uploads/${session.sessionId}`, + params: { id: session.sessionId } + }) + ); + expect(aborted.status).toBe(204); + await expect(complete(ctx, session.sessionId)).rejects.toMatchObject({ + status: 409 + }); + }); +}); diff --git a/apps/web/src/lib/server/services/blobs.ts b/apps/web/src/lib/server/services/blobs.ts index f7ca906..eae084d 100644 --- a/apps/web/src/lib/server/services/blobs.ts +++ b/apps/web/src/lib/server/services/blobs.ts @@ -42,6 +42,32 @@ export interface BlobsShape { readonly deletePrefixes: ( prefixes: ReadonlyArray ) => Effect.Effect; + // R2 multipart, used by the staged/resumable upload flow. Each request + // resumes the upload by (key, uploadId) so parts and completion can span + // separate HTTP requests. + readonly createMultipart: ( + key: string, + contentType: string + ) => Effect.Effect<{ readonly uploadId: string }, StorageError>; + readonly uploadPart: ( + key: string, + uploadId: string, + partNumber: number, + body: ReadableStream | ArrayBuffer, + size: number + ) => Effect.Effect< + { readonly partNumber: number; readonly etag: string }, + StorageError + >; + readonly completeMultipart: ( + key: string, + uploadId: string, + parts: ReadonlyArray<{ readonly partNumber: number; readonly etag: string }> + ) => Effect.Effect; + readonly abortMultipart: ( + key: string, + uploadId: string + ) => Effect.Effect; } export class Blobs extends Context.Service()('app/Blobs') {} @@ -161,6 +187,94 @@ const makeBlobs = Effect.gen(function* () { catch: (cause) => new StorageError({ operation: 'delete blob prefixes', cause }) }); + }), + createMultipart: Effect.fn('Blobs.createMultipart')(function* ( + key, + contentType + ) { + const upload = yield* Effect.tryPromise({ + try: () => + bucket.createMultipartUpload(key, { + httpMetadata: { contentType } + }), + catch: (cause) => + new StorageError({ operation: 'create multipart upload', cause }) + }); + return { uploadId: upload.uploadId }; + }), + uploadPart: Effect.fn('Blobs.uploadPart')(function* ( + key, + uploadId, + partNumber, + body, + size + ) { + const uploaded = yield* Effect.tryPromise({ + try: async () => { + const upload = bucket.resumeMultipartUpload(key, uploadId); + // FixedLengthStream pins the exact part length so a truncated + // or overlong body is rejected at the transform, matching the + // one-shot upload path. + if ( + body instanceof ReadableStream && + typeof FixedLengthStream !== 'undefined' + ) { + const { readable, writable } = new FixedLengthStream(size); + const pumped = body.pipeTo(writable); + const [part] = await Promise.all([ + upload.uploadPart(partNumber, readable), + pumped + ]); + return part; + } + const value = + body instanceof ReadableStream + ? await new Response(body).arrayBuffer() + : body; + if (value.byteLength !== size) { + throw new StorageError({ + operation: 'upload part', + cause: `Part ${partNumber} was ${value.byteLength} bytes, expected ${size}` + }); + } + return upload.uploadPart(partNumber, value); + }, + catch: (cause) => + cause instanceof StorageError + ? cause + : new StorageError({ operation: 'upload part', cause }) + }); + return { partNumber: uploaded.partNumber, etag: uploaded.etag }; + }), + completeMultipart: Effect.fn('Blobs.completeMultipart')(function* ( + key, + uploadId, + parts + ) { + const object = yield* Effect.tryPromise({ + try: () => { + const upload = bucket.resumeMultipartUpload(key, uploadId); + return upload.complete( + parts.map((part) => ({ + partNumber: part.partNumber, + etag: part.etag + })) + ); + }, + catch: (cause) => + new StorageError({ operation: 'complete multipart upload', cause }) + }); + return { size: object.size, etag: object.httpEtag }; + }), + abortMultipart: Effect.fn('Blobs.abortMultipart')(function* ( + key, + uploadId + ) { + yield* Effect.tryPromise({ + try: () => bucket.resumeMultipartUpload(key, uploadId).abort(), + catch: (cause) => + new StorageError({ operation: 'abort multipart upload', cause }) + }); }) }); }); diff --git a/apps/web/src/lib/server/services/lifecycle.ts b/apps/web/src/lib/server/services/lifecycle.ts index 2f4ea11..1209538 100644 --- a/apps/web/src/lib/server/services/lifecycle.ts +++ b/apps/web/src/lib/server/services/lifecycle.ts @@ -3,6 +3,7 @@ import { Auth } from './auth'; import { Files } from './files'; import { Indexing } from './indexing'; import { Sites } from './sites'; +import { Uploads } from './uploads'; export interface LifecycleSummary { readonly authentication: number; @@ -67,6 +68,7 @@ const makeLifecycle = Effect.gen(function* () { const files = yield* Files; const indexing = yield* Indexing; const sites = yield* Sites; + const uploads = yield* Uploads; const run = runLifecycleTasks({ // Rotation and sweep are independent: a failed rotation check must @@ -90,7 +92,16 @@ const makeLifecycle = Effect.gen(function* () { ).pipe(Effect.map(([revoked, swept]) => revoked + swept)), sites: sites.sweepLifecycle(10), indexing: indexing.runDue(5), - files: files.sweepPurges(5), + // Fold expired staged-upload cleanup into the file task so an + // abandoned multipart upload's R2 parts and quota reservation are + // released without changing the summary shape. The two counts are + // independent: an upload sweep failure cannot lose the purge count. + files: Effect.zip( + files.sweepPurges(5), + uploads + .sweep(10) + .pipe(Effect.catchCause(() => Effect.succeed(0))) + ).pipe(Effect.map(([purged, swept]) => purged + swept)), vectors: indexing.retryVectorDeletes(100) }).pipe(Effect.withSpan('Lifecycle.run')); diff --git a/apps/web/src/lib/server/services/uploads.ts b/apps/web/src/lib/server/services/uploads.ts new file mode 100644 index 0000000..48f2c09 --- /dev/null +++ b/apps/web/src/lib/server/services/uploads.ts @@ -0,0 +1,495 @@ +import { + type FileSummary, + type UploadSessionCreate +} from '@adrive/shared'; +import { Context, Effect, Layer, Schema } from 'effect'; +import { validateExpiration } from '../auth-policy'; +import { AppConfig } from '../config'; +import { InvalidRequest, NotFound, StorageError, validate } from '../errors'; +import { + cleanFileName, + contentTypeForUpload, + visibilityForFile +} from '../file-policy'; +import { fileIndexStatements } from '../search-index'; +import { ensureStorageQuota } from '../storage-quota'; +import { + choosePartSize, + expectedPartSize, + partCountFor, + UPLOAD_SESSION_TTL_MS, + validatePartLength, + validatePartNumber, + validateSessionSize +} from '../upload-session-policy'; +import { Blobs } from './blobs'; +import { Db } from './bindings'; +import { forgetTagListCache, Tags } from './tags'; + +const MAX_UPLOAD_TAGS = 50; + +const SessionRow = Schema.Struct({ + id: Schema.String, + file_id: Schema.String, + r2_key: Schema.String, + display_name: Schema.String, + content_type: Schema.String, + public: Schema.Int, + expected_size_bytes: Schema.Int, + part_size_bytes: Schema.Int, + r2_upload_id: Schema.String, + status: Schema.String, + expires_at: Schema.String, + tags: Schema.String, + file_expires_at: Schema.NullOr(Schema.String) +}); + +const PartRow = Schema.Struct({ + part_number: Schema.Int, + etag: Schema.String, + size_bytes: Schema.Int +}); + +const decodeRows = (schema: Schema.Codec, rows: unknown) => { + const decoded = Schema.decodeUnknownOption(Schema.Array(schema))(rows); + return decoded._tag === 'Some' ? decoded.value : []; +}; + +export interface UploadSessionInfo { + readonly sessionId: string; + readonly fileId: string; + readonly partSize: number; + readonly partCount: number; + readonly expiresAt: string; +} + +export interface UploadPartInfo { + readonly sessionId: string; + readonly partNumber: number; + readonly contentLength: string | null; + readonly body: ReadableStream | null; +} + +export interface UploadResult { + readonly file: FileSummary; + readonly forcedPublic: boolean; +} + +export interface UploadsShape { + readonly create: ( + input: UploadSessionCreate + ) => Effect.Effect; + readonly uploadPart: ( + input: UploadPartInfo + ) => Effect.Effect< + { readonly partNumber: number; readonly etag: string; readonly sizeBytes: number }, + InvalidRequest | NotFound | StorageError + >; + readonly complete: ( + sessionId: string + ) => Effect.Effect; + readonly abort: ( + sessionId: string + ) => Effect.Effect; + readonly sweep: (limit: number) => Effect.Effect; +} + +export class Uploads extends Context.Service()( + 'app/Uploads' +) {} + +const makeUploads = Effect.gen(function* () { + const db = yield* Db; + const blobs = yield* Blobs; + const config = yield* AppConfig; + const tags = yield* Tags; + + const loadSession = Effect.fn('Uploads.loadSession')(function* ( + sessionId: string + ) { + const rows = yield* Effect.tryPromise({ + try: async () => { + const result = await db + .prepare( + `SELECT id, file_id, r2_key, display_name, content_type, public, + expected_size_bytes, part_size_bytes, r2_upload_id, status, + expires_at, tags, file_expires_at + FROM upload_sessions WHERE id = ? LIMIT 1` + ) + .bind(sessionId) + .all(); + if (!result.success) throw new Error(result.error ?? 'load session'); + return result.results; + }, + catch: (cause) => + new StorageError({ operation: 'load upload session', cause }) + }); + const row = decodeRows(SessionRow, rows)[0]; + if (!row) return yield* new NotFound({ id: sessionId }); + return row; + }); + + const assertOpen = (row: typeof SessionRow.Type) => + row.status === 'open' && new Date(row.expires_at).getTime() > Date.now() + ? Effect.void + : Effect.fail( + new InvalidRequest({ + status: 409, + message: 'Upload session is no longer open' + }) + ); + + return Uploads.of({ + create: Effect.fn('Uploads.create')(function* (input) { + const size = yield* validate(() => + (() => { + validateSessionSize(input.sizeBytes, config.maxStagedUploadBytes); + return input.sizeBytes; + })() + ); + const displayName = yield* validate(() => cleanFileName(input.name)); + const contentType = contentTypeForUpload( + displayName, + input.contentType ?? 'application/octet-stream' + ); + const tagNames = (input.tags ?? []).slice(0, MAX_UPLOAD_TAGS); + const expiresAt = yield* validate(() => + validateExpiration(input.expiresAt ?? null) + ); + yield* ensureStorageQuota(db, config.maxTotalBytes, size); + + const fileId = crypto.randomUUID(); + const r2Key = `v/${fileId}/${crypto.randomUUID()}`; + const partSize = choosePartSize(size); + const partCount = partCountFor(size, partSize); + const requestedPublic = input.public ?? true; + const created = new Date(); + const sessionExpiresAt = new Date( + created.getTime() + UPLOAD_SESSION_TTL_MS + ).toISOString(); + const sessionId = crypto.randomUUID(); + + const { uploadId } = yield* blobs.createMultipart(r2Key, contentType); + yield* Effect.tryPromise({ + try: () => + db + .prepare( + `INSERT INTO upload_sessions ( + id, file_id, r2_key, display_name, content_type, public, + expected_size_bytes, part_size_bytes, r2_upload_id, status, + created_at, expires_at, tags, file_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)` + ) + .bind( + sessionId, + fileId, + r2Key, + displayName, + contentType, + requestedPublic ? 1 : 0, + size, + partSize, + uploadId, + created.toISOString(), + sessionExpiresAt, + JSON.stringify(tagNames), + expiresAt + ) + .run(), + catch: (cause) => + new StorageError({ operation: 'create upload session', cause }) + }).pipe( + Effect.catch((failure) => + blobs + .abortMultipart(r2Key, uploadId) + .pipe(Effect.ignore, Effect.andThen(Effect.fail(failure))) + ) + ); + return { + sessionId, + fileId, + partSize, + partCount, + expiresAt: sessionExpiresAt + }; + }), + uploadPart: Effect.fn('Uploads.uploadPart')(function* (input) { + const session = yield* loadSession(input.sessionId); + yield* assertOpen(session); + const partCount = partCountFor( + session.expected_size_bytes, + session.part_size_bytes + ); + yield* validate(() => validatePartNumber(input.partNumber, partCount)); + const expected = expectedPartSize( + input.partNumber, + session.expected_size_bytes, + session.part_size_bytes, + partCount + ); + const size = yield* validate(() => + validatePartLength(input.contentLength, expected) + ); + const body = + input.body ?? + new ReadableStream({ + start(controller) { + controller.close(); + } + }); + const part = yield* blobs.uploadPart( + session.r2_key, + session.r2_upload_id, + input.partNumber, + body, + size + ); + yield* Effect.tryPromise({ + try: () => + db + .prepare( + `INSERT INTO upload_parts (session_id, part_number, etag, size_bytes) + VALUES (?, ?, ?, ?) + ON CONFLICT(session_id, part_number) + DO UPDATE SET etag = excluded.etag, size_bytes = excluded.size_bytes` + ) + .bind(session.id, input.partNumber, part.etag, size) + .run(), + catch: (cause) => + new StorageError({ operation: 'record upload part', cause }) + }); + return { partNumber: input.partNumber, etag: part.etag, sizeBytes: size }; + }), + complete: Effect.fn('Uploads.complete')(function* (sessionId) { + const session = yield* loadSession(sessionId); + yield* assertOpen(session); + const partCount = partCountFor( + session.expected_size_bytes, + session.part_size_bytes + ); + const partRows = yield* Effect.tryPromise({ + try: async () => { + const result = await db + .prepare( + `SELECT part_number, etag, size_bytes FROM upload_parts + WHERE session_id = ? ORDER BY part_number` + ) + .bind(session.id) + .all(); + if (!result.success) throw new Error(result.error ?? 'load parts'); + return result.results; + }, + catch: (cause) => + new StorageError({ operation: 'load upload parts', cause }) + }); + const parts = decodeRows(PartRow, partRows); + const total = parts.reduce((sum, part) => sum + part.size_bytes, 0); + const numbersOk = + parts.length === partCount && + parts.every((part, index) => part.part_number === index + 1); + if (!numbersOk || total !== session.expected_size_bytes) { + return yield* new InvalidRequest({ + status: 409, + message: 'Uploaded parts are incomplete; upload every part first' + }); + } + + const stored = yield* blobs.completeMultipart( + session.r2_key, + session.r2_upload_id, + parts.map((part) => ({ + partNumber: part.part_number, + etag: part.etag + })) + ); + + const visibility = visibilityForFile( + session.display_name, + session.content_type, + session.public === 1 + ); + const resolvedTags = yield* tags + .resolveNames(decodeTagNames(session.tags)) + .pipe( + Effect.catchTag('InvalidRequest', () => Effect.succeed([])) + ); + const now = new Date().toISOString(); + const exists = `EXISTS (SELECT 1 FROM upload_sessions WHERE id = ? AND status = 'complete')`; + const statements = [ + db + .prepare( + `UPDATE upload_sessions SET status = 'complete' + WHERE id = ? AND status = 'open'` + ) + .bind(session.id), + db + .prepare( + `INSERT INTO files ( + id, display_name, content_type, kind, current_version, size_bytes, + public, is_site, created_at, updated_at, expires_at, index_state + ) + SELECT ?, ?, ?, 'file', 1, ?, ?, 0, ?, ?, ?, 'pending' + WHERE ${exists}` + ) + .bind( + session.file_id, + session.display_name, + session.content_type, + stored.size, + visibility.public ? 1 : 0, + now, + now, + session.file_expires_at, + session.id + ), + db + .prepare( + `INSERT INTO file_versions ( + file_id, version, r2_key, size_bytes, sha256, content_type, + created_at, text_content + ) + SELECT ?, 1, ?, ?, NULL, ?, ?, NULL + WHERE ${exists}` + ) + .bind( + session.file_id, + session.r2_key, + stored.size, + session.content_type, + now, + session.id + ), + ...resolvedTags.map((tag) => + db + .prepare( + `INSERT INTO file_tags (file_id, tag_id) + SELECT ?, ? WHERE ${exists}` + ) + .bind(session.file_id, tag.id, session.id) + ), + ...fileIndexStatements(db, session.file_id), + db.prepare('DELETE FROM upload_parts WHERE session_id = ?').bind( + session.id + ) + ]; + yield* Effect.tryPromise({ + try: async () => { + const results = await db.batch(statements); + if (results[0]?.meta.changes !== 1) { + throw new Error('The upload session changed while finalizing'); + } + }, + catch: (cause) => + new StorageError({ operation: 'finalize upload', cause }) + }).pipe( + Effect.catch((failure) => + // The R2 object is already assembled but no file row references + // it; drop the orphan before surfacing the failure. + blobs + .delete(session.r2_key) + .pipe(Effect.ignore, Effect.andThen(Effect.fail(failure))) + ) + ); + forgetTagListCache(db); + return { + file: { + id: session.file_id, + displayName: session.display_name, + contentType: session.content_type, + kind: 'file', + version: 1, + sizeBytes: stored.size, + public: visibility.public, + createdAt: now, + expiresAt: session.file_expires_at, + downloadCount: 0, + lastDownloadAt: null, + indexState: 'pending', + indexedVersion: null, + indexAttempts: 0, + indexError: null + }, + forcedPublic: visibility.forcedPublic + }; + }), + abort: Effect.fn('Uploads.abort')(function* (sessionId) { + const session = yield* loadSession(sessionId); + if (session.status === 'complete') { + return yield* new NotFound({ id: sessionId }); + } + yield* blobs + .abortMultipart(session.r2_key, session.r2_upload_id) + .pipe(Effect.ignore); + yield* Effect.tryPromise({ + try: () => + db + .prepare( + `UPDATE upload_sessions SET status = 'aborted' + WHERE id = ? AND status IN ('open', 'committing')` + ) + .bind(session.id) + .run(), + catch: (cause) => + new StorageError({ operation: 'abort upload session', cause }) + }); + }), + sweep: Effect.fn('Uploads.sweep')(function* (limit) { + const bounded = Math.max(1, Math.min(limit, 25)); + const now = new Date().toISOString(); + const rows = yield* Effect.tryPromise({ + try: async () => { + const result = await db + .prepare( + `SELECT id, file_id, r2_key, display_name, content_type, public, + expected_size_bytes, part_size_bytes, r2_upload_id, status, + expires_at, tags, file_expires_at + FROM upload_sessions + WHERE status = 'open' AND expires_at <= ? + ORDER BY expires_at LIMIT ?` + ) + .bind(now, bounded) + .all(); + if (!result.success) throw new Error(result.error ?? 'sweep'); + return result.results; + }, + catch: (cause) => + new StorageError({ operation: 'list expired upload sessions', cause }) + }); + const sessions = decodeRows(SessionRow, rows); + for (const session of sessions) { + yield* blobs + .abortMultipart(session.r2_key, session.r2_upload_id) + .pipe(Effect.ignore); + yield* Effect.tryPromise({ + try: () => + db + .prepare( + `UPDATE upload_sessions SET status = 'aborted' + WHERE id = ? AND status = 'open'` + ) + .bind(session.id) + .run(), + catch: (cause) => + new StorageError({ + operation: 'expire upload session', + cause + }) + }); + } + return sessions.length; + }) + }); +}); + +const decodeTagNames = (value: string): ReadonlyArray => { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === 'string') + : []; + } catch { + return []; + } +}; + +export const UploadsLive = Layer.effect(Uploads, makeUploads); diff --git a/apps/web/src/lib/server/storage-quota.ts b/apps/web/src/lib/server/storage-quota.ts index d84c85d..ad59086 100644 --- a/apps/web/src/lib/server/storage-quota.ts +++ b/apps/web/src/lib/server/storage-quota.ts @@ -34,6 +34,12 @@ const TOTAL_STORED_BYTES_SQL = ` WHERE a.stored_size_bytes IS NOT NULL AND s.status IN ('open', 'committing') ), 0) + + + COALESCE(( + SELECT SUM(expected_size_bytes) + FROM upload_sessions + WHERE status IN ('open', 'committing') + ), 0) AS total`; export const ensureStorageQuota = ( diff --git a/apps/web/src/lib/server/upload-session-policy.test.ts b/apps/web/src/lib/server/upload-session-policy.test.ts new file mode 100644 index 0000000..5bb416c --- /dev/null +++ b/apps/web/src/lib/server/upload-session-policy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + choosePartSize, + DEFAULT_PART_BYTES, + expectedPartSize, + MAX_PARTS, + MIN_PART_BYTES, + partCountFor +} from './upload-session-policy'; + +describe('staged upload part math', () => { + it('uses a single part when the file fits the default part size', () => { + expect(choosePartSize(10)).toBe(10); + expect(partCountFor(10, choosePartSize(10))).toBe(1); + }); + + it('keeps the default part size for moderately large files', () => { + const size = DEFAULT_PART_BYTES * 3 + 123; + expect(choosePartSize(size)).toBe(DEFAULT_PART_BYTES); + expect(partCountFor(size, DEFAULT_PART_BYTES)).toBe(4); + }); + + it('grows the part size so the part count never exceeds the cap', () => { + const size = DEFAULT_PART_BYTES * (MAX_PARTS + 5); + const partSize = choosePartSize(size); + expect(partSize).toBeGreaterThanOrEqual(MIN_PART_BYTES); + expect(partCountFor(size, partSize)).toBeLessThanOrEqual(MAX_PARTS); + }); + + it('gives the final part the remainder bytes', () => { + const size = DEFAULT_PART_BYTES + 1000; + const partSize = choosePartSize(size); + const count = partCountFor(size, partSize); + expect(count).toBe(2); + expect(expectedPartSize(1, size, partSize, count)).toBe(partSize); + expect(expectedPartSize(2, size, partSize, count)).toBe(1000); + }); +}); diff --git a/apps/web/src/lib/server/upload-session-policy.ts b/apps/web/src/lib/server/upload-session-policy.ts new file mode 100644 index 0000000..bc0de57 --- /dev/null +++ b/apps/web/src/lib/server/upload-session-policy.ts @@ -0,0 +1,92 @@ +import { InvalidRequest } from './errors'; + +// R2 multipart rules: every part except the last must be the same size and at +// least 5 MiB, and there can be at most 10,000 parts. We pick a uniform part +// size at session creation so parts can be uploaded (and re-uploaded) in any +// order across separate requests. +export const MIN_PART_BYTES = 5 * 1024 * 1024; +export const DEFAULT_PART_BYTES = 8 * 1024 * 1024; +export const MAX_PARTS = 10_000; +export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; + +export const choosePartSize = (sizeBytes: number) => { + // A single-part upload (the whole file is the last part) has no minimum. + if (sizeBytes <= DEFAULT_PART_BYTES) return sizeBytes; + if (Math.ceil(sizeBytes / DEFAULT_PART_BYTES) <= MAX_PARTS) { + return DEFAULT_PART_BYTES; + } + // Too many parts at the default size: grow the part size to fit the cap. + return Math.max(MIN_PART_BYTES, Math.ceil(sizeBytes / MAX_PARTS)); +}; + +export const partCountFor = (sizeBytes: number, partSize: number) => + Math.max(1, Math.ceil(sizeBytes / partSize)); + +// The exact expected byte length of a given 1-based part: every part is +// `partSize` except the final one, which holds the remainder. +export const expectedPartSize = ( + partNumber: number, + sizeBytes: number, + partSize: number, + partCount: number +) => + partNumber < partCount + ? partSize + : sizeBytes - (partCount - 1) * partSize; + +export const validateSessionSize = ( + sizeBytes: number, + maxStagedUploadBytes: number +) => { + if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) { + throw new InvalidRequest({ + status: 400, + message: 'A positive file size is required' + }); + } + if (sizeBytes > maxStagedUploadBytes) { + throw new InvalidRequest({ + status: 413, + message: 'File exceeds the staged upload limit' + }); + } +}; + +export const validatePartNumber = (partNumber: number, partCount: number) => { + if ( + !Number.isSafeInteger(partNumber) || + partNumber < 1 || + partNumber > partCount + ) { + throw new InvalidRequest({ + status: 400, + message: `Part number must be between 1 and ${partCount}` + }); + } +}; + +export const validatePartLength = ( + header: string | null, + expected: number +) => { + if (header === null) { + throw new InvalidRequest({ + status: 411, + message: 'Content-Length is required' + }); + } + const size = Number(header); + if (!Number.isSafeInteger(size) || size < 0) { + throw new InvalidRequest({ + status: 400, + message: 'Content-Length is invalid' + }); + } + if (size !== expected) { + throw new InvalidRequest({ + status: 400, + message: `Part must be exactly ${expected} bytes` + }); + } + return size; +}; diff --git a/apps/web/src/routes/api/files/+server.ts b/apps/web/src/routes/api/files/+server.ts index 905e2e5..33e6f43 100644 --- a/apps/web/src/routes/api/files/+server.ts +++ b/apps/web/src/routes/api/files/+server.ts @@ -108,6 +108,7 @@ export const GET: RequestHandler = ({ cookies, request, url }) => tags: tagList ?? [], contentOrigin: config.contentOrigin, maxUploadBytes: config.maxUploadBytes, + maxStagedUploadBytes: config.maxStagedUploadBytes, semantic: status ?? { enabled: false, indexedChunks: 0, diff --git a/apps/web/src/routes/api/search/+server.ts b/apps/web/src/routes/api/search/+server.ts index 8470cd4..57435f7 100644 --- a/apps/web/src/routes/api/search/+server.ts +++ b/apps/web/src/routes/api/search/+server.ts @@ -36,6 +36,7 @@ export const GET: RequestHandler = ({ cookies, request, url }) => tags: tagList ?? [], contentOrigin: config.contentOrigin, maxUploadBytes: config.maxUploadBytes, + maxStagedUploadBytes: config.maxStagedUploadBytes, semantic: semantic ?? { enabled: false, indexedChunks: 0, diff --git a/apps/web/src/routes/api/uploads/+server.ts b/apps/web/src/routes/api/uploads/+server.ts new file mode 100644 index 0000000..e0187f7 --- /dev/null +++ b/apps/web/src/routes/api/uploads/+server.ts @@ -0,0 +1,44 @@ +import { UploadSessionCreateSchema } from '@adrive/shared'; +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdge } from '$lib/server/edge'; +import { authRateLimitResponse } from '$lib/server/auth-rate-limit-response'; +import { decodeJson } from '$lib/server/request-json'; +import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { assertUnrestricted } from '$lib/server/token-scope'; +import { AuthGuard } from '$lib/server/services/auth-guard'; +import { Uploads } from '$lib/server/services/uploads'; + +// Open a staged/resumable multipart upload for a file too large for the +// one-shot PUT. Returns the part size and count the client should use. +export const POST: RequestHandler = ({ cookies, request, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const authGuard = yield* AuthGuard; + const uploads = yield* Uploads; + const credential = yield* authorizeWriteRequest( + auth, + request, + url, + cookies + ); + yield* assertUnrestricted(credential); + const rateLimit = yield* authGuard.consume( + 'upload', + credential.credentialId + ); + if (!rateLimit.allowed) { + return authRateLimitResponse( + rateLimit, + 'Too many uploads. Try again later.' + ); + } + const input = yield* decodeJson( + request, + UploadSessionCreateSchema, + 'Upload session request is invalid' + ); + return Response.json(yield* uploads.create(input), { status: 201 }); + }) + ); diff --git a/apps/web/src/routes/api/uploads/[id]/+server.ts b/apps/web/src/routes/api/uploads/[id]/+server.ts new file mode 100644 index 0000000..154d408 --- /dev/null +++ b/apps/web/src/routes/api/uploads/[id]/+server.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdge } from '$lib/server/edge'; +import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { assertUnrestricted } from '$lib/server/token-scope'; +import { Uploads } from '$lib/server/services/uploads'; + +export const DELETE: RequestHandler = ({ cookies, params, request, url }) => + runEdge( + Effect.gen(function* () { + const auth = yield* Auth; + const uploads = yield* Uploads; + const credential = yield* authorizeWriteRequest( + auth, + request, + url, + cookies + ); + yield* assertUnrestricted(credential); + yield* uploads.abort(params.id); + return new Response(null, { status: 204 }); + }) + ); diff --git a/apps/web/src/routes/api/uploads/[id]/complete/+server.ts b/apps/web/src/routes/api/uploads/[id]/complete/+server.ts new file mode 100644 index 0000000..af5ebc9 --- /dev/null +++ b/apps/web/src/routes/api/uploads/[id]/complete/+server.ts @@ -0,0 +1,51 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { AppConfig } from '$lib/server/config'; +import { runEdgeWithEvent, runWorkerProgram } from '$lib/server/edge'; +import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { assertUnrestricted } from '$lib/server/token-scope'; +import { Indexing } from '$lib/server/services/indexing'; +import { Uploads } from '$lib/server/services/uploads'; + +export const POST: RequestHandler = async (event) => { + const { cookies, params, request, url } = event; + const output = await runEdgeWithEvent( + event, + Effect.gen(function* () { + const auth = yield* Auth; + const config = yield* AppConfig; + const uploads = yield* Uploads; + const credential = yield* authorizeWriteRequest( + auth, + request, + url, + cookies + ); + yield* assertUnrestricted(credential); + const result = yield* uploads.complete(params.id); + return { + fileId: result.file.id, + response: Response.json( + { + file: result.file, + url: `${config.contentOrigin}/f/${result.file.id}`, + forcedPublic: result.forcedPublic + }, + { status: 201 } + ) + }; + }) + ); + if (event.platform) { + event.platform.ctx.waitUntil( + runWorkerProgram( + event.platform.env, + Effect.gen(function* () { + const indexing = yield* Indexing; + yield* indexing.process(output.fileId); + }) + ) + ); + } + return output.response; +}; diff --git a/apps/web/src/routes/api/uploads/[id]/parts/[part]/+server.ts b/apps/web/src/routes/api/uploads/[id]/parts/[part]/+server.ts new file mode 100644 index 0000000..51c5a33 --- /dev/null +++ b/apps/web/src/routes/api/uploads/[id]/parts/[part]/+server.ts @@ -0,0 +1,41 @@ +import type { RequestHandler } from './$types'; +import { Effect } from 'effect'; +import { runEdgeWithEvent } from '$lib/server/edge'; +import { InvalidRequest } from '$lib/server/errors'; +import { Auth, authorizeWriteRequest } from '$lib/server/services/auth'; +import { assertUnrestricted } from '$lib/server/token-scope'; +import { Uploads } from '$lib/server/services/uploads'; + +export const PUT: RequestHandler = (event) => { + const { cookies, params, request, url } = event; + return runEdgeWithEvent( + event, + Effect.gen(function* () { + const auth = yield* Auth; + const uploads = yield* Uploads; + const credential = yield* authorizeWriteRequest( + auth, + request, + url, + cookies + ); + yield* assertUnrestricted(credential); + const partNumber = Number(params.part); + if (!Number.isSafeInteger(partNumber) || partNumber < 1) { + return yield* new InvalidRequest({ + status: 400, + message: 'Part number is invalid' + }); + } + return Response.json( + yield* uploads.uploadPart({ + sessionId: params.id, + partNumber, + contentLength: request.headers.get('content-length'), + body: request.body + }), + { status: 201 } + ); + }) + ); +}; diff --git a/apps/web/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts index 090c95b..81d3f6f 100644 --- a/apps/web/worker-configuration.d.ts +++ b/apps/web/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: a4f5b5f4a16285a5f5197e1d014c81e0) +// Generated by Wrangler by running `wrangler types` (hash: 151bdf9d6b46f8d274387d84e4396d69) // Runtime types generated with workerd@1.20260722.1 2026-07-27 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { AUTH_GUARD: KVNamespace; @@ -9,15 +9,16 @@ interface __BaseEnv_Env { BROWSER: BrowserRun; AI?: Ai; ASSETS: Fetcher; + DASHBOARD_ORIGIN: "https://drive.davis7.space" | "http://localhost:5173"; + CONTENT_ORIGIN: "https://files.davis7.space" | "http://localhost:5174"; MAX_UPLOAD_BYTES: "99614720"; + MAX_STAGED_UPLOAD_BYTES: "524288000"; MAX_TOTAL_BYTES: "107374182400"; SEMANTIC_SEARCH: "required" | "auto"; EMBEDDING_MODEL: "@cf/baai/bge-small-en-v1.5"; EMBEDDING_POOLING: "cls"; EMBEDDING_DIMENSIONS: "384"; PASSCODE: string; - DASHBOARD_ORIGIN: string; - CONTENT_ORIGIN: string; } declare namespace Cloudflare { interface ProductionEnv { @@ -28,15 +29,16 @@ declare namespace Cloudflare { BROWSER: BrowserRun; AI: Ai; ASSETS: Fetcher; + DASHBOARD_ORIGIN: "https://drive.davis7.space"; + CONTENT_ORIGIN: "https://files.davis7.space"; MAX_UPLOAD_BYTES: "99614720"; + MAX_STAGED_UPLOAD_BYTES: "524288000"; MAX_TOTAL_BYTES: "107374182400"; SEMANTIC_SEARCH: "required"; EMBEDDING_MODEL: "@cf/baai/bge-small-en-v1.5"; EMBEDDING_POOLING: "cls"; EMBEDDING_DIMENSIONS: "384"; PASSCODE: string; - DASHBOARD_ORIGIN: string; - CONTENT_ORIGIN: string; } interface Env extends __BaseEnv_Env {} } @@ -45,7 +47,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 8322932..f362a03 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -14,6 +14,7 @@ "DASHBOARD_ORIGIN": "http://localhost:5173", "CONTENT_ORIGIN": "http://localhost:5174", "MAX_UPLOAD_BYTES": "99614720", + "MAX_STAGED_UPLOAD_BYTES": "524288000", "MAX_TOTAL_BYTES": "107374182400", "SEMANTIC_SEARCH": "auto", "EMBEDDING_MODEL": "@cf/baai/bge-small-en-v1.5", @@ -79,6 +80,7 @@ "DASHBOARD_ORIGIN": "https://drive.davis7.space", "CONTENT_ORIGIN": "https://files.davis7.space", "MAX_UPLOAD_BYTES": "99614720", + "MAX_STAGED_UPLOAD_BYTES": "524288000", "MAX_TOTAL_BYTES": "107374182400", // required (not auto) so a missing AI/Vectorize binding fails // the deploy loudly instead of silently degrading to keyword. diff --git a/packages/cli/src/cli-smoke.test.ts b/packages/cli/src/cli-smoke.test.ts index 378a299..7013c41 100644 --- a/packages/cli/src/cli-smoke.test.ts +++ b/packages/cli/src/cli-smoke.test.ts @@ -21,6 +21,8 @@ let authChecks = 0; let uploadedContentLength: string | undefined; let linkUrlOverride: string | undefined; let keyCreateBody: unknown; +let maxUploadBytesForCaps = 100_000_000; +const stagedParts: Record = {}; const deviceApiKey = 'adr_login123_123456789012345678901234'; @@ -148,7 +150,8 @@ beforeAll(async () => { nextCursor: null, tags: [], contentOrigin: contentEndpoint, - maxUploadBytes: 100_000_000, + maxUploadBytes: maxUploadBytesForCaps, + maxStagedUploadBytes: 524_288_000, semantic: { enabled: false, indexedChunks: 0, @@ -224,6 +227,64 @@ beforeAll(async () => { } return; } + if (request.method === 'POST' && request.url === '/api/uploads') { + const chunks: Array = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + response.statusCode = 201; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + sessionId: 'up-1', + fileId: 'file-staged', + partSize: 4, + partCount: 2, + expiresAt: '2026-07-28T00:00:00.000Z' + }) + ); + }); + return; + } + const partMatch = /^\/api\/uploads\/up-1\/parts\/(\d+)$/.exec( + request.url ?? '' + ); + if (request.method === 'PUT' && partMatch) { + const partNumber = Number(partMatch[1]); + const chunks: Array = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + stagedParts[partNumber] = Buffer.concat(chunks); + response.statusCode = 201; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + partNumber, + etag: `etag-${partNumber}`, + sizeBytes: stagedParts[partNumber]!.length + }) + ); + }); + return; + } + if ( + request.method === 'POST' && + request.url === '/api/uploads/up-1/complete' + ) { + const total = Object.values(stagedParts).reduce( + (sum, part) => sum + part.length, + 0 + ); + response.statusCode = 201; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + file: { ...file, id: 'file-staged', sizeBytes: total }, + url: `http://content.test/f/file-staged`, + forcedPublic: false + }) + ); + return; + } if (request.method === 'POST' && request.url === '/api/auth/keys') { const chunks: Array = []; request.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -590,6 +651,30 @@ describe('CLI stream and JSON contracts', () => { ); }); + it('auto-stages a file larger than the one-shot cap', async () => { + const payload = Buffer.from('abcdef'); + for (const key of Object.keys(stagedParts)) { + delete stagedParts[Number(key)]; + } + maxUploadBytesForCaps = 4; + try { + const result = await run( + ['--json', 'put', '-', '--name', 'big.bin'], + payload + ); + expect(result.status).toBe(0); + expect(result.stderr.toString()).toBe(''); + expect(Buffer.concat([stagedParts[1]!, stagedParts[2]!])).toEqual( + payload + ); + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + file: { id: 'file-staged', sizeBytes: payload.length } + }); + } finally { + maxUploadBytesForCaps = 100_000_000; + } + }); + it('mints a scoped token and forwards its tag/file targets', async () => { keyCreateBody = undefined; const result = await run([ diff --git a/packages/cli/src/commands/files.ts b/packages/cli/src/commands/files.ts index e7c2cf6..d961454 100644 --- a/packages/cli/src/commands/files.ts +++ b/packages/cli/src/commands/files.ts @@ -3,10 +3,12 @@ import { FileListResponseSchema, FileMutationResponseSchema, FileTagsResponseSchema, - UploadResponseSchema + UploadPartResponseSchema, + UploadResponseSchema, + UploadSessionResponseSchema } from '@adrive/shared'; import { createWriteStream } from 'node:fs'; -import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { mkdtemp, open, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { pipeline } from 'node:stream/promises'; @@ -102,6 +104,7 @@ export const status = Command.make('status', {}, () => totalBytes, tags: firstPage.tags.length, maxUploadBytes: firstPage.maxUploadBytes, + maxStagedUploadBytes: firstPage.maxStagedUploadBytes, semantic: firstPage.semantic }); } else { @@ -117,7 +120,7 @@ export const status = Command.make('status', {}, () => yield* Console.log(`Storage ${formatBytes(totalBytes)}`); yield* Console.log(`Tags ${firstPage.tags.length}`); yield* Console.log( - `Max upload ${formatBytes(firstPage.maxUploadBytes)}` + `Max upload ${formatBytes(firstPage.maxUploadBytes)} (staged ${formatBytes(firstPage.maxStagedUploadBytes)})` ); yield* Console.log(`Semantic ${semantic}`); } @@ -149,6 +152,164 @@ const prepareUpload = (file: string, suppliedName: Option.Option) => new CliFailure({ message: 'Could not prepare the upload', cause }) }); +type UploadResult = typeof UploadResponseSchema.Type; + +const printUpload = (result: UploadResult) => + Effect.gen(function* () { + if (wantsJson()) { + yield* emit(result); + } else { + yield* Console.log(`Uploaded ${result.file.displayName}`); + yield* Console.log(result.url); + yield* Console.log( + `${result.file.id} · ${result.file.sizeBytes} bytes · ${result.file.public ? 'public' : 'private'}${result.forcedPublic ? ' (HTML forced public)' : ''}${result.file.expiresAt ? ` · expires ${result.file.expiresAt}` : ''}` + ); + } + }); + +const uploadCaps = (client: HttpClient.HttpClient, endpoint: string, apiKey: string) => + client + .execute(apiRequest('GET', `${endpoint}/api/files`, apiKey)) + .pipe( + Effect.flatMap(ensureOk), + Effect.flatMap((response) => decodeBody(FileListResponseSchema, response)) + ); + +const oneShotUpload = ( + client: HttpClient.HttpClient, + config: { endpoint: string; apiKey: string }, + prepared: { path: string; displayName: string }, + contentType: string, + isPrivate: boolean, + expires: Option.Option +) => + Effect.gen(function* () { + const body = yield* HttpBody.file(prepared.path, { contentType }); + const response = yield* client + .execute( + apiRequest('PUT', `${config.endpoint}/api/files`, config.apiKey, { + body, + headers: { + 'content-type': contentType, + 'x-adrive-file-name': encodeURIComponent(prepared.displayName), + 'x-adrive-public': String(!isPrivate), + ...(Option.isSome(expires) + ? { 'x-adrive-expires-at': expires.value } + : {}) + } + }) + ) + .pipe(Effect.flatMap(ensureOk)); + return yield* decodeBody(UploadResponseSchema, response); + }); + +// Files larger than the one-shot cap go through the staged multipart flow: +// open a session, PUT each part read straight off disk, then finalize. A +// failure aborts the session so no partial upload lingers. +const stagedUpload = ( + client: HttpClient.HttpClient, + config: { endpoint: string; apiKey: string }, + prepared: { path: string; displayName: string }, + contentType: string, + size: number, + isPrivate: boolean, + expires: Option.Option +) => + Effect.gen(function* () { + const createResponse = yield* client + .execute( + apiRequest('POST', `${config.endpoint}/api/uploads`, config.apiKey, { + body: HttpBody.jsonUnsafe({ + name: prepared.displayName, + sizeBytes: size, + contentType, + public: !isPrivate, + ...(Option.isSome(expires) ? { expiresAt: expires.value } : {}) + }) + }) + ) + .pipe(Effect.flatMap(ensureOk)); + const session = yield* decodeBody( + UploadSessionResponseSchema, + createResponse + ); + const sendParts = Effect.gen(function* () { + const handle = yield* Effect.tryPromise({ + try: () => open(prepared.path, 'r'), + catch: (cause) => + new CliFailure({ message: 'Could not read the file', cause }) + }); + yield* Effect.gen(function* () { + for ( + let partNumber = 1; + partNumber <= session.partCount; + partNumber += 1 + ) { + const start = (partNumber - 1) * session.partSize; + const length = Math.min(session.partSize, size - start); + const bytes = yield* Effect.tryPromise({ + try: async () => { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + return buffer; + }, + catch: (cause) => + new CliFailure({ message: 'Could not read a file part', cause }) + }); + yield* client + .execute( + apiRequest( + 'PUT', + `${config.endpoint}/api/uploads/${encodeURIComponent(session.sessionId)}/parts/${partNumber}`, + config.apiKey, + { body: HttpBody.uint8Array(bytes, contentType) } + ) + ) + .pipe( + Effect.flatMap(ensureOk), + Effect.flatMap((response) => + decodeBody(UploadPartResponseSchema, response) + ) + ); + } + }).pipe( + Effect.ensuring( + Effect.tryPromise({ + try: () => handle.close(), + catch: () => undefined + }).pipe(Effect.ignore) + ) + ); + }); + yield* sendParts.pipe( + Effect.catch((failure) => + client + .execute( + apiRequest( + 'DELETE', + `${config.endpoint}/api/uploads/${encodeURIComponent(session.sessionId)}`, + config.apiKey + ) + ) + .pipe( + Effect.flatMap(ensureOk), + Effect.catchCause(() => Effect.void), + Effect.andThen(Effect.fail(failure)) + ) + ) + ); + const completeResponse = yield* client + .execute( + apiRequest( + 'POST', + `${config.endpoint}/api/uploads/${encodeURIComponent(session.sessionId)}/complete`, + config.apiKey + ) + ) + .pipe(Effect.flatMap(ensureOk)); + return yield* decodeBody(UploadResponseSchema, completeResponse); + }); + export const put = Command.make( 'put', { @@ -173,32 +334,41 @@ export const put = Command.make( const upload = Effect.gen(function* () { const contentType = mime.getType(prepared.displayName) ?? 'application/octet-stream'; - const body = yield* HttpBody.file(prepared.path, { contentType }); - const response = yield* client - .execute( - apiRequest('PUT', `${config.endpoint}/api/files`, config.apiKey, { - body, - headers: { - 'content-type': contentType, - 'x-adrive-file-name': encodeURIComponent(prepared.displayName), - 'x-adrive-public': String(!isPrivate), - ...(Option.isSome(expires) - ? { 'x-adrive-expires-at': expires.value } - : {}) - } - }) - ) - .pipe(Effect.flatMap(ensureOk)); - const result = yield* decodeBody(UploadResponseSchema, response); - if (wantsJson()) { - yield* emit(result); - } else { - yield* Console.log(`Uploaded ${result.file.displayName}`); - yield* Console.log(result.url); - yield* Console.log( - `${result.file.id} · ${result.file.sizeBytes} bytes · ${result.file.public ? 'public' : 'private'}${result.forcedPublic ? ' (HTML forced public)' : ''}${result.file.expiresAt ? ` · expires ${result.file.expiresAt}` : ''}` - ); + const size = yield* Effect.tryPromise({ + try: () => stat(prepared.path).then((details) => details.size), + catch: (cause) => + new CliFailure({ message: 'Could not size the file', cause }) + }); + const caps = yield* uploadCaps( + client, + config.endpoint, + config.apiKey + ); + if (size > caps.maxStagedUploadBytes) { + return yield* new CliFailure({ + message: 'That is too large for this drive.' + }); } + const result = + size > caps.maxUploadBytes + ? yield* stagedUpload( + client, + config, + prepared, + contentType, + size, + isPrivate, + expires + ) + : yield* oneShotUpload( + client, + config, + prepared, + contentType, + isPrivate, + expires + ); + yield* printUpload(result); }); yield* upload.pipe( Effect.ensuring( @@ -215,7 +385,9 @@ export const put = Command.make( ) ); }) -).pipe(Command.withDescription('Stream a file to adrive')); +).pipe( + Command.withDescription('Upload a file (auto-stages files above the cap)') +); export const list = Command.make('list', {}, () => Effect.gen(function* () { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cf1d3b4..5711e0d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -140,6 +140,7 @@ export const FileListResponseSchema = Schema.Struct({ tags: Schema.Array(TagSchema), contentOrigin: Schema.String, maxUploadBytes: Schema.Int, + maxStagedUploadBytes: Schema.Int, semantic: Schema.Struct({ enabled: Schema.Boolean, indexedChunks: Schema.Int, @@ -258,6 +259,38 @@ export const UploadResponseSchema = Schema.Struct({ export type UploadResponse = typeof UploadResponseSchema.Type; +// Staged / resumable upload for files larger than the one-shot cap. The client +// creates a session, PUTs uniform parts (each part_size bytes except the last), +// then completes to finalize into a normal file row. +export const UploadSessionCreateSchema = Schema.Struct({ + name: Schema.String, + sizeBytes: Schema.Int, + contentType: Schema.optional(Schema.String), + public: Schema.optional(Schema.Boolean), + tags: Schema.optional(Schema.Array(Schema.String)), + expiresAt: Schema.optional(Schema.NullOr(Schema.String)) +}); + +export type UploadSessionCreate = typeof UploadSessionCreateSchema.Type; + +export const UploadSessionResponseSchema = Schema.Struct({ + sessionId: Schema.String, + fileId: Schema.String, + partSize: Schema.Int, + partCount: Schema.Int, + expiresAt: Schema.String +}); + +export type UploadSessionResponse = typeof UploadSessionResponseSchema.Type; + +export const UploadPartResponseSchema = Schema.Struct({ + partNumber: Schema.Int, + etag: Schema.String, + sizeBytes: Schema.Int +}); + +export type UploadPartResponse = typeof UploadPartResponseSchema.Type; + export const SiteManifestAssetSchema = Schema.Struct({ path: Schema.String, sizeBytes: Schema.Int, From 6c0b3e432cbbf70af0e22ea0398a84c80033c755 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 09:30:24 +0000 Subject: [PATCH 5/6] docs: document scoped tokens, durable links, publish-from-files, staged uploads Update the use-adrive skill and README for the new agent-facing surfaces and apply Prettier formatting to the new modules. Co-authored-by: Ben Davis --- .agents/skills/use-adrive/SKILL.md | 49 ++++- README.md | 31 +++- apps/web/src/lib/dashboard/parse.ts | 14 +- apps/web/src/lib/server/config.ts | 3 +- apps/web/src/lib/server/mcp/server.ts | 7 +- .../lib/server/routes/publish-files.test.ts | 14 +- .../lib/server/routes/scoped-tokens.test.ts | 15 +- apps/web/src/lib/server/routes/shares.test.ts | 16 +- .../lib/server/routes/staged-upload.test.ts | 22 ++- apps/web/src/lib/server/services/blobs.ts | 168 +++++++++--------- apps/web/src/lib/server/services/lifecycle.ts | 4 +- .../src/lib/server/services/sites/publish.ts | 12 +- apps/web/src/lib/server/services/uploads.ts | 25 ++- apps/web/src/lib/server/site-gallery.ts | 4 +- apps/web/src/lib/server/token-crypto.ts | 4 +- apps/web/src/lib/server/token-scope.ts | 10 +- .../src/lib/server/upload-session-policy.ts | 9 +- apps/web/src/routes/api/files/+server.ts | 5 +- apps/web/src/routes/f/[id]/+server.ts | 9 +- packages/cli/src/commands/files.ts | 22 ++- packages/cli/src/commands/keys.ts | 12 +- packages/cli/src/commands/shares.ts | 9 +- packages/shared/src/index.ts | 3 +- 23 files changed, 264 insertions(+), 203 deletions(-) diff --git a/.agents/skills/use-adrive/SKILL.md b/.agents/skills/use-adrive/SKILL.md index 3179402..631422f 100644 --- a/.agents/skills/use-adrive/SKILL.md +++ b/.agents/skills/use-adrive/SKILL.md @@ -66,9 +66,13 @@ some-command | adrive put - --name "output.txt" --private If the user doesn't say otherwise, it's fine to just make the file public. Make it private if the user asks for it. +`put` uploads in one request under the one-shot cap and automatically switches +to a resumable, multi-part staged upload for larger files, up to the staged cap. +`status` reports both (`Max upload 95 MiB (staged 500 MiB)`). + Before uploading: -- Check `status` for the maximum upload size when the file may be large. +- Check `status` for the one-shot and staged upload sizes when the file may be large. - Quote paths and display names. After uploading, retain the returned file ID and verify its display name, size, content type, and visibility through `adrive --json list`, then send that to the user. @@ -111,6 +115,25 @@ adrive tag delete - Resolve tag and file IDs from fresh output before update or deletion. - Verify changes with `adrive --json tag list` and `adrive --json list`. +## Share a private file with a durable link + +The default private link is a signed 15-minute URL (used for previews). For +"open on my phone later" or "send to one person", create a durable, revocable +link that works on the content origin without the dashboard session: + +```sh +adrive share create +adrive share create --expires-days 30 +adrive share create --password "hunter2" +adrive share create --no-expiry +adrive share list +adrive share revoke +``` + +- The returned URL carries a one-time secret; copy it once and give it to the user. Do not store it. +- A durable link follows the file's current version and stops working when the file is trashed/expired or the share is revoked. +- Only create a password when the user asks; a password is only meaningful for private files (a public file's plain URL bypasses it). + ## Publish static sites Create a public site from a directory: @@ -125,11 +148,35 @@ Republish an existing site: adrive site put "./site-directory" --id ``` +Publish files that are already in the drive as a site — no re-upload: + +```sh +adrive site publish --files " " --name "Photos" +adrive site publish --tag --name "Reports" +adrive site publish --files "" --id +``` + - Site publishing is public. - Inspect the directory first. The CLI rejects symlinks and non-regular assets. - Use `--id` only after confirming the existing site ID; it updates that site. +- `site publish` copies the current bytes of the selected files server-side. If none is an `index.html`, a simple gallery/listing is generated; an existing `index.html` is used as-is. - Verify the returned site ID, asset count, version, and public URL. A successful fetch may return a normal `2xx` response. +## Mint scoped tokens + +Hand out narrow, revocable access with a token limited to specific tags and/or +file IDs, read-only or read-write, with an optional expiry: + +```sh +adrive keys list +adrive keys create "phone reader" --scope read-only --tag +adrive keys create "reports agent" --files " " --expires 2026-09-01T00:00:00Z +adrive keys revoke +``` + +- The created token is shown once; treat it like a password and never store or echo it beyond handing it to the user. +- A scoped token only reads and edits files in its tags/file list and cannot create new files, sites, tags, or other keys. + ## Authenticate only when needed ```sh diff --git a/README.md b/README.md index 207ed87..1124b30 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ Authorization: Bearer adr_… ``` Read-only keys can list, search, and read metadata. Read-write keys can -upload, tag, and publish sites. MCP uploads are capped at 2 MiB; use the -CLI for larger files. +upload, tag, publish sites (including `publish_files_site` from existing files), +and manage durable share links (`create_share` / `list_shares` / +`revoke_share`). A key can also be scoped to specific tags and/or file IDs so it +only reaches those files. MCP uploads are capped at 2 MiB; use the CLI or +dashboard staged upload for larger files. The marketing landing page lives in `apps/site` — a static assets-only Worker (no build step) deployed by `bun release` to @@ -113,11 +116,29 @@ bun adrive get --output ./downloaded-file bun adrive get --output - > downloaded-file bun adrive site put ./dist bun adrive site put ./dist --id +bun adrive site publish --tag --name "Photos" +bun adrive site publish --files " " --name "Docs" +bun adrive share create --expires-days 7 +bun adrive share create --password 'hunter2' +bun adrive share list +bun adrive share revoke +bun adrive keys create "phone reader" --scope read-only --tag +bun adrive keys list +bun adrive keys revoke bun adrive --json tag list bun adrive tag create reports --color '#2563eb' bun adrive tag set reports important ``` +`put` streams a single request under the one-shot cap (`MAX_UPLOAD_BYTES`, 95 +MiB) and automatically switches to a resumable, multi-part staged upload for +larger files up to `MAX_STAGED_UPLOAD_BYTES` (default 500 MiB); the 100 GiB +instance cap still applies. `keys create` can scope a token to specific tags +and/or file IDs so agents get narrow, revocable access. `site publish` turns +files already in the drive into a `/s//` site without re-uploading — it +copies their current bytes server-side and generates a gallery when no +`index.html` is selected. + `login` starts a device flow. Normal mode tries to open the approval URL; `--headless` prints the same complete URL so it can be opened on another machine. Approval mints one full-access API key and saves it at mode `0600` @@ -133,7 +154,11 @@ CLI first requests a typed content link from the authenticated dashboard API, then downloads directly from the cookie-less content origin without forwarding its API key. Public links are stable. Private file links are scoped to one exact version, signed with a deployment-only HMAC, and expire after 15 minutes; the -dashboard clearly labels these expiring links when copying them. +dashboard clearly labels these expiring links when copying them. For sharing a +private file for longer, create a durable link (`adrive share create`, or the +file detail view in the dashboard): a revocable URL that follows the file's +current version, optionally carries a password, defaults to seven days, and +works on the cookie-less content origin without the dashboard session. `site put` walks regular files without following symlinks, declares the complete manifest, streams assets with four uploads at a time, and atomically publishes diff --git a/apps/web/src/lib/dashboard/parse.ts b/apps/web/src/lib/dashboard/parse.ts index 5dd5795..293ad1c 100644 --- a/apps/web/src/lib/dashboard/parse.ts +++ b/apps/web/src/lib/dashboard/parse.ts @@ -226,9 +226,7 @@ export const parseFileTagsResponse = (value: unknown): FileTagsResponse => { }; }; -export const parseSiteCommitResponse = ( - value: unknown -): SiteCommitResponse => { +export const parseSiteCommitResponse = (value: unknown): SiteCommitResponse => { const record = requireRecord(value, 'site'); return { file: parseFileSummary(record.file, 'site.file'), @@ -271,7 +269,10 @@ const parseApiKey = (value: unknown, path = 'key'): ApiKey => { expiresAt: maybeString(record.expiresAt, `${path}.expiresAt`), lastUsedAt: maybeString(record.lastUsedAt, `${path}.lastUsedAt`), revokedAt: maybeString(record.revokedAt, `${path}.revokedAt`), - allowedTagIds: maybeStringList(record.allowedTagIds, `${path}.allowedTagIds`), + allowedTagIds: maybeStringList( + record.allowedTagIds, + `${path}.allowedTagIds` + ), allowedFileIds: maybeStringList( record.allowedFileIds, `${path}.allowedFileIds` @@ -314,7 +315,10 @@ const parseFileShare = (value: unknown, path = 'share'): FileShare => { hasPassword: flag(record.hasPassword, `${path}.hasPassword`), createdAt: text(record.createdAt, `${path}.createdAt`), expiresAt: maybeString(record.expiresAt, `${path}.expiresAt`), - lastAccessedAt: maybeString(record.lastAccessedAt, `${path}.lastAccessedAt`), + lastAccessedAt: maybeString( + record.lastAccessedAt, + `${path}.lastAccessedAt` + ), revokedAt: maybeString(record.revokedAt, `${path}.revokedAt`) }; }; diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index 4633e89..baae441 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -42,8 +42,7 @@ export const configFromEnv = (env: Env) => { // 500 MiB and must be at least the one-shot cap so staged uploads are // never smaller than a single PUT. const rawMaxStagedUploadBytes = env.MAX_STAGED_UPLOAD_BYTES as - | string - | undefined; + string | undefined; const maxStagedUploadBytes = rawMaxStagedUploadBytes === undefined || rawMaxStagedUploadBytes === '' ? 500 * 1024 * 1024 diff --git a/apps/web/src/lib/server/mcp/server.ts b/apps/web/src/lib/server/mcp/server.ts index e276b1e..7b87a0c 100644 --- a/apps/web/src/lib/server/mcp/server.ts +++ b/apps/web/src/lib/server/mcp/server.ts @@ -644,7 +644,9 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { const config = yield* AppConfig; yield* assertUnrestricted(credential); const result = yield* sites.publishFromFiles({ - ...(display_name !== undefined ? { displayName: display_name } : {}), + ...(display_name !== undefined + ? { displayName: display_name } + : {}), ...(file_id !== undefined ? { fileId: file_id } : {}), ...(file_ids !== undefined ? { fileIds: file_ids } : {}), ...(tag_id !== undefined ? { tagId: tag_id } : {}) @@ -657,7 +659,8 @@ const registerWriteTools = (server: McpServer, input: McpServerInput) => { }; }) ); - if (!published.ok) return errorResult(published.message, published.status); + if (!published.ok) + return errorResult(published.message, published.status); scheduleIndex(env, ctx, published.value.file.id); return jsonResult(published.value); } diff --git a/apps/web/src/lib/server/routes/publish-files.test.ts b/apps/web/src/lib/server/routes/publish-files.test.ts index d1b10b0..34ccff7 100644 --- a/apps/web/src/lib/server/routes/publish-files.test.ts +++ b/apps/web/src/lib/server/routes/publish-files.test.ts @@ -12,7 +12,10 @@ import { } from '../test/route-context'; import { login, uploadFile } from '../test/helpers'; -const publish = async (ctx: RouteTestContext, body: Record) => { +const publish = async ( + ctx: RouteTestContext, + body: Record +) => { const { POST } = await import('../../../routes/api/sites/publish/+server.js'); const response = await call( POST, @@ -24,7 +27,9 @@ const publish = async (ctx: RouteTestContext, body: Record) => }) ); if (response.status !== 201) { - throw new Error(`Publish failed: ${response.status} ${await response.text()}`); + throw new Error( + `Publish failed: ${response.status} ${await response.text()}` + ); } await ctx.drainWaitUntil(); return (await response.json()) as { @@ -36,7 +41,10 @@ const publish = async (ctx: RouteTestContext, body: Record) => const serveSite = async (ctx: RouteTestContext, id: string, path: string) => { const { GET } = await import('../../../routes/s/[id]/[...path]/+server.js'); - return call(GET, ctx.event({ path: `/s/${id}/${path}`, params: { id, path } })); + return call( + GET, + ctx.event({ path: `/s/${id}/${path}`, params: { id, path } }) + ); }; describe('publish drive files as a site (local platform)', () => { diff --git a/apps/web/src/lib/server/routes/scoped-tokens.test.ts b/apps/web/src/lib/server/routes/scoped-tokens.test.ts index 81d8a5a..3b6fc49 100644 --- a/apps/web/src/lib/server/routes/scoped-tokens.test.ts +++ b/apps/web/src/lib/server/routes/scoped-tokens.test.ts @@ -94,9 +94,8 @@ describe('scoped tokens (local platform)', () => { allowedTagIds: [reportsTag] }); - const { GET: listGET } = await import( - '../../../routes/api/files/+server.js' - ); + const { GET: listGET } = + await import('../../../routes/api/files/+server.js'); const listing = await call( listGET, bearerEvent(ctx, token, { path: '/api/files' }) @@ -109,9 +108,8 @@ describe('scoped tokens (local platform)', () => { expect(ids).toContain(inScope.id); expect(ids).not.toContain(outScope.id); - const { GET: detailGET } = await import( - '../../../routes/api/files/[id]/+server.js' - ); + const { GET: detailGET } = + await import('../../../routes/api/files/[id]/+server.js'); const inDetail = await call( detailGET, bearerEvent(ctx, token, { @@ -131,9 +129,8 @@ describe('scoped tokens (local platform)', () => { ) ).rejects.toMatchObject({ status: 403 }); - const { GET: linkGET } = await import( - '../../../routes/api/files/[id]/link/+server.js' - ); + const { GET: linkGET } = + await import('../../../routes/api/files/[id]/link/+server.js'); const link = await call( linkGET, bearerEvent(ctx, token, { diff --git a/apps/web/src/lib/server/routes/shares.test.ts b/apps/web/src/lib/server/routes/shares.test.ts index fdf501f..f08e300 100644 --- a/apps/web/src/lib/server/routes/shares.test.ts +++ b/apps/web/src/lib/server/routes/shares.test.ts @@ -17,9 +17,8 @@ const createShare = async ( fileId: string, body: Record ) => { - const { POST } = await import( - '../../../routes/api/files/[id]/shares/+server.js' - ); + const { POST } = + await import('../../../routes/api/files/[id]/shares/+server.js'); const response = await call( POST, ctx.event({ @@ -74,9 +73,8 @@ describe('durable private links (local platform)', () => { expect(served.headers.get('cache-control')).toBe('private, no-store'); expect(await served.text()).toBe('durable body'); - const { DELETE } = await import( - '../../../routes/api/files/[id]/shares/[shareId]/+server.js' - ); + const { DELETE } = + await import('../../../routes/api/files/[id]/shares/[shareId]/+server.js'); const revoked = await call( DELETE, ctx.event({ @@ -87,9 +85,9 @@ describe('durable private links (local platform)', () => { ); expect(revoked.status).toBe(204); - await expect( - serveShare(ctx, file.id, `?s=${token}`) - ).rejects.toMatchObject({ status: 404 }); + await expect(serveShare(ctx, file.id, `?s=${token}`)).rejects.toMatchObject( + { status: 404 } + ); }); it('gates a passworded durable link behind the correct password', async () => { diff --git a/apps/web/src/lib/server/routes/staged-upload.test.ts b/apps/web/src/lib/server/routes/staged-upload.test.ts index 76d4cc9..908ea01 100644 --- a/apps/web/src/lib/server/routes/staged-upload.test.ts +++ b/apps/web/src/lib/server/routes/staged-upload.test.ts @@ -27,7 +27,9 @@ const createSession = async ( }) ); if (response.status !== 201) { - throw new Error(`Create failed: ${response.status} ${await response.text()}`); + throw new Error( + `Create failed: ${response.status} ${await response.text()}` + ); } return (await response.json()) as { sessionId: string; @@ -43,9 +45,8 @@ const uploadPart = async ( partNumber: number, body: string ) => { - const { PUT } = await import( - '../../../routes/api/uploads/[id]/parts/[part]/+server.js' - ); + const { PUT } = + await import('../../../routes/api/uploads/[id]/parts/[part]/+server.js'); return call( PUT, ctx.event({ @@ -59,9 +60,8 @@ const uploadPart = async ( }; const complete = async (ctx: RouteTestContext, sessionId: string) => { - const { POST } = await import( - '../../../routes/api/uploads/[id]/complete/+server.js' - ); + const { POST } = + await import('../../../routes/api/uploads/[id]/complete/+server.js'); return call( POST, ctx.event({ @@ -103,7 +103,10 @@ describe('staged/resumable upload (local platform)', () => { const { GET } = await import('../../../routes/f/[id]/+server.js'); const served = await call( GET, - ctx.event({ path: `/f/${session.fileId}`, params: { id: session.fileId } }) + ctx.event({ + path: `/f/${session.fileId}`, + params: { id: session.fileId } + }) ); expect(served.status).toBe(200); expect(await served.text()).toBe(payload); @@ -130,7 +133,8 @@ describe('staged/resumable upload (local platform)', () => { sizeBytes: 10, contentType: 'application/octet-stream' }); - const { DELETE } = await import('../../../routes/api/uploads/[id]/+server.js'); + const { DELETE } = + await import('../../../routes/api/uploads/[id]/+server.js'); const aborted = await call( DELETE, ctx.event({ diff --git a/apps/web/src/lib/server/services/blobs.ts b/apps/web/src/lib/server/services/blobs.ts index eae084d..0f91cee 100644 --- a/apps/web/src/lib/server/services/blobs.ts +++ b/apps/web/src/lib/server/services/blobs.ts @@ -188,94 +188,86 @@ const makeBlobs = Effect.gen(function* () { new StorageError({ operation: 'delete blob prefixes', cause }) }); }), - createMultipart: Effect.fn('Blobs.createMultipart')(function* ( - key, - contentType - ) { - const upload = yield* Effect.tryPromise({ - try: () => - bucket.createMultipartUpload(key, { - httpMetadata: { contentType } - }), - catch: (cause) => - new StorageError({ operation: 'create multipart upload', cause }) - }); - return { uploadId: upload.uploadId }; - }), - uploadPart: Effect.fn('Blobs.uploadPart')(function* ( - key, - uploadId, - partNumber, - body, - size - ) { - const uploaded = yield* Effect.tryPromise({ - try: async () => { - const upload = bucket.resumeMultipartUpload(key, uploadId); - // FixedLengthStream pins the exact part length so a truncated - // or overlong body is rejected at the transform, matching the - // one-shot upload path. - if ( - body instanceof ReadableStream && - typeof FixedLengthStream !== 'undefined' - ) { - const { readable, writable } = new FixedLengthStream(size); - const pumped = body.pipeTo(writable); - const [part] = await Promise.all([ - upload.uploadPart(partNumber, readable), - pumped - ]); - return part; - } - const value = - body instanceof ReadableStream - ? await new Response(body).arrayBuffer() - : body; - if (value.byteLength !== size) { - throw new StorageError({ - operation: 'upload part', - cause: `Part ${partNumber} was ${value.byteLength} bytes, expected ${size}` - }); - } - return upload.uploadPart(partNumber, value); - }, - catch: (cause) => - cause instanceof StorageError - ? cause - : new StorageError({ operation: 'upload part', cause }) - }); - return { partNumber: uploaded.partNumber, etag: uploaded.etag }; - }), - completeMultipart: Effect.fn('Blobs.completeMultipart')(function* ( - key, - uploadId, - parts - ) { - const object = yield* Effect.tryPromise({ - try: () => { - const upload = bucket.resumeMultipartUpload(key, uploadId); - return upload.complete( - parts.map((part) => ({ - partNumber: part.partNumber, - etag: part.etag - })) - ); - }, - catch: (cause) => - new StorageError({ operation: 'complete multipart upload', cause }) - }); - return { size: object.size, etag: object.httpEtag }; - }), - abortMultipart: Effect.fn('Blobs.abortMultipart')(function* ( - key, - uploadId - ) { - yield* Effect.tryPromise({ - try: () => bucket.resumeMultipartUpload(key, uploadId).abort(), - catch: (cause) => - new StorageError({ operation: 'abort multipart upload', cause }) - }); - }) + createMultipart: Effect.fn('Blobs.createMultipart')( + function* (key, contentType) { + const upload = yield* Effect.tryPromise({ + try: () => + bucket.createMultipartUpload(key, { + httpMetadata: { contentType } + }), + catch: (cause) => + new StorageError({ operation: 'create multipart upload', cause }) + }); + return { uploadId: upload.uploadId }; + } + ), + uploadPart: Effect.fn('Blobs.uploadPart')( + function* (key, uploadId, partNumber, body, size) { + const uploaded = yield* Effect.tryPromise({ + try: async () => { + const upload = bucket.resumeMultipartUpload(key, uploadId); + // FixedLengthStream pins the exact part length so a truncated + // or overlong body is rejected at the transform, matching the + // one-shot upload path. + if ( + body instanceof ReadableStream && + typeof FixedLengthStream !== 'undefined' + ) { + const { readable, writable } = new FixedLengthStream(size); + const pumped = body.pipeTo(writable); + const [part] = await Promise.all([ + upload.uploadPart(partNumber, readable), + pumped + ]); + return part; + } + const value = + body instanceof ReadableStream + ? await new Response(body).arrayBuffer() + : body; + if (value.byteLength !== size) { + throw new StorageError({ + operation: 'upload part', + cause: `Part ${partNumber} was ${value.byteLength} bytes, expected ${size}` + }); + } + return upload.uploadPart(partNumber, value); + }, + catch: (cause) => + cause instanceof StorageError + ? cause + : new StorageError({ operation: 'upload part', cause }) + }); + return { partNumber: uploaded.partNumber, etag: uploaded.etag }; + } + ), + completeMultipart: Effect.fn('Blobs.completeMultipart')( + function* (key, uploadId, parts) { + const object = yield* Effect.tryPromise({ + try: () => { + const upload = bucket.resumeMultipartUpload(key, uploadId); + return upload.complete( + parts.map((part) => ({ + partNumber: part.partNumber, + etag: part.etag + })) + ); + }, + catch: (cause) => + new StorageError({ operation: 'complete multipart upload', cause }) + }); + return { size: object.size, etag: object.httpEtag }; + } + ), + abortMultipart: Effect.fn('Blobs.abortMultipart')( + function* (key, uploadId) { + yield* Effect.tryPromise({ + try: () => bucket.resumeMultipartUpload(key, uploadId).abort(), + catch: (cause) => + new StorageError({ operation: 'abort multipart upload', cause }) + }); + } + ) }); }); diff --git a/apps/web/src/lib/server/services/lifecycle.ts b/apps/web/src/lib/server/services/lifecycle.ts index 1209538..ad0338e 100644 --- a/apps/web/src/lib/server/services/lifecycle.ts +++ b/apps/web/src/lib/server/services/lifecycle.ts @@ -98,9 +98,7 @@ const makeLifecycle = Effect.gen(function* () { // independent: an upload sweep failure cannot lose the purge count. files: Effect.zip( files.sweepPurges(5), - uploads - .sweep(10) - .pipe(Effect.catchCause(() => Effect.succeed(0))) + uploads.sweep(10).pipe(Effect.catchCause(() => Effect.succeed(0))) ).pipe(Effect.map(([purged, swept]) => purged + swept)), vectors: indexing.retryVectorDeletes(100) }).pipe(Effect.withSpan('Lifecycle.run')); diff --git a/apps/web/src/lib/server/services/sites/publish.ts b/apps/web/src/lib/server/services/sites/publish.ts index 1f6ddfe..fc47f69 100644 --- a/apps/web/src/lib/server/services/sites/publish.ts +++ b/apps/web/src/lib/server/services/sites/publish.ts @@ -70,7 +70,10 @@ export const publishOps = ( message: `A site can publish at most ${MAX_SITE_ASSETS} files` }); } - if (input.fileId === undefined && (input.displayName ?? '').trim() === '') { + if ( + input.fileId === undefined && + (input.displayName ?? '').trim() === '' + ) { return yield* new InvalidRequest({ status: 400, message: 'A site name is required for a new site' @@ -176,10 +179,9 @@ export const publishOps = ( ).pipe( Effect.andThen(session.commit(created.sessionId)), Effect.catch((failure) => - session.abort(created.sessionId).pipe( - Effect.ignore, - Effect.andThen(Effect.fail(failure)) - ) + session + .abort(created.sessionId) + .pipe(Effect.ignore, Effect.andThen(Effect.fail(failure))) ) ); }) diff --git a/apps/web/src/lib/server/services/uploads.ts b/apps/web/src/lib/server/services/uploads.ts index 48f2c09..7013b01 100644 --- a/apps/web/src/lib/server/services/uploads.ts +++ b/apps/web/src/lib/server/services/uploads.ts @@ -1,7 +1,4 @@ -import { - type FileSummary, - type UploadSessionCreate -} from '@adrive/shared'; +import { type FileSummary, type UploadSessionCreate } from '@adrive/shared'; import { Context, Effect, Layer, Schema } from 'effect'; import { validateExpiration } from '../auth-policy'; import { AppConfig } from '../config'; @@ -79,10 +76,12 @@ export interface UploadsShape { readonly create: ( input: UploadSessionCreate ) => Effect.Effect; - readonly uploadPart: ( - input: UploadPartInfo - ) => Effect.Effect< - { readonly partNumber: number; readonly etag: string; readonly sizeBytes: number }, + readonly uploadPart: (input: UploadPartInfo) => Effect.Effect< + { + readonly partNumber: number; + readonly etag: string; + readonly sizeBytes: number; + }, InvalidRequest | NotFound | StorageError >; readonly complete: ( @@ -310,9 +309,7 @@ const makeUploads = Effect.gen(function* () { ); const resolvedTags = yield* tags .resolveNames(decodeTagNames(session.tags)) - .pipe( - Effect.catchTag('InvalidRequest', () => Effect.succeed([])) - ); + .pipe(Effect.catchTag('InvalidRequest', () => Effect.succeed([]))); const now = new Date().toISOString(); const exists = `EXISTS (SELECT 1 FROM upload_sessions WHERE id = ? AND status = 'complete')`; const statements = [ @@ -368,9 +365,9 @@ const makeUploads = Effect.gen(function* () { .bind(session.file_id, tag.id, session.id) ), ...fileIndexStatements(db, session.file_id), - db.prepare('DELETE FROM upload_parts WHERE session_id = ?').bind( - session.id - ) + db + .prepare('DELETE FROM upload_parts WHERE session_id = ?') + .bind(session.id) ]; yield* Effect.tryPromise({ try: async () => { diff --git a/apps/web/src/lib/server/site-gallery.ts b/apps/web/src/lib/server/site-gallery.ts index 9e19b5f..16f0478 100644 --- a/apps/web/src/lib/server/site-gallery.ts +++ b/apps/web/src/lib/server/site-gallery.ts @@ -75,7 +75,9 @@ export const renderSiteIndex = ( title: string, assets: ReadonlyArray ) => { - const images = assets.filter((asset) => asset.contentType.startsWith('image/')); + const images = assets.filter((asset) => + asset.contentType.startsWith('image/') + ); const others = assets.filter( (asset) => !asset.contentType.startsWith('image/') ); diff --git a/apps/web/src/lib/server/token-crypto.ts b/apps/web/src/lib/server/token-crypto.ts index 1d49b2e..618e4a6 100644 --- a/apps/web/src/lib/server/token-crypto.ts +++ b/apps/web/src/lib/server/token-crypto.ts @@ -26,9 +26,7 @@ const toHex = (buffer: ArrayBuffer) => ).join(''); export const sha256Hex = (value: string) => - crypto.subtle - .digest('SHA-256', new TextEncoder().encode(value)) - .then(toHex); + crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)).then(toHex); const hexBytes = (value: string) => { const normalized = /^[0-9a-f]{64}$/i.test(value) ? value : '0'.repeat(64); diff --git a/apps/web/src/lib/server/token-scope.ts b/apps/web/src/lib/server/token-scope.ts index 18ab453..98c8641 100644 --- a/apps/web/src/lib/server/token-scope.ts +++ b/apps/web/src/lib/server/token-scope.ts @@ -18,7 +18,10 @@ export const restrictionMatches = ( ) => { if (!isRestricted(restriction)) return true; if (restriction.fileIds?.includes(fileId)) return true; - if (restriction.tagIds && tagIds.some((id) => restriction.tagIds!.includes(id))) + if ( + restriction.tagIds && + tagIds.some((id) => restriction.tagIds!.includes(id)) + ) return true; return false; }; @@ -27,7 +30,10 @@ export const restrictionMatches = ( // this a post-filter avoids threading token state into every keyset query; // scoped tokens are the exception, and short pages simply page again. export const filterFilesByScope = < - F extends { readonly id: string; readonly tags: ReadonlyArray<{ readonly id: string }> } + F extends { + readonly id: string; + readonly tags: ReadonlyArray<{ readonly id: string }>; + } >( credential: AuthorizedCredential, files: ReadonlyArray diff --git a/apps/web/src/lib/server/upload-session-policy.ts b/apps/web/src/lib/server/upload-session-policy.ts index bc0de57..b312c36 100644 --- a/apps/web/src/lib/server/upload-session-policy.ts +++ b/apps/web/src/lib/server/upload-session-policy.ts @@ -30,9 +30,7 @@ export const expectedPartSize = ( partSize: number, partCount: number ) => - partNumber < partCount - ? partSize - : sizeBytes - (partCount - 1) * partSize; + partNumber < partCount ? partSize : sizeBytes - (partCount - 1) * partSize; export const validateSessionSize = ( sizeBytes: number, @@ -65,10 +63,7 @@ export const validatePartNumber = (partNumber: number, partCount: number) => { } }; -export const validatePartLength = ( - header: string | null, - expected: number -) => { +export const validatePartLength = (header: string | null, expected: number) => { if (header === null) { throw new InvalidRequest({ status: 411, diff --git a/apps/web/src/routes/api/files/+server.ts b/apps/web/src/routes/api/files/+server.ts index 33e6f43..4a4aa12 100644 --- a/apps/web/src/routes/api/files/+server.ts +++ b/apps/web/src/routes/api/files/+server.ts @@ -15,7 +15,10 @@ import { AuthGuard } from '$lib/server/services/auth-guard'; import { Files } from '$lib/server/services/files'; import { Indexing } from '$lib/server/services/indexing'; import { Tags } from '$lib/server/services/tags'; -import { assertUnrestricted, filterFilesByScope } from '$lib/server/token-scope'; +import { + assertUnrestricted, + filterFilesByScope +} from '$lib/server/token-scope'; const decodeName = (value: string | null) => { if (value === null) { diff --git a/apps/web/src/routes/f/[id]/+server.ts b/apps/web/src/routes/f/[id]/+server.ts index a625b01..a6fd587 100644 --- a/apps/web/src/routes/f/[id]/+server.ts +++ b/apps/web/src/routes/f/[id]/+server.ts @@ -59,8 +59,7 @@ const serveFile: RequestHandler = ({ params, platform, request, url }) => if (share.passwordHash !== null) { const supplied = url.searchParams.get('p'); const unlocked = - supplied !== null && - (yield* shares.checkPassword(share, supplied)); + supplied !== null && (yield* shares.checkPassword(share, supplied)); if (!unlocked) { return sharePasswordPage(url, supplied !== null); } @@ -72,10 +71,8 @@ const serveFile: RequestHandler = ({ params, platform, request, url }) => const grantSecrets = yield* GrantSecrets; const version = requestedVersion(url); if (version === null) return yield* new NotFound({ id: params.id }); - const hasGrant = - url.searchParams.has('e') && url.searchParams.has('g'); - const thumbnailSource = - url.searchParams.get('purpose') === 'thumbnail'; + const hasGrant = url.searchParams.has('e') && url.searchParams.has('g'); + const thumbnailSource = url.searchParams.get('purpose') === 'thumbnail'; if (thumbnailSource && !hasGrant) { return yield* new NotFound({ id: params.id }); } diff --git a/packages/cli/src/commands/files.ts b/packages/cli/src/commands/files.ts index d961454..0f7f25f 100644 --- a/packages/cli/src/commands/files.ts +++ b/packages/cli/src/commands/files.ts @@ -167,13 +167,15 @@ const printUpload = (result: UploadResult) => } }); -const uploadCaps = (client: HttpClient.HttpClient, endpoint: string, apiKey: string) => - client - .execute(apiRequest('GET', `${endpoint}/api/files`, apiKey)) - .pipe( - Effect.flatMap(ensureOk), - Effect.flatMap((response) => decodeBody(FileListResponseSchema, response)) - ); +const uploadCaps = ( + client: HttpClient.HttpClient, + endpoint: string, + apiKey: string +) => + client.execute(apiRequest('GET', `${endpoint}/api/files`, apiKey)).pipe( + Effect.flatMap(ensureOk), + Effect.flatMap((response) => decodeBody(FileListResponseSchema, response)) + ); const oneShotUpload = ( client: HttpClient.HttpClient, @@ -339,11 +341,7 @@ export const put = Command.make( catch: (cause) => new CliFailure({ message: 'Could not size the file', cause }) }); - const caps = yield* uploadCaps( - client, - config.endpoint, - config.apiKey - ); + const caps = yield* uploadCaps(client, config.endpoint, config.apiKey); if (size > caps.maxStagedUploadBytes) { return yield* new CliFailure({ message: 'That is too large for this drive.' diff --git a/packages/cli/src/commands/keys.ts b/packages/cli/src/commands/keys.ts index 5f84ec8..6db76f5 100644 --- a/packages/cli/src/commands/keys.ts +++ b/packages/cli/src/commands/keys.ts @@ -93,15 +93,9 @@ export const keysCreate = Command.make( body: HttpBody.jsonUnsafe({ name, ...(scopeValue !== undefined ? { scope: scopeValue } : {}), - ...(Option.isSome(expires) - ? { expiresAt: expires.value } - : {}), - ...(allowedTagIds.length > 0 - ? { allowedTagIds } - : {}), - ...(allowedFileIds.length > 0 - ? { allowedFileIds } - : {}) + ...(Option.isSome(expires) ? { expiresAt: expires.value } : {}), + ...(allowedTagIds.length > 0 ? { allowedTagIds } : {}), + ...(allowedFileIds.length > 0 ? { allowedFileIds } : {}) }) } ) diff --git a/packages/cli/src/commands/shares.ts b/packages/cli/src/commands/shares.ts index 3f1e023..2cbd6fc 100644 --- a/packages/cli/src/commands/shares.ts +++ b/packages/cli/src/commands/shares.ts @@ -57,10 +57,7 @@ export const shareCreate = Command.make( ) ) .pipe(Effect.flatMap(ensureOk)); - const result = yield* decodeBody( - FileShareCreateResponseSchema, - response - ); + const result = yield* decodeBody(FileShareCreateResponseSchema, response); if (wantsJson()) { yield* emit(result); } else { @@ -70,9 +67,7 @@ export const shareCreate = Command.make( ); } }) -).pipe( - Command.withDescription('Create a durable private link for a file') -); +).pipe(Command.withDescription('Create a durable private link for a file')); export const shareList = Command.make( 'list', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5711e0d..3ebae3e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -385,8 +385,7 @@ export const FileShareCreateResponseSchema = Schema.Struct({ url: Schema.String }); -export type FileShareCreateResponse = - typeof FileShareCreateResponseSchema.Type; +export type FileShareCreateResponse = typeof FileShareCreateResponseSchema.Type; export const AuthCheckResponseSchema = Schema.Struct({ ok: Schema.Literal(true) From 9064cdc0f766e8a2b42872c685b6f10fc7ca4c9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 09:32:15 +0000 Subject: [PATCH 6/6] test: cover scoped-token fields in dashboard parser fixture Co-authored-by: Ben Davis --- apps/web/src/lib/dashboard/parse.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/dashboard/parse.test.ts b/apps/web/src/lib/dashboard/parse.test.ts index 43a9303..9c10a28 100644 --- a/apps/web/src/lib/dashboard/parse.test.ts +++ b/apps/web/src/lib/dashboard/parse.test.ts @@ -166,9 +166,16 @@ describe('dashboard response parsing', () => { createdAt: '2026-07-30T12:00:00.000Z', expiresAt: null, lastUsedAt: null, - revokedAt: null + revokedAt: null, + allowedTagIds: null, + allowedFileIds: null }; expect(parseApiKeyListResponse({ keys: [key] }).keys[0]?.name).toBe('cli'); + expect( + parseApiKeyListResponse({ + keys: [{ ...key, allowedTagIds: ['tag-a'], allowedFileIds: null }] + }).keys[0]?.allowedTagIds + ).toEqual(['tag-a']); expect(parseApiKeyCreateResponse({ key, token: 'adr_secret' }).token).toBe( 'adr_secret' );