From e3ec29adef319a4bafbe74c6734251f3a4f0f89c Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 09:43:26 -0300 Subject: [PATCH 01/12] Fix macOS tray file uploads --- .../FastSharedApp/Scenes/MacMenuBarView.swift | 160 ++++++++++-------- 1 file changed, 87 insertions(+), 73 deletions(-) diff --git a/apple/FastSharedApp/Scenes/MacMenuBarView.swift b/apple/FastSharedApp/Scenes/MacMenuBarView.swift index b3f0bb0..d6e6acb 100644 --- a/apple/FastSharedApp/Scenes/MacMenuBarView.swift +++ b/apple/FastSharedApp/Scenes/MacMenuBarView.swift @@ -367,25 +367,12 @@ final class TrayManager: NSObject { let po = NSPopover() po.behavior = .transient po.contentSize = NSSize(width: 360, height: 400) - let host = NSHostingController(rootView: rootView) + let host = NSViewController() + let hostView = PopoverDropHostingView(rootView: rootView) + hostView.manager = self + host.view = hostView po.contentViewController = host self.popover = po - - // WHY: SwiftUI .onDrop inside an NSPopover is unreliable on macOS - // (the drag session that started on the Finder window sometimes - // refuses to transfer into the popover's content window). We add a - // transparent AppKit overlay that implements NSDraggingDestination. - // CRITICAL: add to host.view.superview, NOT host.view itself — - // adding subviews to NSHostingController.view is unsupported and - // triggers layout recursion (rdar://FB9867432). - DispatchQueue.main.async { [weak self] in - guard let self else { return } - guard let container = host.view.superview else { return } - let dropOverlay = PopoverDropOverlay(frame: container.bounds) - dropOverlay.autoresizingMask = [.width, .height] - dropOverlay.manager = self - container.addSubview(dropOverlay) - } } // MARK: - Popover control @@ -553,11 +540,7 @@ private final class TrayIconView: NSView { super.init(frame: frameRect) // Register both modern (public.file-url) and legacy (NSFilenamesPboardType) // types so Finder drags are always recognised. - registerForDraggedTypes([ - NSPasteboard.PasteboardType.fileURL, - NSPasteboard.PasteboardType("NSFilenamesPboardType"), - NSPasteboard.PasteboardType.URL - ]) + registerForDraggedTypes(PasteboardFileURLs.draggedTypes) } required init?(coder: NSCoder) { @@ -577,19 +560,12 @@ private final class TrayIconView: NSView { // MARK: NSDraggingDestination - // MARK: NSDraggingDestination - override func wantsPeriodicDraggingUpdates() -> Bool { true } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - let pasteboard = sender.draggingPasteboard - let hasFiles = pasteboard.canReadObject(forClasses: [NSURL.self], - options: [.urlReadingFileURLsOnly: true]) - || pasteboard.types?.contains(NSPasteboard.PasteboardType("NSFilenamesPboardType")) ?? false - - if hasFiles { + if PasteboardFileURLs.canReadFileURLs(from: sender.draggingPasteboard) { manager?.beginDragSession() return .copy } @@ -609,17 +585,7 @@ private final class TrayIconView: NSView { guard let mgr = manager else { return false } - let pasteboard = sender.draggingPasteboard - var fileURLs: [URL] = [] - - if let urls = pasteboard.readObjects(forClasses: [NSURL.self], - options: [.urlReadingFileURLsOnly: true]) as? [URL] { - fileURLs.append(contentsOf: urls) - } - if fileURLs.isEmpty, - let propertyList = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String] { - fileURLs = propertyList.map { URL(fileURLWithPath: $0) } - } + let fileURLs = PasteboardFileURLs.read(from: sender.draggingPasteboard) guard !fileURLs.isEmpty else { return false } mgr.handleTrayDrop(urls: fileURLs) @@ -627,58 +593,106 @@ private final class TrayIconView: NSView { } } -// MARK: - PopoverDropOverlay +// MARK: - PopoverDropHostingView -/// Transparent overlay added to the NSPopover's content view so drops -/// that enter the popover window are captured at the AppKit level and -/// forwarded to the upload pipeline. Works around SwiftUI .onDrop -/// reliability issues inside NSPopover on macOS. -private final class PopoverDropOverlay: NSView { +/// Hosting view registered as the popover's AppKit drag destination. +/// SwiftUI `.onDrop` inside an `NSPopover` is unreliable for Finder drags, and +/// installing an overlay before the popover is shown races with view creation. +private final class PopoverDropHostingView: NSHostingView { weak var manager: TrayManager? - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - registerForDraggedTypes([ - NSPasteboard.PasteboardType.fileURL, - NSPasteboard.PasteboardType("NSFilenamesPboardType"), - NSPasteboard.PasteboardType.URL - ]) + required init(rootView: Root) { + super.init(rootView: rootView) + registerForDraggedTypes(PasteboardFileURLs.draggedTypes) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - // Allow mouse clicks to pass through to SwiftUI buttons underneath. - // We only need this overlay for drag-and-drop, not for hit-testing. - override func hitTest(_ point: NSPoint) -> NSView? { - nil - } - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - return .copy + PasteboardFileURLs.canReadFileURLs(from: sender.draggingPasteboard) ? .copy : [] } override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - .copy + PasteboardFileURLs.canReadFileURLs(from: sender.draggingPasteboard) ? .copy : [] } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - let pasteboard = sender.draggingPasteboard - var fileURLs: [URL] = [] + let fileURLs = PasteboardFileURLs.read(from: sender.draggingPasteboard) + guard !fileURLs.isEmpty else { return false } + manager?.handleTrayDrop(urls: fileURLs) + return true + } +} - if let urls = pasteboard.readObjects(forClasses: [NSURL.self], - options: [.urlReadingFileURLsOnly: true]) as? [URL] { - fileURLs.append(contentsOf: urls) +// MARK: - PasteboardFileURLs + +private enum PasteboardFileURLs { + private static let legacyFilenamesType = NSPasteboard.PasteboardType("NSFilenamesPboardType") + + static let draggedTypes: [NSPasteboard.PasteboardType] = [ + .fileURL, + legacyFilenamesType, + .URL + ] + + static func canReadFileURLs(from pasteboard: NSPasteboard) -> Bool { + pasteboard.canReadObject(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) + || (pasteboard.types?.contains(legacyFilenamesType) ?? false) + || fileURLString(from: pasteboard) != nil + } + + static func read(from pasteboard: NSPasteboard) -> [URL] { + var urls: [URL] = [] + + let objects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) ?? [] + for object in objects { + if let url = object as? URL, url.isFileURL { + urls.append(url) + } else if let nsURL = object as? NSURL { + let url = nsURL as URL + if url.isFileURL { + urls.append(url) + } + } } - if fileURLs.isEmpty, - let propertyList = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String] { - fileURLs = propertyList.map { URL(fileURLWithPath: $0) } + + if urls.isEmpty, + let paths = pasteboard.propertyList(forType: legacyFilenamesType) as? [String] { + urls = paths.map { URL(fileURLWithPath: $0) } } - guard !fileURLs.isEmpty else { return false } - manager?.handleTrayDrop(urls: fileURLs) - return true + if urls.isEmpty, let url = fileURLString(from: pasteboard) { + urls.append(url) + } + + return deduplicated(urls) + } + + private static func fileURLString(from pasteboard: NSPasteboard) -> URL? { + for type in [NSPasteboard.PasteboardType.fileURL, NSPasteboard.PasteboardType.URL] { + guard let raw = pasteboard.string(forType: type), + let url = URL(string: raw), + url.isFileURL else { continue } + return url + } + return nil + } + + private static func deduplicated(_ urls: [URL]) -> [URL] { + var seen = Set() + var result: [URL] = [] + for url in urls { + let key = url.standardizedFileURL.path + if seen.insert(key).inserted { + result.append(url) + } + } + return result } } #endif From 3c506cb8d53befe10bfe936a0984fc15fb4475fd Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 15:46:05 -0300 Subject: [PATCH 02/12] Add one-minute retention policy to backend --- backend/src/db/schema.ts | 9 ++- backend/src/lib/retention.ts | 2 + backend/src/middleware/rateLimitFreeTier.ts | 2 + backend/src/routes/redirect.ts | 4 +- backend/src/routes/uploads.ts | 68 ++++++++++++++++++++- backend/src/services/shareLinks.ts | 4 +- backend/test/rateLimitFreeTier.test.ts | 22 +++++++ backend/test/redirect.test.ts | 17 +++++- backend/test/uploads.test.ts | 57 +++++++++++++++++ 9 files changed, 177 insertions(+), 8 deletions(-) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 665bee9..74df65f 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -101,7 +101,14 @@ export const asset = pgTable( export const LINK_STATUSES = ['pending', 'active', 'expired', 'revoked'] as const; export type LinkStatus = (typeof LINK_STATUSES)[number]; -export const RETENTION_POLICIES = ['oneHour', 'oneDay', 'oneWeek', 'oneMonth', 'custom'] as const; +export const RETENTION_POLICIES = [ + 'oneMinute', + 'oneHour', + 'oneDay', + 'oneWeek', + 'oneMonth', + 'custom', +] as const; export type RetentionPolicy = (typeof RETENTION_POLICIES)[number]; export const shareLink = pgTable( diff --git a/backend/src/lib/retention.ts b/backend/src/lib/retention.ts index 6b200cd..9210b3b 100644 --- a/backend/src/lib/retention.ts +++ b/backend/src/lib/retention.ts @@ -10,6 +10,7 @@ const MIN_CUSTOM_SECONDS = 300; const MAX_CUSTOM_SECONDS = 2_592_000; // 30 days const PRESET_SECONDS: Record, number> = { + oneMinute: 60, oneHour: 3_600, oneDay: 86_400, oneWeek: 604_800, @@ -40,6 +41,7 @@ export function resolveRetention( function asRetentionPolicy(value: string): RetentionPolicy { switch (value) { + case 'oneMinute': case 'oneHour': case 'oneDay': case 'oneWeek': diff --git a/backend/src/middleware/rateLimitFreeTier.ts b/backend/src/middleware/rateLimitFreeTier.ts index afebcb3..3814af4 100644 --- a/backend/src/middleware/rateLimitFreeTier.ts +++ b/backend/src/middleware/rateLimitFreeTier.ts @@ -165,6 +165,8 @@ export function rateLimitFreeTier(): MiddlewareHandler { function ttlSecondsForPolicy(policy: string, customTtlSeconds?: number): number | null { switch (policy) { + case 'oneMinute': + return 60; case 'oneHour': return 3600; case 'oneDay': diff --git a/backend/src/routes/redirect.ts b/backend/src/routes/redirect.ts index 66d67c0..f56d2b7 100644 --- a/backend/src/routes/redirect.ts +++ b/backend/src/routes/redirect.ts @@ -69,8 +69,6 @@ async function loadActiveLinkAndAsset( const link = await findByToken(db, token); if (!link) return { status: 'not_found' }; if (link.linkStatus === 'revoked') return { status: 'gone', reason: 'revoked' }; - // Pending: asset hasn't landed yet. Recipient sees the "Uploading…" page. - if (link.linkStatus === 'pending') return { status: 'pending', link }; const now = Date.now(); if (link.expiresAt.getTime() <= now) { // Lazy flip so the stored state matches reality for observers. @@ -85,6 +83,8 @@ async function loadActiveLinkAndAsset( ); return { status: 'gone', reason: 'expired' }; } + // Pending: asset hasn't landed yet. Recipient sees the "Uploading…" page. + if (link.linkStatus === 'pending') return { status: 'pending', link }; // assetId is nullable on the schema (pending rows), but any non-pending // state must have an asset — if it's missing the link is effectively gone. if (!link.assetId) return { status: 'gone', reason: 'deleted' }; diff --git a/backend/src/routes/uploads.ts b/backend/src/routes/uploads.ts index f7ea6c6..ff861f2 100644 --- a/backend/src/routes/uploads.ts +++ b/backend/src/routes/uploads.ts @@ -34,7 +34,14 @@ import { import { log } from '~/lib/logger'; import { hmacSha256Hex } from '~/lib/hash'; -const RETENTION_POLICIES = ['oneHour', 'oneDay', 'oneWeek', 'oneMonth', 'custom'] as const; +const RETENTION_POLICIES = [ + 'oneMinute', + 'oneHour', + 'oneDay', + 'oneWeek', + 'oneMonth', + 'custom', +] as const; // Tier 2: files larger than this threshold go through R2 multipart (parallel // PUTs). Threshold and part size are frozen by the plan — don't tune here. @@ -409,6 +416,18 @@ uploadRoutes.post('/:uploadId/complete', async (c) => { originalFilename: body.originalFilename, }); + if (job.expiresAt.getTime() <= Date.now()) { + await markCompleteTooLate(c, db, job, storageKey, uploadId); + return problem( + c, + 409, + 'complete_too_late', + 'Conflict', + 'share link expired before upload completed', + { expiresAt: job.expiresAt.toISOString() }, + ); + } + // Tier 2: multipart finalization. Enforce a bidirectional contract between // client and server — whatever path presign took, /complete must honor the // same shape. Mixing paths is a client bug, so 400. @@ -961,6 +980,8 @@ async function enforceBatchFreeTierLimits( function retentionExceedsFreeCap(policy: string, customTtlSeconds: number | undefined): boolean { const maxSeconds = FREE_CAPS.maxRetentionHours * 3600; switch (policy) { + case 'oneMinute': + return false; case 'oneHour': return false; case 'oneDay': @@ -976,6 +997,51 @@ function retentionExceedsFreeCap(policy: string, customTtlSeconds: number | unde } } +async function markCompleteTooLate( + c: Context, + db: Db, + job: LoadedUploadJob, + storageKey: string, + uploadId: string, +): Promise { + await db + .update(uploadJob) + .set({ status: 'failed', errorCode: 'complete_too_late', updatedAt: sql`now()` }) + .where(eq(uploadJob.id, uploadId)); + + if (job.pendingShareLinkToken) { + await db + .update(shareLink) + .set({ linkStatus: 'expired' }) + .where( + and(eq(shareLink.token, job.pendingShareLinkToken), eq(shareLink.linkStatus, 'pending')), + ); + } + + c.executionCtx.waitUntil( + cleanupExpiredUpload(c, storageKey, job.multipartUploadId).catch((err) => { + log.warn({ + msg: 'complete_too_late_cleanup_failed', + requestId: c.get('requestId'), + uploadId, + error: err instanceof Error ? err.message : String(err), + }); + }), + ); +} + +async function cleanupExpiredUpload( + c: Context, + storageKey: string, + multipartUploadId: string | null, +): Promise { + if (multipartUploadId) { + await abortMultipartUpload(c.env, storageKey, multipartUploadId); + return; + } + await deleteObject({ env: c.env, key: storageKey }); +} + function nextUtcMidnight(now: Date = new Date()): Date { const d = new Date(now); d.setUTCHours(24, 0, 0, 0); diff --git a/backend/src/services/shareLinks.ts b/backend/src/services/shareLinks.ts index 0f7306b..8ab5b2b 100644 --- a/backend/src/services/shareLinks.ts +++ b/backend/src/services/shareLinks.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import type { Db } from '~/db/client'; import { asset, shareLink, type ShareLink, type NewShareLink } from '~/db/schema'; import { generateToken, TOKEN_LENGTH } from '~/lib/tokens'; @@ -91,7 +91,7 @@ export async function markExpiredByToken(db: Db, token: string): Promise { await db .update(shareLink) .set({ linkStatus: 'expired' }) - .where(and(eq(shareLink.token, token), eq(shareLink.linkStatus, 'active'))); + .where(and(eq(shareLink.token, token), inArray(shareLink.linkStatus, ['active', 'pending']))); } // Single UPDATE keeps the hot redirect path to one round trip. diff --git a/backend/test/rateLimitFreeTier.test.ts b/backend/test/rateLimitFreeTier.test.ts index fd0b856..0519277 100644 --- a/backend/test/rateLimitFreeTier.test.ts +++ b/backend/test/rateLimitFreeTier.test.ts @@ -195,6 +195,28 @@ describe('rateLimitFreeTier', () => { expect(store.uploadJobs[0]!.retentionPolicy).toBe('oneDay'); }); + it('Free retentionPolicy=oneMinute is allowed without clamp', async () => { + const { token } = await seedDevice(); + const res = await post( + '/v1/uploads', + { + clientJobId: crypto.randomUUID(), + contentType: 'image/jpeg', + sizeBytes: 2048, + retentionPolicy: 'oneMinute', + }, + { authorization: `Bearer ${token}` }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + retentionPolicy: string; + retentionClamped?: boolean; + }; + expect(body.retentionPolicy).toBe('oneMinute'); + expect(body.retentionClamped).toBeUndefined(); + expect(store.uploadJobs[0]!.retentionPolicy).toBe('oneMinute'); + }); + it('Pro uploads bypass the daily cap even at 1 GB', async () => { const { deviceId, token } = await seedDevice(); seedActiveSub(deviceId); diff --git a/backend/test/redirect.test.ts b/backend/test/redirect.test.ts index 6bc0cc3..7ee19d2 100644 --- a/backend/test/redirect.test.ts +++ b/backend/test/redirect.test.ts @@ -292,7 +292,10 @@ describe('redirect proxy', () => { // Tier 1 — pending share_link. Seeds a pending row with no asset (null // assetId — asset is created only at /complete). - function seedPendingLink(token = 'pendingTokenAAAAAAAAAA'): string { + function seedPendingLink( + token = 'pendingTokenAAAAAAAAAA', + expiresAt = new Date(Date.now() + 3_600_000), + ): string { store.shareLinks.push({ id: crypto.randomUUID(), token, @@ -300,7 +303,7 @@ describe('redirect proxy', () => { // assetId; real schema column is nullable for pending rows. assetId: null as unknown as string, visibility: 'signed', - expiresAt: new Date(Date.now() + 3_600_000), + expiresAt, hits: 0, linkStatus: 'pending', retentionPolicy: 'oneDay', @@ -323,6 +326,16 @@ describe('redirect proxy', () => { expect(body).toMatch(/ { + const token = seedPendingLink( + 'pendingExpiredTokenAAAAA', + new Date(Date.now() - 1_000), + ); + const res = await get(`/s/${token}`, { accept: 'text/html' }); + expect(res.status).toBe(410); + expect(store.shareLinks.find((l) => l.token === token)?.linkStatus).toBe('expired'); + }); + it('GET /s/:token/raw on pending link returns 404 (file not in R2 yet)', async () => { const token = seedPendingLink('pendingRawTokenAAAAAAA'); const res = await get(`/s/${token}/raw`, { accept: '*/*' }); diff --git a/backend/test/uploads.test.ts b/backend/test/uploads.test.ts index a867993..f0fd003 100644 --- a/backend/test/uploads.test.ts +++ b/backend/test/uploads.test.ts @@ -47,6 +47,7 @@ interface UploadJob { clientJobId: string; assetId: string | null; status: string; + errorCode: string | null; retentionPolicy: string | null; expiresAt: Date | null; deleteAfter: Date | null; @@ -267,6 +268,7 @@ vi.mock('~/db/client', () => { clientJobId: v.clientJobId as string, assetId: (v.assetId as string | null) ?? null, status: v.status as string, + errorCode: (v.errorCode as string | null) ?? null, retentionPolicy: (v.retentionPolicy as string | null) ?? null, expiresAt: (v.expiresAt as Date | null) ?? null, deleteAfter: (v.deleteAfter as Date | null) ?? null, @@ -616,6 +618,27 @@ describe('uploads flow', () => { expect(new Date(body.expiresAt).getTime()).toBeLessThanOrEqual(expected + 60_000); }); + it('presign oneMinute applies a 60 second retention window', async () => { + const { token } = await seedDevice(); + const t0 = Date.now(); + const res = await post( + '/v1/uploads', + { + clientJobId: crypto.randomUUID(), + contentType: 'image/jpeg', + sizeBytes: 1024, + retentionPolicy: 'oneMinute', + }, + { authorization: `Bearer ${token}` }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { expiresAt: string; retentionPolicy: string }; + expect(body.retentionPolicy).toBe('oneMinute'); + const deltaMs = new Date(body.expiresAt).getTime() - t0; + expect(deltaMs).toBeGreaterThanOrEqual(55_000); + expect(deltaMs).toBeLessThanOrEqual(65_000); + }); + it('duplicate clientJobId returns same uploadId', async () => { const { token } = await seedDevice(); const clientJobId = crypto.randomUUID(); @@ -898,6 +921,40 @@ describe('uploads flow', () => { expect(row?.assetId).toBe(body.assetId); }); + it('/complete returns complete_too_late when the pending short link already expired', async () => { + const { token } = await seedDevice(); + const presign = await post( + '/v1/uploads', + { + clientJobId: crypto.randomUUID(), + contentType: 'image/jpeg', + sizeBytes: 1024, + retentionPolicy: 'oneMinute', + }, + { authorization: `Bearer ${token}` }, + ); + const { uploadId, token: presignToken } = (await presign.json()) as { + uploadId: string; + token: string; + }; + const job = store.uploadJobs.find((j) => j.id === uploadId); + expect(job).toBeDefined(); + job!.expiresAt = new Date(Date.now() - 1_000); + + const res = await post( + `/v1/uploads/${uploadId}/complete`, + { contentType: 'image/jpeg', sizeBytes: 1024, sha256: 'c'.repeat(64) }, + { authorization: `Bearer ${token}` }, + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { code?: string }; + expect(body.code).toBe('complete_too_late'); + expect(job!.status).toBe('failed'); + expect(job!.errorCode).toBe('complete_too_late'); + expect(store.shareLinks.find((l) => l.token === presignToken)?.linkStatus).toBe('expired'); + expect(headMock).not.toHaveBeenCalled(); + }); + // Tier 2 — multipart uploads it('presign with sizeBytes > 10MB returns a multipart plan', async () => { const { token } = await seedDevice(); From a2be9778262f779f2c3ec26d81c06d3a6b0487a1 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 15:46:50 -0300 Subject: [PATCH 03/12] Expose one-minute retention in Apple client --- apple/FastSharedApp/Scenes/HistoryView.swift | 1 + apple/FastSharedApp/Scenes/LibraryView.swift | 1 + apple/FastSharedApp/Scenes/SettingsView.swift | 1 + apple/FastSharedShareExt/ShareRootView.swift | 11 +++++++---- .../LiveActivity/BundleUploadAttributes.swift | 1 + .../LiveActivity/FastSharedActivityAttributes.swift | 3 ++- .../FastSharedCore/Models/RetentionPolicy.swift | 5 ++++- .../Subscription/GatedRetentionPicker.swift | 1 + .../FastSharedCoreTests/RetentionPolicyTests.swift | 4 +++- 9 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apple/FastSharedApp/Scenes/HistoryView.swift b/apple/FastSharedApp/Scenes/HistoryView.swift index 7e4bb45..ce545cc 100644 --- a/apple/FastSharedApp/Scenes/HistoryView.swift +++ b/apple/FastSharedApp/Scenes/HistoryView.swift @@ -391,6 +391,7 @@ struct HistoryView: View { case .retentionTooLong(let seconds, _): let policy: RetentionPolicy = { switch seconds { + case 0...60: return .oneMinute case 0...3600: return .oneHour case 0...86_400: return .oneDay case 0...604_800: return .oneWeek diff --git a/apple/FastSharedApp/Scenes/LibraryView.swift b/apple/FastSharedApp/Scenes/LibraryView.swift index 43a1db0..b2ee5e0 100644 --- a/apple/FastSharedApp/Scenes/LibraryView.swift +++ b/apple/FastSharedApp/Scenes/LibraryView.swift @@ -366,6 +366,7 @@ struct LibraryView: View { case .retentionTooLong(let seconds, _): let policy: RetentionPolicy = { switch seconds { + case 0...60: return .oneMinute case 0...3600: return .oneHour case 0...86_400: return .oneDay case 0...604_800: return .oneWeek diff --git a/apple/FastSharedApp/Scenes/SettingsView.swift b/apple/FastSharedApp/Scenes/SettingsView.swift index 5610810..ce6e898 100644 --- a/apple/FastSharedApp/Scenes/SettingsView.swift +++ b/apple/FastSharedApp/Scenes/SettingsView.swift @@ -844,6 +844,7 @@ private struct FriendlyToggle: View { private extension RetentionPolicy { var shortLabel: String { switch self { + case .oneMinute: return "60s" case .oneHour: return "1h" case .oneDay: return "24h" case .oneWeek: return "1w" diff --git a/apple/FastSharedShareExt/ShareRootView.swift b/apple/FastSharedShareExt/ShareRootView.swift index e911232..0887f19 100644 --- a/apple/FastSharedShareExt/ShareRootView.swift +++ b/apple/FastSharedShareExt/ShareRootView.swift @@ -740,10 +740,11 @@ private struct IdleStage: View { private func chipLabel(for policy: RetentionPolicy) -> String { switch policy { - case .oneHour: return "1 hour" - case .oneDay: return "24 hours" - case .oneWeek: return "3 days" - case .oneMonth: return "7 days" + case .oneMinute: return "60s" + case .oneHour: return "1h" + case .oneDay: return "24h" + case .oneWeek: return "7d" + case .oneMonth: return "30d" default: return policy.displayName } } @@ -937,6 +938,7 @@ private struct SuccessStage: View { private var retentionBodyText: String { switch retention { + case .oneMinute: return "60 seconds" case .oneHour: return "1 hour" case .oneDay: return "24 hours" case .oneWeek: return "7 days" @@ -1137,6 +1139,7 @@ private struct BundleSuccessStage: View { private var retentionBodyText: String { switch retention { + case .oneMinute: return "60 seconds" case .oneHour: return "1 hour" case .oneDay: return "24 hours" case .oneWeek: return "7 days" diff --git a/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/BundleUploadAttributes.swift b/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/BundleUploadAttributes.swift index 0b86b50..2da7626 100644 --- a/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/BundleUploadAttributes.swift +++ b/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/BundleUploadAttributes.swift @@ -69,6 +69,7 @@ public extension BundleUploadAttributes { /// can use the same pill component without branching on attribute type. var retentionBadge: String { switch retentionPolicy { + case RetentionPolicy.oneMinute.rawValue: return "60s" case RetentionPolicy.oneHour.rawValue: return "1h" case RetentionPolicy.oneDay.rawValue: return "24h" case RetentionPolicy.oneWeek.rawValue: return "7d" diff --git a/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/FastSharedActivityAttributes.swift b/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/FastSharedActivityAttributes.swift index 9ce646b..6b24715 100644 --- a/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/FastSharedActivityAttributes.swift +++ b/apple/Packages/FastSharedCore/Sources/FastSharedCore/LiveActivity/FastSharedActivityAttributes.swift @@ -58,9 +58,10 @@ public struct FastSharedActivityAttributes: ActivityAttributes, Sendable, Hashab } public extension FastSharedActivityAttributes { - /// Convenience for the retention badge ("1h" / "24h" / "7d" / "30d"). + /// Convenience for the retention badge ("60s" / "1h" / "24h" / "7d" / "30d"). var retentionBadge: String { switch retentionPolicy { + case RetentionPolicy.oneMinute.rawValue: return "60s" case RetentionPolicy.oneHour.rawValue: return "1h" case RetentionPolicy.oneDay.rawValue: return "24h" case RetentionPolicy.oneWeek.rawValue: return "7d" diff --git a/apple/Packages/FastSharedCore/Sources/FastSharedCore/Models/RetentionPolicy.swift b/apple/Packages/FastSharedCore/Sources/FastSharedCore/Models/RetentionPolicy.swift index c480ceb..7878d0b 100644 --- a/apple/Packages/FastSharedCore/Sources/FastSharedCore/Models/RetentionPolicy.swift +++ b/apple/Packages/FastSharedCore/Sources/FastSharedCore/Models/RetentionPolicy.swift @@ -1,6 +1,7 @@ import Foundation public enum RetentionPolicy: String, CaseIterable, Sendable, Codable { + case oneMinute case oneHour case oneDay case oneWeek @@ -9,6 +10,7 @@ public enum RetentionPolicy: String, CaseIterable, Sendable, Codable { public var ttlSeconds: TimeInterval { switch self { + case .oneMinute: return 60 case .oneHour: return 3600 case .oneDay: return 86_400 case .oneWeek: return 604_800 @@ -19,6 +21,7 @@ public enum RetentionPolicy: String, CaseIterable, Sendable, Codable { public var displayName: String { switch self { + case .oneMinute: return "60 seconds" case .oneHour: return "1 hour" case .oneDay: return "1 day" case .oneWeek: return "1 week" @@ -31,7 +34,7 @@ public enum RetentionPolicy: String, CaseIterable, Sendable, Codable { // WHY: .custom is intentionally excluded from the share extension picker in MVP; power users // can come later via a settings-level override. - public static let shareable: [RetentionPolicy] = [.oneHour, .oneDay, .oneWeek, .oneMonth] + public static let shareable: [RetentionPolicy] = [.oneMinute, .oneHour, .oneDay, .oneWeek, .oneMonth] /// Reads the user's preferred retention from the shared App Group suite, falling back to `.default`. /// Used by surfaces that run outside the SwiftUI environment (App Intents, headless entry points). diff --git a/apple/Packages/FastSharedCore/Sources/FastSharedCore/Subscription/GatedRetentionPicker.swift b/apple/Packages/FastSharedCore/Sources/FastSharedCore/Subscription/GatedRetentionPicker.swift index 16f8e9e..cde6b06 100644 --- a/apple/Packages/FastSharedCore/Sources/FastSharedCore/Subscription/GatedRetentionPicker.swift +++ b/apple/Packages/FastSharedCore/Sources/FastSharedCore/Subscription/GatedRetentionPicker.swift @@ -80,6 +80,7 @@ public struct GatedRetentionPicker: View { private func shortLabel(for policy: RetentionPolicy) -> String { switch policy { + case .oneMinute: return "60s" case .oneHour: return "1h" case .oneDay: return "24h" case .oneWeek: return "1w" diff --git a/apple/Packages/FastSharedCore/Tests/FastSharedCoreTests/RetentionPolicyTests.swift b/apple/Packages/FastSharedCore/Tests/FastSharedCoreTests/RetentionPolicyTests.swift index eda3a51..cee1dc4 100644 --- a/apple/Packages/FastSharedCore/Tests/FastSharedCoreTests/RetentionPolicyTests.swift +++ b/apple/Packages/FastSharedCore/Tests/FastSharedCoreTests/RetentionPolicyTests.swift @@ -11,6 +11,7 @@ final class RetentionPolicyTests: XCTestCase { } func test_all_ttls_are_reasonable() { + XCTAssertEqual(RetentionPolicy.oneMinute.ttlSeconds, 60) XCTAssertEqual(RetentionPolicy.oneHour.ttlSeconds, 3600) XCTAssertEqual(RetentionPolicy.oneWeek.ttlSeconds, 604_800) XCTAssertEqual(RetentionPolicy.oneMonth.ttlSeconds, 2_592_000) @@ -18,6 +19,7 @@ final class RetentionPolicyTests: XCTestCase { } func test_display_names_are_human() { + XCTAssertEqual(RetentionPolicy.oneMinute.displayName, "60 seconds") XCTAssertEqual(RetentionPolicy.oneHour.displayName, "1 hour") XCTAssertEqual(RetentionPolicy.oneDay.displayName, "1 day") XCTAssertEqual(RetentionPolicy.oneWeek.displayName, "1 week") @@ -26,7 +28,7 @@ final class RetentionPolicyTests: XCTestCase { } func test_shareable_excludes_custom() { - XCTAssertEqual(RetentionPolicy.shareable, [.oneHour, .oneDay, .oneWeek, .oneMonth]) + XCTAssertEqual(RetentionPolicy.shareable, [.oneMinute, .oneHour, .oneDay, .oneWeek, .oneMonth]) XCTAssertFalse(RetentionPolicy.shareable.contains(.custom)) } From 93b4eb2e19602cf45d6a50ea96471c2fbda566e9 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 15:47:13 -0300 Subject: [PATCH 04/12] Document one-minute retention policy --- apple/fastlane/metadata/en-US/description.txt | 2 +- docs/architecture/apple-client.md | 6 +++--- docs/architecture/backend.md | 4 ++-- docs/architecture/data-model.md | 6 +++--- docs/architecture/system-design.md | 2 +- docs/architecture/upload-flow.md | 14 +++++++------- docs/plan/implementation-checklist.md | 6 +++--- docs/plan/mvp-roadmap.md | 2 +- docs/plan/pro-feature-B-apple.md | 2 +- docs/product/overview.md | 2 +- 10 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apple/fastlane/metadata/en-US/description.txt b/apple/fastlane/metadata/en-US/description.txt index 48e6837..1e9e998 100644 --- a/apple/fastlane/metadata/en-US/description.txt +++ b/apple/fastlane/metadata/en-US/description.txt @@ -5,7 +5,7 @@ FastShared is a native Apple utility that turns "share a file" into "get a tempo WHAT IT DOES - Share any file from any app through the iOS share sheet. - Drag a file onto the Mac app, or paste anything with Command-V. -- Pick how long the link lives: 1 hour, 24 hours, 1 week, 1 month, or custom from 5 minutes to 30 days. +- Pick how long the link lives: 60 seconds, 1 hour, 24 hours, 1 week, 1 month, or custom from 5 minutes to 30 days. - The short link lands on your clipboard. Paste it anywhere. - Watch upload progress in a Live Activity and Dynamic Island on supported iPhones. - Browse recent links with a live countdown. Revoke any link instantly. diff --git a/docs/architecture/apple-client.md b/docs/architecture/apple-client.md index cce4a41..40d67f4 100644 --- a/docs/architecture/apple-client.md +++ b/docs/architecture/apple-client.md @@ -58,7 +58,7 @@ Handoff: 1. `NSExtensionPrincipalClass` is `ShareViewController` with `NSExtensionMainStoryboard` disabled. 2. `viewDidLoad` walks `extensionContext?.inputItems` for `NSItemProvider` instances conforming to concrete UTTypes (`public.image`, `public.movie`, `public.file-url`, `com.adobe.pdf`, etc.). 3. For each matching provider, we call `loadFileRepresentation(forTypeIdentifier:completionHandler:)` and stream the result to `App Group/Caches/Staging/`. During the stream we compute SHA-256 incrementally using `CryptoKit.SHA256`. -4. **User selects a retention policy** (default `oneDay`, read from Settings). The picker shows the four presets (`oneHour`, `oneDay`, `oneWeek`, `oneMonth`) with sub-second tap targets. The chosen policy is carried into `PresignRequest`. +4. **User selects a retention policy** (default `oneDay`, read from Settings). The picker shows the five presets (`oneMinute`, `oneHour`, `oneDay`, `oneWeek`, `oneMonth`) with sub-second tap targets. The chosen policy is carried into `PresignRequest`. 5. Insert an `UploadJobEntity(state: .pending, sha256:, size:, mime:, stagedPath:, clientJobId: UUID(), retentionPolicy:)` into the shared SwiftData store. 6. Call `POST /v1/uploads` via `APIClient` with the retention policy. 7. Server response includes `expiresAt` and `deleteAfter`; we persist them on the job row for the completion step. @@ -81,7 +81,7 @@ public final class UploadJobEntity { public var attempts: Int public var lastError: String? public var serverUploadId: String? - public var retentionPolicy: String // oneHour | oneDay | oneWeek | oneMonth | custom + public var retentionPolicy: String // oneMinute | oneHour | oneDay | oneWeek | oneMonth | custom public var customTtlSeconds: Int? // set only when retentionPolicy == "custom" public var expiresAt: Date? // filled after presign succeeds public var deleteAfter: Date? // filled after presign succeeds @@ -165,7 +165,7 @@ Injecting the protocol makes unit tests trivial. - **Drop target.** Main window hosts a `DropDelegate` accepting `UTType.fileURL` items. Drop handler applies the default retention from Settings and enqueues jobs. - **Command menu.** `CommandGroup(after: .newItem)` adds "Upload from Clipboard" (⌘⇧V) and "Open Recent Link" (⌘L). - **`.fileImporter`.** Primary button opens `fileImporter(isPresented:allowedContentTypes:allowsMultipleSelection:onCompletion:)`. Multi-select produces one `UploadJobEntity` per URL; each picks up the default retention from Settings. -- **Settings pane** exposes a **default-retention picker** (same four presets as the Share Extension) so Mac drops inherit the user's preferred window without a modal. +- **Settings pane** exposes a **default-retention picker** (same five presets as the Share Extension) so Mac drops inherit the user's preferred window without a modal. - **Menu bar extra (future).** Tracked as a post-MVP item; not in scope for MVP. ## Logging diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index fa83930..df0bc21 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -207,7 +207,7 @@ Full schema SQL lives in [Data model](./data-model.md). - **Writes.** Server presigns PUT with 5-minute TTL, content-length range, and fixed `Content-Type`. Client PUTs directly to R2; the Worker never sees bytes. Object key format: `a///
//` (column name `storage_key`). - **Reads.** Only via `GET /s/:token`. Handler loads `share_link` + `asset`, checks `link_status`, `expires_at`, and deletion state, then streams the object from private R2 with `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, and `Referrer-Policy: no-referrer`. The browser never receives a raw R2 read URL. -- **Verification.** `POST /v1/uploads/:id/complete` issues an S3 HEAD against the object, compares `Content-Length` against the registered size, stores the returned `ETag` on the asset row, and rejects with `409` if the target `expires_at <= now() + 60s` (protects against zombie completes). +- **Verification.** `POST /v1/uploads/:id/complete` issues an S3 HEAD against the object, compares `Content-Length` against the registered size, stores the returned `ETag` on the asset row, and rejects with `409` if the target `expires_at <= now()` (protects against zombie completes). - **Deletes.** Deletion worker calls `DELETE object(storage_key)`. 404 is treated as success (already gone). - **Bucket-level lifecycle rule.** `fastshared-*` buckets have a 90 d `Expire` lifecycle as a safety net. Configured via `wrangler r2 bucket lifecycle put`. This covers objects whose app-level deletion fails permanently. @@ -252,7 +252,7 @@ RFC 7807, always `application/problem+json`. } ``` -`reason` is one of `expired`, `revoked`, or `deleted` for the `410 Gone` case. Other notable codes: `rate_limited` (429), `upload_not_found` (404), `object_size_mismatch` (422), `link_not_found` (404), `complete_too_late` (409, raised when `expires_at <= now() + 60s`). +`reason` is one of `expired`, `revoked`, or `deleted` for the `410 Gone` case. Other notable codes: `rate_limited` (429), `upload_not_found` (404), `object_size_mismatch` (422), `link_not_found` (404), `complete_too_late` (409, raised when `expires_at <= now()`). Known error types are catalogued in the source under `src/errors.ts`. `type` is always a real URL that resolves to a short explainer page served by `/errors/:code`. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 52bd802..7c640ab 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -162,7 +162,7 @@ create table share_link ( link_status text not null default 'active' check (link_status in ('active','expired','revoked')), retention_policy text not null - check (retention_policy in ('oneHour','oneDay','oneWeek','oneMonth','custom')), + check (retention_policy in ('oneMinute','oneHour','oneDay','oneWeek','oneMonth','custom')), expires_at timestamptz not null, delete_after timestamptz not null, revoked_at timestamptz, @@ -192,7 +192,7 @@ create table upload_job ( attempts int not null default 0, last_error text, retention_policy text not null - check (retention_policy in ('oneHour','oneDay','oneWeek','oneMonth','custom')), + check (retention_policy in ('oneMinute','oneHour','oneDay','oneWeek','oneMonth','custom')), custom_ttl_seconds int, asset_id uuid references asset(id) on delete set null, created_at timestamptz not null default now(), @@ -242,7 +242,7 @@ public final class UploadJobEntity { public var attempts: Int public var lastError: String? public var serverUploadId: String? - public var retentionPolicy: String // oneHour | oneDay | oneWeek | oneMonth | custom + public var retentionPolicy: String // oneMinute | oneHour | oneDay | oneWeek | oneMonth | custom public var customTtlSeconds: Int? public var expiresAt: Date? // filled after presign public var deleteAfter: Date? // filled after presign diff --git a/docs/architecture/system-design.md b/docs/architecture/system-design.md index ddab386..9e99020 100644 --- a/docs/architecture/system-design.md +++ b/docs/architecture/system-design.md @@ -171,7 +171,7 @@ SwiftData is the local source of truth for UI; a lazy `markExpiredIfNeeded` help | Device token leak | Attacker can upload on behalf of user | Server supports token rotation via `POST /v1/devices/:id/rotate` (post-MVP) | | Deletion job fails 8x | Object remains in R2 beyond `deleteAfter` | Terminal `deletion_status='failed'`; pager fires; R2 lifecycle rule (90 d) still eventually reaps it | | R2 object missing while DB still `verified` | `/s/:token` returns 502 on GET | Hourly reconciler notices `deleteAfter` past or a HEAD miss and marks the asset `deleted` | -| Upload completes after link already expired | Late user lands on a just-created expired link | `POST /v1/uploads/:id/complete` rejects with `409` when server sees `expiresAt <= now() + 60s` | +| Upload completes after link already expired | Late user lands on a just-created expired link | `POST /v1/uploads/:id/complete` rejects with `409` when server sees `expiresAt <= now()` | | Share token leaks | Someone with the token can download while the link is active | Resolve endpoint is rate-limited per-IP and per-token, logs redact token values, and owner revoke returns 410 | ## Key decisions diff --git a/docs/architecture/upload-flow.md b/docs/architecture/upload-flow.md index c264faa..0782dfa 100644 --- a/docs/architecture/upload-flow.md +++ b/docs/architecture/upload-flow.md @@ -55,7 +55,7 @@ sequenceDiagram App->>API: POST /v1/uploads/:id/complete API->>R2: HEAD storageKey R2-->>API: size, etag - alt expires_at <= now() + 60s + alt expires_at <= now() API-->>App: 409 complete_too_late else ok API->>DB: BEGIN; upsert asset; insert share_link(token); insert deletion_job(asset_id, scheduled_for=delete_after); COMMIT @@ -169,7 +169,7 @@ Triggers per transition: } ``` -`retentionPolicy` is one of `oneHour` (3600s), `oneDay` (86400s, **default**), `oneWeek` (604800s), `oneMonth` (2592000s), `custom`. `customTtlSeconds` is required when `retentionPolicy == "custom"` and is clamped server-side to `[300, 2592000]`. +`retentionPolicy` is one of `oneMinute` (60s), `oneHour` (3600s), `oneDay` (86400s, **default**), `oneWeek` (604800s), `oneMonth` (2592000s), `custom`. `customTtlSeconds` is required when `retentionPolicy == "custom"` and is clamped server-side to `[300, 2592000]`. Response (fresh): @@ -225,7 +225,7 @@ Response (dedup hit against a live asset): - App calls `POST /v1/uploads/:id/complete` with the `uploadId` returned at presign. - Server fetches `upload_job`, confirms it belongs to the caller's device, and issues S3 HEAD against `storage_key`. - If `Content-Length != upload_job.size`, respond 422 `object_size_mismatch` and mark the job `failed`. -- If `expires_at <= now() + 60s`, respond 409 `complete_too_late` and mark the job `failed`. (See "Resume + expiration edge cases" below.) +- If `expires_at <= now()`, respond 409 `complete_too_late` and mark the job `failed`. (See "Resume + expiration edge cases" below.) - Else, start a transaction: upsert `asset` on `(owner_device_id, sha256)` filtered to live rows; insert `share_link` with a fresh token, `link_status='active'`, `expires_at`, `delete_after`, `retention_policy`; insert `deletion_job(asset_id, scheduled_for=delete_after, status='pending')`; commit. Return the completion DTO. - Dedup path (two fast retries racing) is safe because the `asset` upsert key is `(owner_device_id, sha256)` and the partial unique index on `deletion_job` prevents duplicate scheduling. @@ -293,13 +293,13 @@ The deletion lifecycle is the second half of the ephemeral story. It is delibera Three corner cases worth calling out explicitly. 1. **App launches 3 days after a share was enqueued.** If the job is still pending and the target `delete_after` has already elapsed, the job is purged on the resume scan and marked `failed`. Staged file is reaped. The share would have been dead by now anyway. -2. **Presign arrives after link already expired.** Shouldn't happen for a fresh job (server computes `expiresAt` *at* presign). It can happen if the client times out and retries after a long gap: the retry path sees a stale cached `expiresAt` in the SwiftData row. Server is authoritative and rejects the `/complete` call with `409 complete_too_late` when `expires_at <= now() + 60s`. +2. **Presign arrives after link already expired.** Shouldn't happen for a fresh job (server computes `expiresAt` *at* presign). It can happen if the client times out and retries after a long gap: the retry path sees a stale cached `expiresAt` in the SwiftData row. Server is authoritative and rejects the `/complete` call with `409 complete_too_late` when `expires_at <= now()`. 3. **User closes app mid-upload.** The background `URLSession` continues. When the app is relaunched and `/complete` fires, one of three things happens: - - `expires_at > now() + 60s` — complete succeeds normally. - - `expires_at <= now() + 60s` — server rejects with 409; job marked `failed`. + - `expires_at > now()` — complete succeeds normally. + - `expires_at <= now()` — server rejects with 409; job marked `failed`. - Complete was previously attempted and persisted a token but the client never saw the response — the `upload_job` row already has `state='completed'` and the server returns the same DTO (idempotent). - **Minor footgun**: a sufficiently long background upload with a short `oneHour` retention can land on an already-past `expires_at` if we did not re-validate. Mitigation: the **server re-validates `expiresAt > now() + 60s` on complete** and rejects; the client surfaces a "this share expired before it could finish uploading" error with a retry affordance that presigns afresh. + **Minor footgun**: a sufficiently long background upload with a short `oneMinute` retention can land on an already-past `expires_at` if we did not re-validate. Mitigation: the **server re-validates `expiresAt > now()` on complete** and rejects; the client surfaces a "this share expired before it could finish uploading" error with a retry affordance that presigns afresh. ## Failure handling diff --git a/docs/plan/implementation-checklist.md b/docs/plan/implementation-checklist.md index 071aa84..2ff4ba0 100644 --- a/docs/plan/implementation-checklist.md +++ b/docs/plan/implementation-checklist.md @@ -86,7 +86,7 @@ Concrete developer actions, organized by milestone. Check items off as they land - [ ] Add columns to `upload_job`: `retention_policy`, `custom_ttl_seconds`; extend state check with `deduped`; rename `failed_permanent → failed` - [ ] Create `deletion_job` table with partial unique `(asset_id) WHERE status IN ('pending','running')` and due-queue `(scheduled_for) WHERE status = 'pending'` - [ ] Implement `backend/src/util/token.ts` — 22-char base62 via `crypto.getRandomValues(32)` + base62 encode + truncate; collision-retry via `TOKEN_RESERVATIONS` KV -- [ ] Implement `backend/src/policy/retention.ts` — map `oneHour|oneDay|oneWeek|oneMonth|custom` to seconds; clamp `customTtlSeconds` to `[300, 2592000]` +- [ ] Implement `backend/src/policy/retention.ts` — map `oneMinute|oneHour|oneDay|oneWeek|oneMonth|custom` to seconds; clamp `customTtlSeconds` to `[300, 2592000]` - [ ] Implement `backend/src/services/deletion.ts` — `FOR UPDATE SKIP LOCKED LIMIT 50`, R2 DELETE, backoff `120 × 2^(attempts-1)s` cap 7200s jitter ±10%, terminal at 8 attempts - [ ] Implement `backend/src/services/reconciliation.ts` — expire stale links, enqueue missing deletion jobs, reset stuck running, sampled HEAD probe - [ ] Implement `backend/src/services/multipartSweeper.ts` — stub that logs (full impl in M8) @@ -97,8 +97,8 @@ Concrete developer actions, organized by milestone. Check items off as they land - [ ] Add resolve-route rate limit: per-IP 60/min and per-token 300/min (KV buckets with `HMAC-SHA256(pepper, token)` keying) - [ ] Implement `POST /v1/links/:token/revoke` — owner-only, flip `link_status='revoked'`, `revoked_at=now()`, pull `deletion_job.scheduled_for` to now - [ ] Update `POST /v1/uploads` to accept `retentionPolicy` + optional `customTtlSeconds`; return `expiresAt`, `deleteAfter`, `retentionPolicy` -- [ ] Update `POST /v1/uploads/:id/complete` to return `token`, `expiresAt`, `deleteAfter`, `linkStatus`, `retentionPolicy`; reject with 409 `complete_too_late` if `expires_at <= now() + 60s` -- [ ] Add retention picker UI to `FastSharedShareExt` (4 presets, default `oneDay`) +- [ ] Update `POST /v1/uploads/:id/complete` to return `token`, `expiresAt`, `deleteAfter`, `linkStatus`, `retentionPolicy`; reject with 409 `complete_too_late` if `expires_at <= now()` +- [ ] Add retention picker UI to `FastSharedShareExt` (5 presets, default `oneDay`) - [ ] Add default-retention picker to `SettingsView` on iOS and macOS - [ ] Update `FastSharedCore` `APIClient` DTOs to carry retention fields end-to-end - [ ] Update `UploadJobEntity` + `ShareLinkEntity` SwiftData shapes to match data-model doc diff --git a/docs/plan/mvp-roadmap.md b/docs/plan/mvp-roadmap.md index 8173a9b..d02403d 100644 --- a/docs/plan/mvp-roadmap.md +++ b/docs/plan/mvp-roadmap.md @@ -47,7 +47,7 @@ Scope, milestones, and release plan. The implementation-level to-do list lives i - Rewrite `/s/:token` handler: DB-backed lookup, 302 to 60s signed GET, 410 Gone (`code: "link_gone"`, `reason: expired | revoked | deleted`), 404 for unknown. Always emit `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer`. - Add per-IP (60/min) and per-token (300/min) rate limit buckets on the resolve route. - Implement `POST /v1/links/:token/revoke` (owner-only): flip `link_status='revoked'`, `revoked_at=now()`, pull the pending `deletion_job.scheduled_for` forward to `now()`. -- Add retention picker in Share Extension (4 presets, default `oneDay`). +- Add retention picker in Share Extension (5 presets, default `oneDay`). - Add default-retention picker in Settings (Mac + iOS). - Update client DTOs: `PresignRequest` carries `retentionPolicy` + optional `customTtlSeconds`; `CompleteResponse` carries `token`, `expiresAt`, `deleteAfter`, `linkStatus`, `retentionPolicy`. - Configure R2 lifecycle rule (90 d expire) via `wrangler r2 bucket lifecycle put`. diff --git a/docs/plan/pro-feature-B-apple.md b/docs/plan/pro-feature-B-apple.md index 10c3460..fa0bacd 100644 --- a/docs/plan/pro-feature-B-apple.md +++ b/docs/plan/pro-feature-B-apple.md @@ -343,7 +343,7 @@ Focus: inherits `preferredColorScheme(.light)` (doesn't override); `TODO(i18n)` **Files (create):** `apple/FastSharedApp/Components/GatedRetentionPicker.swift`. **Modify:** `apple/FastSharedShareExt/ShareRootView.swift` (`IdleStage.retentionSection`), `apple/FastSharedApp/Scenes/SettingsView.swift` (default retention picker). **Intent:** Replace both in-place `Picker("Link valid for"/"Default retention", selection:)` call sites with `GatedRetentionPicker(selection:, tier:, onPaywall:)`. Behavior: -- Shows all `.shareable` options always (`oneHour`/`oneDay`/`oneWeek`/`oneMonth`). +- Shows all `.shareable` options always (`oneMinute`/`oneHour`/`oneDay`/`oneWeek`/`oneMonth`). - For Free, options where `ttlSeconds > 86_400` render a trailing `lock.fill` (`BrandPalette.dust`). On `onChange`, if the new value is gated, revert the binding and call `onPaywall(.longRetentionRequested(policy: newValue))`. - For Pro, vanilla behavior. - WHY show-with-lock, not hide: one-tap discovery of Pro, aligned with scope. diff --git a/docs/product/overview.md b/docs/product/overview.md index 45a888d..89ab3b7 100644 --- a/docs/product/overview.md +++ b/docs/product/overview.md @@ -51,7 +51,7 @@ Not targeted at enterprise compliance buyers. Not targeted at cross-platform pow - Shared SwiftData-backed history with tombstones and live countdown. - Per-device bearer token auth for the owner API (upload, history, revoke). - Temporary links at `https://fastsha.red/s/`. -- **Default retention = 24 h**, with 1 h / 1 d / 1 w / 1 mo presets and a clamped custom value (300 s … 30 d). +- **Default retention = 24 h**, with 60 s / 1 h / 1 d / 1 w / 1 mo presets and a clamped custom value (300 s … 30 d). - **Link expires automatically** at `expiresAt`; subsequent access returns `410 Gone`. - **R2 object is deleted automatically** at `deleteAfter = expiresAt + 24 h` via an app-level deletion cron (R2 lifecycle rule as safety net). - **Anonymous resolve** at `/s/:token` — DB-gated Worker stream from private R2. No sign-in required for recipients. From 3b66626163575d7c4fdefd1a00fe2239e81c407b Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 15:48:14 -0300 Subject: [PATCH 05/12] Expire pending bundle links before rendering --- backend/src/routes/redirect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/routes/redirect.ts b/backend/src/routes/redirect.ts index f56d2b7..754f9db 100644 --- a/backend/src/routes/redirect.ts +++ b/backend/src/routes/redirect.ts @@ -470,7 +470,6 @@ async function loadActiveBundle( const link = await findByToken(db, token); if (!link || !link.isBundle) return { status: 'not_found' }; if (link.linkStatus === 'revoked') return { status: 'gone', reason: 'revoked' }; - if (link.linkStatus === 'pending') return { status: 'pending' }; if (link.expiresAt.getTime() <= Date.now()) { c.executionCtx.waitUntil( markExpiredByToken(db, token).catch((err) => { @@ -483,6 +482,7 @@ async function loadActiveBundle( ); return { status: 'gone', reason: 'expired' }; } + if (link.linkStatus === 'pending') return { status: 'pending' }; // Two queries (junction + assets) instead of a JOIN — keeps the route // simple and lets the test fake stay dumb. Assets in displayOrder. const junctions = await db From 9c2daef06dddd676178ede5ed0822441e64368e8 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:03:29 -0300 Subject: [PATCH 06/12] Bump Apple app version to 1.0.1 --- apple/Config/Shared.xcconfig | 2 +- apple/fastlane/Deliverfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apple/Config/Shared.xcconfig b/apple/Config/Shared.xcconfig index a3e3052..856f4d1 100644 --- a/apple/Config/Shared.xcconfig +++ b/apple/Config/Shared.xcconfig @@ -15,7 +15,7 @@ SWIFT_VERSION = 5.10 // MARKETING_VERSION: human-readable release version (CFBundleShortVersionString). // Bumped on each public release following semver; CI may override for TestFlight // pre-releases. -MARKETING_VERSION = 1.0.0 +MARKETING_VERSION = 1.0.1 // CURRENT_PROJECT_VERSION: build number (CFBundleVersion). Must be monotonically // increasing for every archive uploaded to App Store Connect under the same diff --git a/apple/fastlane/Deliverfile b/apple/fastlane/Deliverfile index bb9731b..1c5e5f0 100644 --- a/apple/fastlane/Deliverfile +++ b/apple/fastlane/Deliverfile @@ -6,7 +6,7 @@ app_identifier("dev.kindrazki.fastshared") team_id("YFYB6NKC73") -app_version(ENV["FASTSHARED_APPSTORE_VERSION"] || "1.0.0") +app_version(ENV["FASTSHARED_APPSTORE_VERSION"] || "1.0.1") metadata_path("./fastlane/metadata") screenshots_path("./fastlane/screenshots/ios") From 9d94ca414593462e143c5d43798436790f75be4c Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:24:19 -0300 Subject: [PATCH 07/12] Allow CLI device registration --- backend/src/routes/devices.ts | 2 +- backend/test/devices.test.ts | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 backend/test/devices.test.ts diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 5a054c6..d459cca 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -7,7 +7,7 @@ import { toBase64Url } from '~/lib/hash'; import { ratelimit } from '~/middleware/ratelimit'; const registerSchema = z.object({ - platform: z.enum(['ios', 'ipados', 'macos']), + platform: z.enum(['ios', 'ipados', 'macos', 'cli']), appVersion: z.string().min(1).max(64), idfv: z.string().min(1).max(128).optional(), }); diff --git a/backend/test/devices.test.ts b/backend/test/devices.test.ts new file mode 100644 index 0000000..15d91a9 --- /dev/null +++ b/backend/test/devices.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { installDrizzleFake, resetKv, resetStore, store, TEST_ENV, ctx } from './support'; + +installDrizzleFake(); + +vi.mock('~/services/r2', async () => { + const actual = await vi.importActual('~/services/r2'); + return { + ...actual, + presignPut: async ({ + key, + contentType, + sizeBytes, + }: { + key: string; + contentType: string; + sizeBytes: number; + }) => ({ + url: `https://r2.test/${key}?sig=x`, + method: 'PUT' as const, + headers: { 'content-type': contentType, 'content-length': String(sizeBytes) }, + expiresAt: new Date(Date.now() + 900_000).toISOString(), + }), + }; +}); + +import worker from '~/index'; + +async function post(path: string, body: unknown, headers: Record = {}) { + return worker.fetch( + new Request(`https://api.fastsha.red${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), + TEST_ENV, + ctx, + ); +} + +describe('POST /v1/devices', () => { + beforeEach(() => { + resetStore(); + resetKv(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('registers a CLI device token and accepts it for uploads', async () => { + const register = await post('/v1/devices', { + platform: 'cli', + appVersion: 'fastshared-cli/0.1.0', + }); + + expect(register.status).toBe(201); + const registered = (await register.json()) as { deviceId: string; deviceToken: string }; + expect(registered.deviceId).toBeTruthy(); + expect(registered.deviceToken).toBeTruthy(); + expect(store.devices).toHaveLength(1); + expect(store.devices[0]?.platform).toBe('cli'); + + const upload = await post( + '/v1/uploads', + { + clientJobId: crypto.randomUUID(), + contentType: 'text/plain', + sizeBytes: 11, + sha256: '64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c', + originalFilename: 'hello.txt', + retentionPolicy: 'oneHour', + }, + { authorization: `Bearer ${registered.deviceToken}` }, + ); + + expect(upload.status).toBe(200); + const body = (await upload.json()) as { uploadId?: string; shortUrl?: string }; + expect(body.uploadId).toBeTruthy(); + expect(body.shortUrl).toMatch(/^https:\/\/fastsha\.red\/s\//); + }); + + it('rejects unknown platforms', async () => { + const res = await post('/v1/devices', { + platform: 'linux', + appVersion: 'fastshared-cli/0.1.0', + }); + + expect(res.status).toBe(400); + expect(store.devices).toHaveLength(0); + }); +}); From d1c7e0d5d0ddec983312a8c7d3c36e393b3ad949 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:31:43 -0300 Subject: [PATCH 08/12] Add FastShared CLI uploader --- cli/README.md | 17 + cli/package.json | 29 ++ cli/pnpm-lock.yaml | 771 +++++++++++++++++++++++++++++++ cli/src/api.ts | 272 +++++++++++ cli/src/cli.ts | 137 ++++++ cli/src/config.ts | 52 +++ cli/src/index.ts | 21 + cli/src/options.ts | 171 +++++++ cli/src/stage.ts | 328 +++++++++++++ cli/test/cli.integration.test.ts | 232 ++++++++++ cli/test/config.test.ts | 35 ++ cli/test/options.test.ts | 45 ++ cli/test/stage.test.ts | 57 +++ cli/tsconfig.json | 21 + cli/vitest.config.ts | 8 + 15 files changed, 2196 insertions(+) create mode 100644 cli/README.md create mode 100644 cli/package.json create mode 100644 cli/pnpm-lock.yaml create mode 100644 cli/src/api.ts create mode 100644 cli/src/cli.ts create mode 100644 cli/src/config.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/options.ts create mode 100644 cli/src/stage.ts create mode 100644 cli/test/cli.integration.test.ts create mode 100644 cli/test/config.test.ts create mode 100644 cli/test/options.test.ts create mode 100644 cli/test/stage.test.ts create mode 100644 cli/tsconfig.json create mode 100644 cli/vitest.config.ts diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..a42a231 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,17 @@ +# FastShared CLI + +Command-line uploader for FastShared. + +```bash +fastshared ./report.pdf +fastshared ./screenshots +fastshared - --name output.txt +fastshared ./trace.log --ttl 60s --json +``` + +Default output is the share URL only, which makes it safe for scripts and AI agents: + +```bash +url="$(fastshared ./artifact.zip)" +``` + diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..079fc0d --- /dev/null +++ b/cli/package.json @@ -0,0 +1,29 @@ +{ + "name": "@fastshared/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "FastShared command-line uploader for agents and scripts", + "bin": { + "fastshared": "./dist/index.js" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "pack:release": "pnpm build && rm -rf dist-release && mkdir -p dist-release && npm pack --pack-destination dist-release" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^22.19.1", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + } +} diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml new file mode 100644 index 0000000..9d1001d --- /dev/null +++ b/cli/pnpm-lock.yaml @@ -0,0 +1,771 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.19.1 + version: 22.19.17 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@22.19.17)(vite@8.0.10(@types/node@22.19.17)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@22.19.17) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite@8.0.10(@types/node@22.19.17): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + + vitest@4.1.5(@types/node@22.19.17)(vite@8.0.10(@types/node@22.19.17)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@22.19.17) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/cli/src/api.ts b/cli/src/api.ts new file mode 100644 index 0000000..f5e438c --- /dev/null +++ b/cli/src/api.ts @@ -0,0 +1,272 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import type { RetentionPolicy } from './options.js'; + +export interface DeviceRegistration { + deviceId: string; + deviceToken: string; +} + +export interface UploadInput { + filePath: string; + filename: string; + contentType: string; + sizeBytes: number; + sha256: string; + retentionPolicy: RetentionPolicy; +} + +export interface UploadResult { + shortUrl: string; + token: string; + expiresAt: string; + deleteAfter: string; + linkStatus: string; + retentionPolicy: string; + assetId?: string; +} + +interface UploadInstructionSingle { + mode: 'single'; + url: string; + method: 'PUT'; + headers: Record; + expiresAt: string; +} + +interface UploadInstructionMultipart { + mode: 'multipart'; + multipartUploadId: string; + partSize: number; + parts: Array<{ partNumber: number; url: string; method: 'PUT' }>; + expiresAt: string; +} + +type UploadInstruction = UploadInstructionSingle | UploadInstructionMultipart; + +interface PresignResponse { + uploadId?: string; + upload?: UploadInstruction; + shortUrl?: string; + token?: string; + expiresAt?: string; + deleteAfter?: string; + linkStatus?: string; + retentionPolicy?: string; + deduped?: { + assetId: string; + shortUrl: string; + token: string; + expiresAt: string; + deleteAfter: string; + retentionPolicy: string; + }; +} + +interface CompleteResponse { + assetId: string; + shortUrl: string; + token: string; + expiresAt: string; + deleteAfter: string; + linkStatus: string; + retentionPolicy: string; +} + +interface ProblemBody { + status?: number; + code?: string; + title?: string; + detail?: string; +} + +export class FastSharedHttpError extends Error { + constructor( + readonly status: number, + message: string, + readonly code?: string, + readonly detail?: string, + ) { + super(message); + } +} + +export class FastSharedApi { + private readonly apiBaseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: { apiBaseUrl: string; fetchImpl?: typeof fetch }) { + this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async registerDevice(appVersion: string): Promise { + return this.requestJson('/v1/devices', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ platform: 'cli', appVersion }), + }); + } + + async uploadFile(input: UploadInput, deviceToken: string): Promise { + const presign = await this.requestJson( + '/v1/uploads', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ + clientJobId: crypto.randomUUID(), + contentType: input.contentType, + sizeBytes: input.sizeBytes, + sha256: input.sha256, + originalFilename: input.filename, + retentionPolicy: input.retentionPolicy, + }), + }, + ); + + if (presign.deduped) { + return { + assetId: presign.deduped.assetId, + shortUrl: presign.deduped.shortUrl, + token: presign.deduped.token, + expiresAt: presign.deduped.expiresAt, + deleteAfter: presign.deduped.deleteAfter, + linkStatus: 'active', + retentionPolicy: presign.deduped.retentionPolicy, + }; + } + + if (!presign.uploadId || !presign.upload) { + throw new Error('invalid presign response: missing uploadId/upload'); + } + + let usedMultipart = false; + try { + let multipart: { parts: Array<{ partNumber: number; eTag: string }> } | undefined; + if (presign.upload.mode === 'multipart') { + usedMultipart = true; + multipart = { parts: await this.putMultipart(input.filePath, input.sizeBytes, presign.upload) }; + } else { + await this.putSingle(input.filePath, presign.upload); + } + + const complete = await this.requestJson( + `/v1/uploads/${encodeURIComponent(presign.uploadId)}/complete`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ + contentType: input.contentType, + sizeBytes: input.sizeBytes, + sha256: input.sha256, + originalFilename: input.filename, + ...(multipart ? { multipart } : {}), + }), + }, + ); + return complete; + } catch (err) { + if (usedMultipart) await this.abortMultipart(presign.uploadId, deviceToken).catch(() => undefined); + else await this.markFailed(presign.uploadId, deviceToken, err).catch(() => undefined); + throw err; + } + } + + private async putSingle(filePath: string, upload: UploadInstructionSingle): Promise { + const response = await this.fetchImpl(upload.url, { + method: upload.method, + headers: upload.headers, + body: Readable.toWeb(createReadStream(filePath)) as BodyInit, + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + if (!response.ok) { + throw new FastSharedHttpError(response.status, `R2 upload failed with HTTP ${response.status}`); + } + } + + private async putMultipart( + filePath: string, + sizeBytes: number, + upload: UploadInstructionMultipart, + ): Promise> { + const parts: Array<{ partNumber: number; eTag: string }> = []; + for (const part of upload.parts.slice().sort((a, b) => a.partNumber - b.partNumber)) { + const start = (part.partNumber - 1) * upload.partSize; + const end = Math.min(start + upload.partSize, sizeBytes) - 1; + const length = end - start + 1; + const response = await this.fetchImpl(part.url, { + method: part.method, + headers: { 'content-length': String(length) }, + body: Readable.toWeb(createReadStream(filePath, { start, end })) as BodyInit, + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + if (!response.ok) { + throw new FastSharedHttpError( + response.status, + `R2 multipart part ${part.partNumber} failed with HTTP ${response.status}`, + ); + } + const eTag = response.headers.get('etag'); + if (!eTag) throw new Error(`R2 multipart part ${part.partNumber} did not return an ETag`); + parts.push({ partNumber: part.partNumber, eTag }); + } + return parts; + } + + private async markFailed(uploadId: string, deviceToken: string, err: unknown): Promise { + await this.requestVoid(`/v1/uploads/${encodeURIComponent(uploadId)}/fail`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ + errorCode: 'cli_upload_failed', + detail: err instanceof Error ? err.message : String(err), + }), + }); + } + + private async abortMultipart(uploadId: string, deviceToken: string): Promise { + await this.requestVoid(`/v1/uploads/${encodeURIComponent(uploadId)}/abort-multipart`, { + method: 'POST', + headers: { authorization: `Bearer ${deviceToken}` }, + }); + } + + private async requestJson(path: string, init: RequestInit): Promise { + const response = await this.fetchImpl(`${this.apiBaseUrl}${path}`, init); + if (!response.ok) throw await this.toHttpError(response); + return (await response.json()) as T; + } + + private async requestVoid(path: string, init: RequestInit): Promise { + const response = await this.fetchImpl(`${this.apiBaseUrl}${path}`, init); + if (!response.ok) throw await this.toHttpError(response); + } + + private async toHttpError(response: Response): Promise { + const problem = (await response + .clone() + .json() + .catch(() => null)) as ProblemBody | null; + const title = problem?.title ?? (response.statusText || 'HTTP error'); + const detail = problem?.detail; + const code = problem?.code; + return new FastSharedHttpError( + response.status, + detail ? `${title}: ${detail}` : `${title} (${response.status})`, + code, + detail, + ); + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..aae9a30 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,137 @@ +import type { Readable, Writable } from 'node:stream'; +import { loadConfig, resolveConfigPath, saveConfig, type CliConfig } from './config.js'; +import { FastSharedApi, FastSharedHttpError, type UploadResult } from './api.js'; +import { CLI_VERSION, DEFAULT_API_URL, parseArgs, UsageError, usage } from './options.js'; +import { stageInputs } from './stage.js'; + +export interface CliIO { + stdout: Writable; + stderr: Writable; + stdin: Readable; + env: NodeJS.ProcessEnv; + cwd: string; + fetchImpl?: typeof fetch; +} + +export async function runCli( + argv: string[], + io: CliIO = { + stdout: process.stdout, + stderr: process.stderr, + stdin: process.stdin, + env: process.env, + cwd: process.cwd(), + }, +): Promise { + let outputJson = false; + try { + const options = parseArgs(argv); + outputJson = options.outputJson; + if (options.help) { + io.stdout.write(usage()); + return 0; + } + if (options.version) { + io.stdout.write(`${CLI_VERSION}\n`); + return 0; + } + + const configPath = options.configPath ?? resolveConfigPath(io.env); + const config = await loadConfig(configPath); + const apiBaseUrl = stripTrailingSlash( + options.apiUrl ?? io.env.FASTSHARED_API_URL ?? config.apiBaseUrl ?? DEFAULT_API_URL, + ); + const api = new FastSharedApi({ apiBaseUrl, ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) }); + const deviceToken = await resolveDeviceToken({ + api, + config, + configPath, + apiBaseUrl, + env: io.env, + }); + + const staged = await stageInputs(options.inputs, { + ...(options.name ? { name: options.name } : {}), + stdin: io.stdin, + cwd: io.cwd, + }); + try { + if (!options.quiet) { + io.stderr.write(`Uploading ${staged.filename} (${staged.sizeBytes} bytes)...\n`); + } + const result = await api.uploadFile( + { + filePath: staged.filePath, + filename: staged.filename, + contentType: staged.contentType, + sizeBytes: staged.sizeBytes, + sha256: staged.sha256, + retentionPolicy: options.retentionPolicy, + }, + deviceToken, + ); + writeSuccess(io.stdout, result, outputJson); + return 0; + } finally { + await staged.cleanup(); + } + } catch (err) { + writeError(io.stderr, err, outputJson); + return err instanceof UsageError ? err.exitCode : 1; + } +} + +async function resolveDeviceToken(args: { + api: FastSharedApi; + config: Partial; + configPath: string; + apiBaseUrl: string; + env: NodeJS.ProcessEnv; +}): Promise { + if (args.env.FASTSHARED_DEVICE_TOKEN) return args.env.FASTSHARED_DEVICE_TOKEN; + if (args.config.deviceToken) return args.config.deviceToken; + + const registration = await args.api.registerDevice(`fastshared-cli/${CLI_VERSION}`); + await saveConfig(args.configPath, { + version: 1, + deviceId: registration.deviceId, + deviceToken: registration.deviceToken, + apiBaseUrl: args.apiBaseUrl, + }); + return registration.deviceToken; +} + +function writeSuccess(stdout: Writable, result: UploadResult, outputJson: boolean): void { + if (outputJson) { + stdout.write(`${JSON.stringify(result)}\n`); + return; + } + stdout.write(`${result.shortUrl}\n`); +} + +function writeError(stderr: Writable, err: unknown, outputJson: boolean): void { + if (outputJson && err instanceof FastSharedHttpError) { + stderr.write( + `${JSON.stringify({ + error: err.code ?? 'http_error', + status: err.status, + detail: err.detail ?? err.message, + })}\n`, + ); + return; + } + if (err instanceof UsageError) { + stderr.write(`${err.message}\n\n${usage()}`); + return; + } + if (err instanceof FastSharedHttpError) { + const code = err.code ? ` ${err.code}` : ''; + stderr.write(`fastshared: HTTP ${err.status}${code}: ${err.detail ?? err.message}\n`); + return; + } + stderr.write(`fastshared: ${err instanceof Error ? err.message : String(err)}\n`); +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..811f8aa --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,52 @@ +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { mkdir, readFile, rename, rm, writeFile, chmod } from 'node:fs/promises'; + +export interface CliConfig { + version: 1; + deviceId?: string; + deviceToken?: string; + apiBaseUrl?: string; +} + +export function resolveConfigPath( + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string { + if (env.FASTSHARED_CONFIG) return env.FASTSHARED_CONFIG; + const base = env.XDG_CONFIG_HOME || join(home, '.config'); + return join(base, 'fastshared', 'config.json'); +} + +export async function loadConfig(path: string): Promise> { + try { + const raw = await readFile(path, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return {}; + const obj = parsed as Record; + return { + version: 1, + ...(typeof obj.deviceId === 'string' ? { deviceId: obj.deviceId } : {}), + ...(typeof obj.deviceToken === 'string' ? { deviceToken: obj.deviceToken } : {}), + ...(typeof obj.apiBaseUrl === 'string' ? { apiBaseUrl: obj.apiBaseUrl } : {}), + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw err; + } +} + +export async function saveConfig(path: string, config: CliConfig): Promise { + const dir = dirname(path); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await chmod(tmp, 0o600); + await rename(tmp, path); + await chmod(path, 0o600); + } catch (err) { + await rm(tmp, { force: true }).catch(() => undefined); + throw err; + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..2c5fc7a --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url'; +import { runCli } from './cli.js'; + +const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; + +if (import.meta.url === entry) { + runCli(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((err: unknown) => { + process.stderr.write(`fastshared: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + }); +} + +export { runCli } from './cli.js'; +export { parseArgs, parseTtl } from './options.js'; +export { loadConfig, resolveConfigPath, saveConfig } from './config.js'; +export { stageInputs, sha256File, detectMime } from './stage.js'; diff --git a/cli/src/options.ts b/cli/src/options.ts new file mode 100644 index 0000000..819cc88 --- /dev/null +++ b/cli/src/options.ts @@ -0,0 +1,171 @@ +export const CLI_VERSION = '0.1.0'; +export const DEFAULT_API_URL = 'https://api.fastsha.red'; + +export type RetentionPolicy = 'oneMinute' | 'oneHour' | 'oneDay' | 'oneWeek' | 'oneMonth'; + +export interface ParsedArgs { + inputs: string[]; + retentionPolicy: RetentionPolicy; + outputJson: boolean; + quiet: boolean; + help: boolean; + version: boolean; + apiUrl?: string; + configPath?: string; + name?: string; +} + +export class UsageError extends Error { + readonly exitCode = 2; +} + +export function parseTtl(raw: string | undefined): RetentionPolicy { + const value = (raw ?? '1h').trim().toLowerCase(); + switch (value) { + case '60s': + case '1m': + case 'one-minute': + case 'oneminute': + return 'oneMinute'; + case '1h': + case '60m': + case 'one-hour': + case 'onehour': + return 'oneHour'; + case '1d': + case '24h': + case 'one-day': + case 'oneday': + return 'oneDay'; + case '1w': + case '7d': + case 'one-week': + case 'oneweek': + return 'oneWeek'; + case '30d': + case '1mo': + case 'one-month': + case 'onemonth': + return 'oneMonth'; + default: + throw new UsageError(`invalid --ttl "${raw}". Use 60s, 1h, 1d, 1w, or 30d.`); + } +} + +export function parseArgs(argv: string[]): ParsedArgs { + const args = argv[0] === 'upload' ? argv.slice(1) : argv.slice(); + const inputs: string[] = []; + let ttl: string | undefined; + let outputJson = false; + let quiet = false; + let help = false; + let version = false; + let apiUrl: string | undefined; + let configPath: string | undefined; + let name: string | undefined; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') { + inputs.push(...args.slice(i + 1)); + break; + } + if (arg === '-h' || arg === '--help') { + help = true; + continue; + } + if (arg === '--version') { + version = true; + continue; + } + if (arg === '--json') { + outputJson = true; + continue; + } + if (arg === '--quiet') { + quiet = true; + continue; + } + if (arg.startsWith('--ttl=')) { + ttl = arg.slice('--ttl='.length); + continue; + } + if (arg === '--ttl') { + ttl = readValue(args, ++i, '--ttl'); + continue; + } + if (arg.startsWith('--api-url=')) { + apiUrl = arg.slice('--api-url='.length); + continue; + } + if (arg === '--api-url') { + apiUrl = readValue(args, ++i, '--api-url'); + continue; + } + if (arg.startsWith('--config=')) { + configPath = arg.slice('--config='.length); + continue; + } + if (arg === '--config') { + configPath = readValue(args, ++i, '--config'); + continue; + } + if (arg.startsWith('--name=')) { + name = arg.slice('--name='.length); + continue; + } + if (arg === '--name') { + name = readValue(args, ++i, '--name'); + continue; + } + if (arg.startsWith('--')) { + throw new UsageError(`unknown option: ${arg}`); + } + inputs.push(arg); + } + + if (!help && !version && inputs.length === 0) { + throw new UsageError('missing input file, directory, or "-".'); + } + + return { + inputs, + retentionPolicy: parseTtl(ttl), + outputJson, + quiet, + help, + version, + ...(apiUrl ? { apiUrl: stripTrailingSlash(apiUrl) } : {}), + ...(configPath ? { configPath } : {}), + ...(name ? { name } : {}), + }; +} + +export function usage(): string { + return `Usage: fastshared [options] [...] + +Uploads a file to FastShared and prints the temporary URL. + +Options: + --ttl 60s|1h|1d|1w|30d Link lifetime (default: 1h) + --json Print structured JSON instead of the URL only + --quiet Suppress progress logs on stderr + --api-url Override API URL (default: ${DEFAULT_API_URL}) + --config Override config path + --name Filename for stdin or generated archive + --version Print CLI version + -h, --help Show this help +`; +} + +function readValue(args: string[], index: number, flag: string): string { + const value = args[index]; + if (!value || value.startsWith('--')) { + throw new UsageError(`${flag} requires a value.`); + } + return value; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} diff --git a/cli/src/stage.ts b/cli/src/stage.ts new file mode 100644 index 0000000..e3eda6d --- /dev/null +++ b/cli/src/stage.ts @@ -0,0 +1,328 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { + lstat, + mkdir, + mkdtemp, + open, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path, { basename, dirname, join, relative } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import type { Readable } from 'node:stream'; + +export interface StagedUpload { + filePath: string; + filename: string; + contentType: string; + sizeBytes: number; + sha256: string; + sourceKind: 'file' | 'stdin' | 'zip'; + cleanup: () => Promise; +} + +export interface StageOptions { + name?: string; + stdin?: NodeJS.ReadableStream; + cwd?: string; +} + +interface ZipEntry { + fsPath: string; + archivePath: string; + size: number; + crc32: number; + localHeaderOffset: number; + dosTime: number; + dosDate: number; +} + +export async function stageInputs(inputs: string[], options: StageOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + if (inputs.length === 1 && inputs[0] === '-') { + return stageStdin(options.stdin ?? process.stdin, options.name ?? 'stdin.bin'); + } + + const resolved = inputs.map((input) => path.resolve(cwd, input)); + const stats = await Promise.all(resolved.map((input) => lstat(input))); + const singleFile = resolved.length === 1 && stats[0]?.isFile() === true; + if (singleFile) { + const filePath = resolved[0]!; + const filename = options.name ?? basename(filePath); + const st = await stat(filePath); + return { + filePath, + filename, + contentType: detectMime(filename), + sizeBytes: st.size, + sha256: await sha256File(filePath), + sourceKind: 'file', + cleanup: async () => undefined, + }; + } + + return stageZip(resolved, stats, cwd, options.name); +} + +export function detectMime(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() ?? ''; + const map: Record = { + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + json: 'application/json', + xml: 'application/xml', + yaml: 'application/yaml', + yml: 'application/yaml', + pdf: 'application/pdf', + zip: 'application/zip', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + mp4: 'video/mp4', + mov: 'video/quicktime', + mp3: 'audio/mpeg', + wav: 'audio/wav', + }; + return map[ext] ?? 'application/octet-stream'; +} + +async function stageStdin(stdin: NodeJS.ReadableStream, filename: string): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'fastshared-')); + const filePath = join(tempDir, basename(filename)); + await pipeline(stdin, await openWriteStream(filePath)); + const st = await stat(filePath); + return { + filePath, + filename, + contentType: detectMime(filename), + sizeBytes: st.size, + sha256: await sha256File(filePath), + sourceKind: 'stdin', + cleanup: async () => { + await rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +async function stageZip( + inputs: string[], + stats: Array>>, + cwd: string, + overrideName: string | undefined, +): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'fastshared-')); + const filename = overrideName ?? defaultZipName(inputs, stats, cwd); + const safeName = filename.toLowerCase().endsWith('.zip') ? filename : `${filename}.zip`; + const filePath = join(tempDir, basename(safeName)); + await createZipArchive(inputs, stats, cwd, filePath); + const st = await stat(filePath); + return { + filePath, + filename: basename(safeName), + contentType: 'application/zip', + sizeBytes: st.size, + sha256: await sha256File(filePath), + sourceKind: 'zip', + cleanup: async () => { + await rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +function defaultZipName( + inputs: string[], + stats: Array>>, + cwd: string, +): string { + if (inputs.length === 1 && stats[0]?.isDirectory()) return `${basename(inputs[0]!)}.zip`; + const cwdName = basename(cwd) || 'bundle'; + return `${cwdName}-fastshared.zip`; +} + +async function openWriteStream(filePath: string) { + const handle = await open(filePath, 'w', 0o600); + return handle.createWriteStream(); +} + +export async function sha256File(filePath: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} + +async function createZipArchive( + inputs: string[], + stats: Array>>, + cwd: string, + outputPath: string, +): Promise { + const files = await collectFiles(inputs, stats, cwd); + if (files.length === 0) throw new Error('no files found to archive'); + + const entries: ZipEntry[] = []; + for (const file of files) { + const st = await stat(file.fsPath); + if (st.size > 0xffffffff) { + throw new Error(`file too large for zip64-less archive: ${file.fsPath}`); + } + const { dosTime, dosDate } = dateToDos(st.mtime); + entries.push({ + fsPath: file.fsPath, + archivePath: file.archivePath, + size: st.size, + crc32: await crc32File(file.fsPath), + localHeaderOffset: 0, + dosTime, + dosDate, + }); + } + + const handle = await open(outputPath, 'w', 0o600); + let offset = 0; + try { + for (const entry of entries) { + entry.localHeaderOffset = offset; + const name = Buffer.from(entry.archivePath, 'utf8'); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50, 0); + header.writeUInt16LE(20, 4); + header.writeUInt16LE(0, 6); + header.writeUInt16LE(0, 8); + header.writeUInt16LE(entry.dosTime, 10); + header.writeUInt16LE(entry.dosDate, 12); + header.writeUInt32LE(entry.crc32, 14); + header.writeUInt32LE(entry.size, 18); + header.writeUInt32LE(entry.size, 22); + header.writeUInt16LE(name.length, 26); + header.writeUInt16LE(0, 28); + await handle.write(header); + await handle.write(name); + offset += header.length + name.length; + for await (const chunk of createReadStream(entry.fsPath)) { + const buf = chunk as Buffer; + await handle.write(buf); + offset += buf.length; + } + } + + const centralStart = offset; + for (const entry of entries) { + const name = Buffer.from(entry.archivePath, 'utf8'); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(entry.dosTime, 12); + central.writeUInt16LE(entry.dosDate, 14); + central.writeUInt32LE(entry.crc32, 16); + central.writeUInt32LE(entry.size, 20); + central.writeUInt32LE(entry.size, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(entry.localHeaderOffset, 42); + await handle.write(central); + await handle.write(name); + offset += central.length + name.length; + } + const centralSize = offset - centralStart; + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralStart, 16); + end.writeUInt16LE(0, 20); + await handle.write(end); + } finally { + await handle.close(); + } +} + +async function collectFiles( + inputs: string[], + stats: Array>>, + cwd: string, +): Promise> { + const out: Array<{ fsPath: string; archivePath: string }> = []; + for (let i = 0; i < inputs.length; i += 1) { + const input = inputs[i]!; + const st = stats[i]!; + if (st.isDirectory()) { + const root = basename(input); + const children = await walkDirectory(input); + for (const child of children) { + out.push({ + fsPath: child, + archivePath: sanitizeArchivePath(path.posix.join(root, toPosix(relative(input, child)))), + }); + } + } else if (st.isFile()) { + const rel = toPosix(relative(cwd, input)); + out.push({ fsPath: input, archivePath: sanitizeArchivePath(rel || basename(input)) }); + } + } + out.sort((a, b) => a.archivePath.localeCompare(b.archivePath)); + return out; +} + +async function walkDirectory(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }); + const out: string[] = []; + for (const entry of entries) { + const full = join(root, entry.name); + if (entry.isDirectory()) out.push(...(await walkDirectory(full))); + else if (entry.isFile()) out.push(full); + } + return out; +} + +function sanitizeArchivePath(value: string): string { + const normalized = path.posix.normalize(toPosix(value)).replace(/^(\.\.\/)+/, '').replace(/^\/+/, ''); + return normalized === '.' || normalized.length === 0 ? 'file' : normalized; +} + +function toPosix(value: string): string { + return value.split(path.sep).join('/'); +} + +function dateToDos(date: Date): { dosTime: number; dosDate: number } { + const year = Math.max(1980, date.getFullYear()); + const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { dosTime, dosDate }; +} + +const CRC_TABLE = new Uint32Array(256).map((_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +async function crc32File(filePath: string): Promise { + let crc = 0xffffffff; + for await (const chunk of createReadStream(filePath)) { + const buf = chunk as Buffer; + for (const byte of buf) { + crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/cli/test/cli.integration.test.ts b/cli/test/cli.integration.test.ts new file mode 100644 index 0000000..c71ccf9 --- /dev/null +++ b/cli/test/cli.integration.test.ts @@ -0,0 +1,232 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Writable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runCli } from '../src/cli.js'; + +class MemoryWritable extends Writable { + chunks: string[] = []; + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.chunks.push(chunk.toString()); + callback(); + } + + text(): string { + return this.chunks.join(''); + } +} + +interface RecordedRequest { + method: string; + url: string; + headers: IncomingMessage['headers']; + body: Buffer; +} + +describe('CLI upload flow', () => { + let server: ReturnType | undefined; + let requests: RecordedRequest[] = []; + + beforeEach(() => { + requests = []; + }); + + afterEach(async () => { + if (!server) return; + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + }); + + it('registers a CLI device, uploads a file, completes it, and prints URL only', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fastshared-cli-flow-')); + const file = join(dir, 'hello.txt'); + const config = join(dir, 'config.json'); + await writeFile(file, 'hello world'); + + const apiUrl = await startMockApi(async (req, res, body) => { + requests.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body }); + if (req.method === 'POST' && req.url === '/v1/devices') { + return json(res, 201, { deviceId: 'dev_cli_1', deviceToken: 'tok_cli_1' }); + } + if (req.method === 'POST' && req.url === '/v1/uploads') { + expect(req.headers.authorization).toBe('Bearer tok_cli_1'); + const parsed = JSON.parse(body.toString()) as Record; + expect(parsed.retentionPolicy).toBe('oneHour'); + return json(res, 200, { + uploadId: 'upl_1', + upload: { + mode: 'single', + url: `${apiUrl}/r2/object`, + method: 'PUT', + headers: { 'content-type': 'text/plain', 'content-length': '11' }, + expiresAt: new Date(Date.now() + 900_000).toISOString(), + }, + shortUrl: 'https://fastsha.red/s/pending', + token: 'pending', + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + deleteAfter: new Date(Date.now() + 90_000_000).toISOString(), + linkStatus: 'pending', + retentionPolicy: 'oneHour', + }); + } + if (req.method === 'PUT' && req.url === '/r2/object') { + expect(body.toString()).toBe('hello world'); + res.writeHead(200, { etag: '"etag-1"' }); + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/v1/uploads/upl_1/complete') { + expect(req.headers.authorization).toBe('Bearer tok_cli_1'); + return json(res, 200, { + assetId: '11111111-1111-4111-8111-111111111111', + shortUrl: 'https://fastsha.red/s/finaltoken', + token: 'finaltoken', + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + deleteAfter: new Date(Date.now() + 90_000_000).toISOString(), + linkStatus: 'active', + retentionPolicy: 'oneHour', + }); + } + return json(res, 404, { code: 'not_found', detail: req.url }); + }); + + const stdout = new MemoryWritable(); + const stderr = new MemoryWritable(); + const code = await runCli([file, '--api-url', apiUrl, '--config', config], { + stdout, + stderr, + stdin: process.stdin, + env: {}, + cwd: dir, + }); + + expect(code).toBe(0); + expect(stdout.text()).toBe('https://fastsha.red/s/finaltoken\n'); + expect(stderr.text()).toContain('Uploading hello.txt'); + expect(JSON.parse(await readFile(config, 'utf8'))).toMatchObject({ + deviceId: 'dev_cli_1', + deviceToken: 'tok_cli_1', + apiBaseUrl: apiUrl, + }); + expect((await stat(config)).mode & 0o777).toBe(0o600); + expect(requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'POST /v1/devices', + 'POST /v1/uploads', + 'PUT /r2/object', + 'POST /v1/uploads/upl_1/complete', + ]); + }); + + it('prints structured JSON when requested', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fastshared-cli-json-')); + const file = join(dir, 'hello.txt'); + await writeFile(file, 'hello world'); + + const apiUrl = await startMockApi(async (req, res, body) => { + if (req.method === 'POST' && req.url === '/v1/uploads') { + return json(res, 200, { + uploadId: 'upl_1', + upload: { + mode: 'single', + url: `${apiUrl}/r2/object`, + method: 'PUT', + headers: { 'content-type': 'text/plain', 'content-length': '11' }, + expiresAt: new Date(Date.now() + 900_000).toISOString(), + }, + retentionPolicy: 'oneHour', + }); + } + if (req.method === 'PUT' && req.url === '/r2/object') { + res.writeHead(200); + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/v1/uploads/upl_1/complete') { + return json(res, 200, { + assetId: '11111111-1111-4111-8111-111111111111', + shortUrl: 'https://fastsha.red/s/json', + token: 'json', + expiresAt: '2026-04-25T20:00:00.000Z', + deleteAfter: '2026-04-26T20:00:00.000Z', + linkStatus: 'active', + retentionPolicy: 'oneHour', + }); + } + return json(res, 404, {}); + }); + + const stdout = new MemoryWritable(); + const code = await runCli( + [file, '--api-url', apiUrl, '--json', '--quiet'], + { + stdout, + stderr: new MemoryWritable(), + stdin: process.stdin, + env: { FASTSHARED_DEVICE_TOKEN: 'tok_env' }, + cwd: dir, + }, + ); + + expect(code).toBe(0); + expect(JSON.parse(stdout.text())).toMatchObject({ + shortUrl: 'https://fastsha.red/s/json', + token: 'json', + linkStatus: 'active', + }); + }); + + it('returns non-zero for backend quota errors', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fastshared-cli-error-')); + const file = join(dir, 'too-big.bin'); + await writeFile(file, 'x'); + + const apiUrl = await startMockApi(async (req, res) => { + if (req.method === 'POST' && req.url === '/v1/uploads') { + return json(res, 413, { + title: 'Payload Too Large', + code: 'file_too_large', + detail: 'size exceeds limit', + }); + } + return json(res, 404, {}); + }); + + const stderr = new MemoryWritable(); + const code = await runCli([file, '--api-url', apiUrl, '--quiet'], { + stdout: new MemoryWritable(), + stderr, + stdin: process.stdin, + env: { FASTSHARED_DEVICE_TOKEN: 'tok_env' }, + cwd: dir, + }); + + expect(code).toBe(1); + expect(stderr.text()).toContain('HTTP 413 file_too_large'); + }); + + async function startMockApi( + handler: (req: IncomingMessage, res: ServerResponse, body: Buffer) => Promise | void, + ): Promise { + server = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk as Buffer)); + await handler(req, res, Buffer.concat(chunks)); + }); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('bad server address'); + return `http://127.0.0.1:${address.port}`; + } +}); + +function json(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} diff --git a/cli/test/config.test.ts b/cli/test/config.test.ts new file mode 100644 index 0000000..4e0b89b --- /dev/null +++ b/cli/test/config.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { loadConfig, resolveConfigPath, saveConfig } from '../src/config.js'; + +describe('config', () => { + it('resolves env override before XDG default', () => { + expect(resolveConfigPath({ FASTSHARED_CONFIG: '/tmp/fastshared.json' }, '/home/me')).toBe( + '/tmp/fastshared.json', + ); + expect(resolveConfigPath({ XDG_CONFIG_HOME: '/cfg' }, '/home/me')).toBe( + '/cfg/fastshared/config.json', + ); + }); + + it('saves and loads config with 0600 file mode', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fastshared-config-test-')); + const path = join(dir, 'nested', 'config.json'); + await saveConfig(path, { + version: 1, + deviceId: 'dev_1', + deviceToken: 'tok_1', + apiBaseUrl: 'https://api.test', + }); + + await expect(loadConfig(path)).resolves.toEqual({ + version: 1, + deviceId: 'dev_1', + deviceToken: 'tok_1', + apiBaseUrl: 'https://api.test', + }); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); +}); diff --git a/cli/test/options.test.ts b/cli/test/options.test.ts new file mode 100644 index 0000000..8ebed8a --- /dev/null +++ b/cli/test/options.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { parseArgs, parseTtl, UsageError } from '../src/options.js'; + +describe('options', () => { + it('maps ttl presets to backend retention policies', () => { + expect(parseTtl(undefined)).toBe('oneHour'); + expect(parseTtl('60s')).toBe('oneMinute'); + expect(parseTtl('1h')).toBe('oneHour'); + expect(parseTtl('1d')).toBe('oneDay'); + expect(parseTtl('1w')).toBe('oneWeek'); + expect(parseTtl('30d')).toBe('oneMonth'); + }); + + it('parses upload command options and inputs', () => { + expect( + parseArgs([ + 'upload', + '--ttl', + '60s', + '--json', + '--quiet', + '--api-url=https://api.example.test/', + '--config', + '/tmp/cfg.json', + '--name', + 'trace.txt', + '-', + ]), + ).toEqual({ + inputs: ['-'], + retentionPolicy: 'oneMinute', + outputJson: true, + quiet: true, + help: false, + version: false, + apiUrl: 'https://api.example.test', + configPath: '/tmp/cfg.json', + name: 'trace.txt', + }); + }); + + it('rejects missing input', () => { + expect(() => parseArgs([])).toThrow(UsageError); + }); +}); diff --git a/cli/test/stage.test.ts b/cli/test/stage.test.ts new file mode 100644 index 0000000..09289cd --- /dev/null +++ b/cli/test/stage.test.ts @@ -0,0 +1,57 @@ +import { Readable } from 'node:stream'; +import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { detectMime, stageInputs } from '../src/stage.js'; + +describe('stageInputs', () => { + it('stages a regular file with sha256 and mime detection', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fastshared-stage-file-')); + const file = join(dir, 'hello.txt'); + await writeFile(file, 'hello world'); + + const staged = await stageInputs([file], { cwd: dir }); + expect(staged.filename).toBe('hello.txt'); + expect(staged.contentType).toBe('text/plain'); + expect(staged.sizeBytes).toBe(11); + expect(staged.sha256).toBe( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + ); + await staged.cleanup(); + }); + + it('writes stdin to a temporary file and cleans it up', async () => { + const staged = await stageInputs(['-'], { + name: 'output.bin', + stdin: Readable.from(['abc']), + }); + + expect(staged.filename).toBe('output.bin'); + expect(staged.contentType).toBe('application/octet-stream'); + expect(await readFile(staged.filePath, 'utf8')).toBe('abc'); + await staged.cleanup(); + await expect(stat(staged.filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('zips directories for a single share URL', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fastshared-stage-dir-')); + const folder = join(dir, 'bundle'); + await mkdir(join(folder, 'nested'), { recursive: true }); + await writeFile(join(folder, 'nested', 'a.txt'), 'A'); + await writeFile(join(folder, 'b.json'), '{"b":true}'); + + const staged = await stageInputs([folder], { cwd: dir }); + expect(staged.filename).toBe('bundle.zip'); + expect(staged.contentType).toBe('application/zip'); + expect(staged.sourceKind).toBe('zip'); + expect((await readFile(staged.filePath)).subarray(0, 4).toString('hex')).toBe('504b0304'); + await staged.cleanup(); + }); +}); + +describe('detectMime', () => { + it('falls back to application/octet-stream', () => { + expect(detectMime('artifact.unknownext')).toBe('application/octet-stream'); + }); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..b34eaba --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 0000000..fa69665 --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + }, +}); From e14f6f16bb1ee542721223b4fb014a9abd13cefe Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:32:47 -0300 Subject: [PATCH 09/12] Document and install FastShared CLI --- README.md | 24 ++++++++++++++ docs/product/cli.md | 52 ++++++++++++++++++++++++++++++ web/public/install.sh | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 docs/product/cli.md create mode 100755 web/public/install.sh diff --git a/README.md b/README.md index 1fa2111..66e1346 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ · Product overview · + CLI + · Security · TestFlight runbook @@ -42,6 +44,7 @@ Every link expires. Every file is deleted. By design. | ------- | ---------- | | iPhone and iPad | Native Share Extension, background uploads, Live Activity, and Dynamic Island progress. | | Mac | Menu bar workflow, drag-and-drop uploads, paste-to-upload command, and structured recent links. | +| CLI | Scriptable uploads for agents and shell workflows; stdout returns the temporary URL. | | Recipient | Opens a short link in any browser. No account, app, or sign-in required. | ## Product limits @@ -85,6 +88,7 @@ workflow, with storage lifecycle rules as a safety net. | Layer | Stack | | ----- | ----- | | Apple | SwiftUI, SwiftData, Share Extension, background `URLSession`, Keychain Sharing, CloudKit metadata sync | +| CLI | Node.js 20+, TypeScript, native `fetch`, local ZIP staging | | Backend | Cloudflare Workers, Hono, Drizzle ORM, Neon Postgres, R2, KV | | Web | Astro landing/docs surfaces | | CI/CD | GitHub Actions, Xcode/TestFlight lanes, Wrangler deploy workflows | @@ -94,6 +98,7 @@ workflow, with storage lifecycle rules as a safety net. | Path | Purpose | | ---- | ------- | | `apple/` | iOS, iPadOS, macOS app targets, Share Extension, shared Swift package | +| `cli/` | Node/TypeScript command-line uploader for agents and scripts | | `backend/` | Hono Worker API, persistence, billing verification, retention jobs | | `web/` | Public site and static marketing surfaces | | `docs/` | Product, architecture, security, launch, and ops documentation | @@ -125,6 +130,15 @@ pnpm install pnpm build ``` +CLI: + +```bash +cd cli +pnpm install +pnpm test +pnpm build +``` + ## Validation Backend: @@ -149,9 +163,19 @@ cd web pnpm build ``` +CLI: + +```bash +cd cli +pnpm typecheck +pnpm test +pnpm build +``` + ## Documentation - [Product overview](./docs/product/overview.md) +- [CLI](./docs/product/cli.md) - [System design](./docs/architecture/system-design.md) - [Apple client](./docs/architecture/apple-client.md) - [Backend](./docs/architecture/backend.md) diff --git a/docs/product/cli.md b/docs/product/cli.md new file mode 100644 index 0000000..f68af97 --- /dev/null +++ b/docs/product/cli.md @@ -0,0 +1,52 @@ +# FastShared CLI + +FastShared CLI is the scriptable surface for agents and terminal workflows. It uploads a local artifact and prints a temporary FastShared URL. + +## Install + +```bash +curl -fsSL https://fastsha.red/install.sh | bash +``` + +The installer requires Node.js 20+, installs into `~/.local/share/fastshared-cli`, and creates `~/.local/bin/fastshared`. + +## Usage + +```bash +fastshared ./artifact.zip +fastshared ./folder +fastshared ./a.log ./b.json +fastshared - --name output.txt +fastshared ./trace.log --ttl 60s --json +``` + +Default stdout is URL-only: + +```bash +url="$(fastshared ./result.json)" +``` + +Progress and errors go to stderr so agents can safely consume stdout. + +## Behavior + +- Default retention is `1h`; supported `--ttl` values are `60s`, `1h`, `1d`, `1w`, and `30d`. +- A single file uploads as itself. A directory or multiple paths are zipped locally into one temporary archive and uploaded as one link. +- `-` reads stdin; use `--name` to preserve a useful filename and MIME type. +- `--json` returns `{ shortUrl, token, expiresAt, deleteAfter, linkStatus, retentionPolicy }`. + +## Auth And Config + +On first use the CLI registers a `cli` device through `POST /v1/devices` and stores the returned bearer token in: + +```text +~/.config/fastshared/config.json +``` + +The file is written with `0600` permissions. Overrides: + +- `FASTSHARED_DEVICE_TOKEN` uses a token without reading/writing auth. +- `FASTSHARED_API_URL` changes the API host. +- `FASTSHARED_CONFIG` changes the config file path. + +V1 uses the same device limits as the app: Free devices keep the current Free caps; heavier agent usage can move to a scoped API-token model later. diff --git a/web/public/install.sh b/web/public/install.sh new file mode 100755 index 0000000..499a41b --- /dev/null +++ b/web/public/install.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="${FASTSHARED_CLI_REPO:-MatheusKindrazki/fastshared}" +version="${FASTSHARED_CLI_VERSION:-latest}" +asset="${FASTSHARED_CLI_ASSET:-fastshared-cli.tgz}" +install_dir="${FASTSHARED_INSTALL_DIR:-$HOME/.local/share/fastshared-cli}" +bin_dir="${FASTSHARED_BIN_DIR:-$HOME/.local/bin}" + +if [ -n "${FASTSHARED_CLI_URL:-}" ]; then + url="$FASTSHARED_CLI_URL" +elif [ "$version" = "latest" ]; then + url="https://github.com/$repo/releases/latest/download/$asset" +else + url="https://github.com/$repo/releases/download/$version/$asset" +fi + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + printf "fastshared install: missing required command: %s\n" "$1" >&2 + exit 1 + fi +} + +need curl +need tar +need node +need npm + +node_major="$(node -p "Number(process.versions.node.split('.')[0])")" +if [ "$node_major" -lt 20 ]; then + printf "fastshared install: Node.js 20+ is required, found %s\n" "$(node -v)" >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +cleanup() { + rm -rf "$tmp" +} +trap cleanup EXIT + +printf "Downloading FastShared CLI from %s\n" "$url" >&2 +curl -fsSL "$url" -o "$tmp/$asset" + +tar -xzf "$tmp/$asset" -C "$tmp" +if [ ! -f "$tmp/package/package.json" ]; then + printf "fastshared install: release asset did not contain an npm package\n" >&2 + exit 1 +fi + +rm -rf "$install_dir" +mkdir -p "$install_dir" "$bin_dir" +cp -R "$tmp/package/." "$install_dir/" + +( + cd "$install_dir" + npm install --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null +) + +if [ ! -f "$install_dir/dist/index.js" ]; then + printf "fastshared install: package is missing dist/index.js\n" >&2 + exit 1 +fi + +chmod +x "$install_dir/dist/index.js" +ln -sf "$install_dir/dist/index.js" "$bin_dir/fastshared" + +printf "FastShared CLI installed at %s\n" "$bin_dir/fastshared" >&2 +case ":$PATH:" in + *":$bin_dir:"*) ;; + *) + printf "Add %s to PATH to run 'fastshared' from any shell.\n" "$bin_dir" >&2 + ;; +esac From add049f7d9437d71e33a42977b9033b428eb4956 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:33:43 -0300 Subject: [PATCH 10/12] Add CLI validation workflow --- .github/workflows/cli.yml | 45 +++++++++++++++++++++++++++++++++++++++ Makefile | 9 +++++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cli.yml diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 0000000..4705dda --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,45 @@ +name: cli + +on: + push: + branches: [main] + paths: + - "cli/**" + - ".github/workflows/cli.yml" + - "Makefile" + pull_request: + paths: + - "cli/**" + - ".github/workflows/cli.yml" + - "Makefile" + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: cli/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/Makefile b/Makefile index 8fcbc32..83a1fe9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap ios backend-dev backend-test db-migrate db-generate lint fmt clean web-dev web-build web-deploy testflight testflight-ios testflight-macos testflight-doctor testflight-bootstrap appstore-doctor appstore-screenshots appstore-privacy appstore-metadata appstore-metadata-ios appstore-metadata-macos appstore-sync appstore-submit +.PHONY: bootstrap ios backend-dev backend-test db-migrate db-generate lint fmt clean web-dev web-build web-deploy cli-test cli-build testflight testflight-ios testflight-macos testflight-doctor testflight-bootstrap appstore-doctor appstore-screenshots appstore-privacy appstore-metadata appstore-metadata-ios appstore-metadata-macos appstore-sync appstore-submit BUNDLER_VERSION ?= 2.4.22 BUNDLE ?= bundle _$(BUNDLER_VERSION)_ @@ -35,6 +35,7 @@ fmt: clean: rm -rf backend/node_modules backend/dist backend/.wrangler + rm -rf cli/node_modules cli/dist cli/dist-release rm -rf apple/FastShared.xcodeproj apple/FastShared.xcworkspace rm -rf apple/build apple/DerivedData @@ -47,6 +48,12 @@ web-build: web-deploy: cd web && pnpm build && pnpm dlx wrangler pages deploy dist --project-name fastshared-web +cli-test: + cd cli && pnpm test + +cli-build: + cd cli && pnpm typecheck && pnpm test && pnpm build + # --- TestFlight --------------------------------------------------------------- # First-time setup: `make testflight-bootstrap` (installs fastlane via bundler). # Before uploading: put your App Store Connect API key details in From a6e677336a790ab7a2e26f1946dd6d4ae42eed99 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:34:33 -0300 Subject: [PATCH 11/12] Align CLI release package artifact --- cli/package.json | 2 +- cli/scripts/pack-release.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 cli/scripts/pack-release.mjs diff --git a/cli/package.json b/cli/package.json index 079fc0d..980da75 100644 --- a/cli/package.json +++ b/cli/package.json @@ -16,7 +16,7 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", - "pack:release": "pnpm build && rm -rf dist-release && mkdir -p dist-release && npm pack --pack-destination dist-release" + "pack:release": "pnpm build && node scripts/pack-release.mjs" }, "engines": { "node": ">=20" diff --git a/cli/scripts/pack-release.mjs b/cli/scripts/pack-release.mjs new file mode 100644 index 0000000..11417c4 --- /dev/null +++ b/cli/scripts/pack-release.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { copyFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const outDir = 'dist-release'; +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); + +const packed = spawnSync('npm', ['pack', '--pack-destination', outDir], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], +}); + +if (packed.status !== 0) { + process.exit(packed.status ?? 1); +} + +const file = packed.stdout.trim().split(/\r?\n/).filter(Boolean).pop(); +if (!file) { + throw new Error('npm pack did not report an output filename'); +} + +await copyFile(join(outDir, file), join(outDir, 'fastshared-cli.tgz')); +console.log(join(outDir, 'fastshared-cli.tgz')); From fc2b8c139a8d35f4a92e1d093eb94999eb527632 Mon Sep 17 00:00:00 2001 From: Matheus Kindrazki Date: Sat, 25 Apr 2026 16:47:20 -0300 Subject: [PATCH 12/12] Show CLI agent workflow on landing page --- web/src/components/AgentCliSurface.astro | 69 +++++++++++ web/src/components/FAQ.astro | 4 + web/src/components/Hero.astro | 6 +- web/src/components/Nav.astro | 6 + web/src/pages/index.astro | 2 + web/src/styles/global.css | 146 ++++++++++++++++++++++- 6 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 web/src/components/AgentCliSurface.astro diff --git a/web/src/components/AgentCliSurface.astro b/web/src/components/AgentCliSurface.astro new file mode 100644 index 0000000..e7fce59 --- /dev/null +++ b/web/src/components/AgentCliSurface.astro @@ -0,0 +1,69 @@ +--- +const recipes = [ + { + label: 'Single artifact', + command: 'fastshared ./result.json --ttl 1h', + }, + { + label: 'Directory handoff', + command: 'fastshared ./agent-run --ttl 1h', + }, + { + label: 'stdin output', + command: 'agent > output.txt && fastshared - --name output.txt', + }, +]; +--- +
+
+
+ Agents + CLI +

+ A file handoff primitive for AI agents. +

+

+ Install the Node 20+ CLI, upload the artifact an agent produced, and + pass one short-lived URL to the next tool, model, or human reviewer. + The default retention is one hour. +

+ + +
+ +
+
+ agent handoff + stdout contract +
+
$ curl -fsSL https://fastsha.red/install.sh | bash
+$ fastshared ./handoff --ttl 1h --quiet
+https://fastsha.red/s/A7k2
+
+# progress and errors stay on stderr
+# folders and multiple files become a local zip
+
+
+ +
+ {recipes.map((recipe) => ( +
+ {recipe.label} + {recipe.command} +
+ ))} +
+
diff --git a/web/src/components/FAQ.astro b/web/src/components/FAQ.astro index a9155a6..775f7f4 100644 --- a/web/src/components/FAQ.astro +++ b/web/src/components/FAQ.astro @@ -17,6 +17,10 @@ const items: QA[] = [ q: 'Does it work on Mac?', a: 'Yes. The Mac app is built around a menu bar workflow, drag-and-drop uploads, paste-to-upload, and a structured recent-links list.', }, + { + q: 'Can agents use FastShared from the terminal?', + a: 'Yes. The fastshared CLI uploads files, folders, multiple paths, or stdin and prints only the temporary URL to stdout, so agents can pass it safely to the next step.', + }, { q: 'What happens when a link expires?', a: 'The public link returns 410 Gone and the file moves through the deletion workflow. Revoking a live link does the same thing immediately.', diff --git a/web/src/components/Hero.astro b/web/src/components/Hero.astro index 0c946b4..d930382 100644 --- a/web/src/components/Hero.astro +++ b/web/src/components/Hero.astro @@ -37,7 +37,7 @@
- Temporary file links for Apple devices + Temporary file links for Apple devices and agents

↓ + + CLI for agents + +

diff --git a/web/src/components/Nav.astro b/web/src/components/Nav.astro index 2a4be5f..67d5c85 100644 --- a/web/src/components/Nav.astro +++ b/web/src/components/Nav.astro @@ -33,6 +33,12 @@ > Pricing +