feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode (#467)

* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode

Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).

- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
  a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
  (MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
  unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
  bearer header so both the image content-type and Authorization survive hono's
  per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
  takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
  the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy

The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

---------

Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban-local[bot]
2026-06-20 00:16:07 -04:00
committed by GitHub
co-authored by Alex Chen
parent 0138e7779e
commit 00f48cf355
77 changed files with 4600 additions and 798 deletions
+177 -136
View File
@@ -1,211 +1,252 @@
import { describe, expect, it, vi } from 'vitest'
import type { Platform } from '../../platform/interface'
import type { S3Gateway, StorageRecord, StorageRepo } from '../../usecases/ports'
import { createImageUploadGateway, isImageMime } from './image-upload'
import type { LicenseBindingRepo, LicenseState, LicensingCloudGateway } from '../../usecases/ports'
import { createImageUploadGateway, isAvatarContentType } from './image-upload'
// biome-ignore lint/suspicious/noExplicitAny: test stub intentionally opaque
type Any = any
function mockR2Bucket() {
const AVATAR_PREFIX = '_system/avatars'
const LOGO_PREFIX = '_system/org-logos'
function mockPlatform(env: Record<string, string | undefined> = {}): Platform {
return { db: {} as Any, getEnv: (k: string) => env[k], getBinding: () => undefined } as unknown as Platform
}
function activeBinding(refreshToken: string | null = 'refresh-token'): LicenseState {
return {
put: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
id: 'binding-1',
cloudBindingId: 'cb',
cloudStoreId: null,
instanceId: 'instance-1',
cloudAccountId: 'account-1',
cloudAccountEmail: null,
status: 'active',
refreshToken,
cachedCert: null,
cachedExpiresAt: null,
boundAt: 0,
disconnectedAt: null,
lastRefreshAt: null,
lastRefreshError: null,
}
}
function mockPlatform(opts: { r2?: ReturnType<typeof mockR2Bucket>; publicUrl?: string } = {}): Platform {
return {
db: {} as Any,
getEnv: (key) => (key === 'PUBLIC_IMAGES_URL' ? opts.publicUrl : undefined),
getBinding: <T>(key: string) => (key === 'PUBLIC_IMAGES' && opts.r2 ? (opts.r2 as unknown as T) : undefined),
}
function mockLicenseBinding(binding: LicenseState | null): LicenseBindingRepo {
return { loadActiveLicenseBinding: vi.fn(async () => binding) } as unknown as LicenseBindingRepo
}
// A StorageRepo whose `select('public')` either returns a storage or throws
// (no public storage configured). Only `select` is exercised by the gateway.
function mockStorages(opts: { storage?: StorageRecord; selectThrows?: boolean } = {}): StorageRepo {
return {
select: async () => {
if (opts.selectThrows) throw new Error('no storage')
return opts.storage as StorageRecord
},
} as unknown as StorageRepo
// A fake Cloud client capturing the avatar PUT/DELETE the SDK helper issues. The
// gateway calls the REAL `uploadAvatar`/`deleteAvatar` SDK helpers against it, so
// these spies see exactly what the SDK forwards (param + per-request init).
function mockAvatarClient(putResponse: () => Response = () => json201()) {
const put = vi.fn(async (_args: unknown, _opt: unknown) => putResponse())
const del = vi.fn(async (_args: unknown) => new Response(null, { status: 204 }))
const client = { avatars: { ':scope': { ':id': { $put: put, $delete: del } } } }
return { client, put, del }
}
function mockS3() {
return {
putObject: vi.fn().mockResolvedValue(16),
getPublicUrl: vi.fn().mockReturnValue('https://s3.example/bucket/key'),
deleteObject: vi.fn().mockResolvedValue(undefined),
} as unknown as S3Gateway & {
putObject: ReturnType<typeof vi.fn>
getPublicUrl: ReturnType<typeof vi.fn>
deleteObject: ReturnType<typeof vi.fn>
}
function json201(
body: unknown = { url: 'https://cloud.example/avatars/user/u1.png', key: 'avatars/user/u1' },
): Response {
return new Response(JSON.stringify(body), { status: 201, headers: { 'content-type': 'application/json' } })
}
function cloudError(status: number, code: string): Response {
return new Response(JSON.stringify({ error: code }), { status, headers: { 'content-type': 'application/json' } })
}
function mockLicensingCloud(client: unknown) {
const createAvatarUploadClient = vi.fn(() => client)
return { gateway: { createAvatarUploadClient } as unknown as LicensingCloudGateway, createAvatarUploadClient }
}
function makeFile(type: string, bytes = 16): File {
return new File([new Uint8Array(bytes)], `f.${type.split('/')[1]}`, { type })
return new File([new Uint8Array(bytes)], `f.${type.split('/')[1] ?? 'bin'}`, { type })
}
describe('isImageMime', () => {
it('accepts png/jpeg/webp', () => {
expect(isImageMime('image/png')).toBe(true)
expect(isImageMime('image/jpeg')).toBe(true)
expect(isImageMime('image/webp')).toBe(true)
describe('isAvatarContentType', () => {
it('accepts the Cloud avatar content types (incl. gif)', () => {
expect(isAvatarContentType('image/png')).toBe(true)
expect(isAvatarContentType('image/jpeg')).toBe(true)
expect(isAvatarContentType('image/webp')).toBe(true)
expect(isAvatarContentType('image/gif')).toBe(true)
})
it('rejects other mimes', () => {
expect(isImageMime('image/gif')).toBe(false)
expect(isImageMime('application/pdf')).toBe(false)
expect(isImageMime('')).toBe(false)
expect(isImageMime(undefined)).toBe(false)
expect(isImageMime(42)).toBe(false)
it('rejects unsupported mimes', () => {
expect(isAvatarContentType('application/pdf')).toBe(false)
expect(isAvatarContentType('image/bmp')).toBe(false)
expect(isAvatarContentType('')).toBe(false)
expect(isAvatarContentType(undefined)).toBe(false)
expect(isAvatarContentType(42)).toBe(false)
})
})
describe('uploadPublicImage — R2 binding path', () => {
it('uses R2 binding when PUBLIC_IMAGES + PUBLIC_IMAGES_URL both set', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
describe('uploadPublicImage — Cloud avatar service', () => {
it('uploads a user avatar via the Cloud avatar service with the image content type', async () => {
const { client, put } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const file = makeFile('image/png')
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
const result = await gw.uploadPublicImage(
mockPlatform({ ZPAN_CLOUD_URL: 'https://cloud.example' }),
AVATAR_PREFIX,
'u1',
file,
)
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
expect(r2.put).toHaveBeenCalledOnce()
expect(r2.put.mock.calls[0]?.[0]).toBe('_system/avatars/u1.png')
expect(r2.put.mock.calls[0]?.[2]).toEqual({ httpMetadata: { contentType: 'image/png' } })
if (result.ok) expect(result.url).toBe('https://cloud.example/avatars/user/u1.png')
// Bound client built from the active binding's refresh token + the cloud base url.
expect(createAvatarUploadClient).toHaveBeenCalledWith('https://cloud.example', 'refresh-token')
// The SDK PUT targets /avatars/user/u1 and sends the IMAGE content type (not JSON).
expect(put).toHaveBeenCalledOnce()
expect(put.mock.calls[0]?.[0]).toEqual({ param: { scope: 'user', id: 'u1' } })
const init = (put.mock.calls[0]?.[1] as { init: { body: unknown; headers: Record<string, string> } }).init
expect(init.headers['content-type']).toBe('image/png')
expect(init.body).toBe(file)
})
it('maps mime to correct file extension (jpeg → jpg)', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
it('maps the org-logo prefix to the team scope', async () => {
const { client, put } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/jpeg'))
await gw.uploadPublicImage(mockPlatform(), LOGO_PREFIX, 'team-1', makeFile('image/webp'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toMatch(/\.jpg$/)
expect(put.mock.calls[0]?.[0]).toEqual({ param: { scope: 'team', id: 'team-1' } })
})
it('trims a trailing slash in PUBLIC_IMAGES_URL', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev/' })
it('rejects an unsupported mime with 400 before any Cloud call', async () => {
const { client, put } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
})
it('rejects invalid mime (gif) before touching R2', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/gif'))
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('application/pdf'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(400)
expect(r2.put).not.toHaveBeenCalled()
expect(createAvatarUploadClient).not.toHaveBeenCalled()
expect(put).not.toHaveBeenCalled()
})
it('rejects file > 2 MiB before touching R2', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
it('rejects a file larger than 1 MiB with 413 before any Cloud call', async () => {
const { client, put } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png', 3 * 1024 * 1024))
const result = await gw.uploadPublicImage(
mockPlatform(),
AVATAR_PREFIX,
'u1',
makeFile('image/png', 2 * 1024 * 1024),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(413)
expect(r2.put).not.toHaveBeenCalled()
expect(put).not.toHaveBeenCalled()
})
it('falls back to S3 path when binding is missing (PUBLIC_IMAGES_URL alone ignored)', async () => {
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ publicUrl: 'https://pub-abc.r2.dev' })
it('returns 503 cloud_required when the instance is not paired to Cloud', async () => {
const { client, put } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(null), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(503)
expect(result.error).toBe('cloud_required')
}
expect(createAvatarUploadClient).not.toHaveBeenCalled()
expect(put).not.toHaveBeenCalled()
})
it('returns 503 cloud_required when the active binding has no refresh token', async () => {
const { client } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding(null)), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
})
it('falls back to S3 path when PUBLIC_IMAGES_URL is missing (binding alone ignored)', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2 })
it.each([
['unsupported_media_type', 415, 400],
['payload_too_large', 413, 413],
['license_inactive', 403, 403],
['something_else', 500, 500],
])('maps Cloud error %s to local status %i', async (code, cloudStatus, localStatus) => {
const { client } = mockAvatarClient(() => cloudError(cloudStatus, code))
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
expect(r2.put).not.toHaveBeenCalled()
if (!result.ok) expect(result.status).toBe(localStatus)
})
it('returns 503 when neither binding nor public S3 storage is available', async () => {
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform()
it('returns 500 when the Cloud 201 body is malformed', async () => {
const { client } = mockAvatarClient(() => json201({ nope: true }))
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
if (!result.ok) expect(result.status).toBe(500)
})
it('uploads via S3 gateway when a public storage is configured', async () => {
const s3 = mockS3()
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
const platform = mockPlatform()
it('returns 500 when the Cloud request throws', async () => {
const put = vi.fn(async () => {
throw new Error('network down')
})
const client = { avatars: { ':scope': { ':id': { $put: put, $delete: vi.fn() } } } }
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://s3.example/bucket/key')
expect(s3.putObject).toHaveBeenCalledOnce()
expect(s3.putObject.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
expect(s3.getPublicUrl.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(500)
})
})
describe('deletePublicImageVariants — R2 binding path', () => {
it('deletes all 3 mime variants via R2 binding', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
describe('deletePublicImageVariants — Cloud avatar service', () => {
it('deletes the Cloud-hosted avatar for the scope/id', async () => {
const { client, del } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
await gw.deletePublicImageVariants(mockPlatform(), AVATAR_PREFIX, 'u1')
expect(r2.delete).toHaveBeenCalledTimes(3)
const keys = r2.delete.mock.calls.map((c) => c[0] as string)
expect(keys).toContain('_system/avatars/u1.png')
expect(keys).toContain('_system/avatars/u1.jpg')
expect(keys).toContain('_system/avatars/u1.webp')
expect(del).toHaveBeenCalledOnce()
expect(del.mock.calls[0]?.[0]).toEqual({ param: { scope: 'user', id: 'u1' } })
})
it('deletes all 3 mime variants via S3 gateway when a public storage is configured', async () => {
const s3 = mockS3()
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
const platform = mockPlatform()
it('is a no-op when the instance is not paired to Cloud', async () => {
const { client, del } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(null), gateway)
await gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
expect(s3.deleteObject).toHaveBeenCalledTimes(3)
const keys = s3.deleteObject.mock.calls.map((c) => c[1] as string)
expect(keys).toContain('_system/avatars/u1.png')
expect(keys).toContain('_system/avatars/u1.jpg')
expect(keys).toContain('_system/avatars/u1.webp')
await expect(gw.deletePublicImageVariants(mockPlatform(), AVATAR_PREFIX, 'u1')).resolves.toBeUndefined()
expect(createAvatarUploadClient).not.toHaveBeenCalled()
expect(del).not.toHaveBeenCalled()
})
it('is a no-op when no backend is configured', async () => {
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform()
await expect(gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')).resolves.toBeUndefined()
it('swallows Cloud delete failures (best-effort)', async () => {
const del = vi.fn(async () => {
throw new Error('boom')
})
const client = { avatars: { ':scope': { ':id': { $put: vi.fn(), $delete: del } } } }
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await expect(gw.deletePublicImageVariants(mockPlatform(), LOGO_PREFIX, 'team-1')).resolves.toBeUndefined()
})
})
+85 -75
View File
@@ -1,102 +1,112 @@
import { mimeToExt } from '../../lib/mime-utils'
import {
AVATAR_CONTENT_TYPES,
type AvatarContentType,
type AvatarScope,
avatarUploadResponseSchema,
deleteAvatar,
MAX_AVATAR_BYTES,
uploadAvatar,
} from 'zpan-cloud-sdk'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import type { Platform } from '../../platform/interface'
import type {
ImageMime,
ImageUpload,
ImageUploadResult,
S3Gateway,
StorageRecord,
StorageRepo,
import {
AVATAR_PREFIX,
type ImageUpload,
type ImageUploadResult,
type LicenseBindingRepo,
type LicensingCloudGateway,
LOGO_PREFIX,
} from '../../usecases/ports'
import { IMAGE_MIMES, MAX_IMAGE_SIZE } from '../../usecases/ports'
export function isImageMime(v: unknown): v is ImageMime {
return typeof v === 'string' && (IMAGE_MIMES as readonly string[]).includes(v)
export function isAvatarContentType(v: unknown): v is AvatarContentType {
return typeof v === 'string' && (AVATAR_CONTENT_TYPES as readonly string[]).includes(v)
}
export function imageKey(prefix: string, id: string, mime: ImageMime): string {
return `${prefix}/${id}.${mimeToExt(mime)}`
// Only the avatar + org-logo usecases call this port, and they pass these exact
// prefixes — an unknown prefix is a programming error, so fail fast.
function prefixToScope(prefix: string): AvatarScope {
if (prefix === AVATAR_PREFIX) return 'user'
if (prefix === LOGO_PREFIX) return 'team'
throw new Error(`Unknown image prefix: ${prefix}`)
}
// Minimal R2Bucket interface we actually call — typed locally so we don't
// depend on @cloudflare/workers-types on non-CF builds.
interface R2BucketLike {
put(
key: string,
value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob,
options?: { httpMetadata?: { contentType?: string } },
): Promise<unknown>
delete(key: string): Promise<void>
function cloudErrorCode(data: unknown): string | null {
if (!data || typeof data !== 'object' || !('error' in data)) return null
const error = (data as { error: unknown }).error
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code
return null
}
type Backend =
| { kind: 'r2'; bucket: R2BucketLike; publicUrlBase: string }
| { kind: 's3'; storage: StorageRecord }
| { kind: 'none' }
// Maps a non-2xx Cloud avatar response to the local outcome status. Cloud error
// codes: unsupported_media_type → 400, payload_too_large → 413,
// license_inactive → 403, everything else (incl. malformed) → 500.
async function cloudUploadError(res: {
status: number
json(): Promise<unknown>
}): Promise<Extract<ImageUploadResult, { ok: false }>> {
const code = cloudErrorCode(await res.json().catch(() => null))
switch (code) {
case 'unsupported_media_type':
return { ok: false, status: 400, error: 'unsupported_media_type' }
case 'payload_too_large':
return { ok: false, status: 413, error: 'payload_too_large' }
case 'license_inactive':
return { ok: false, status: 403, error: 'license_inactive' }
default:
return { ok: false, status: 500, error: code ?? `cloud_request_failed_${res.status}` }
}
}
export function createImageUploadGateway(s3: S3Gateway, storages: StorageRepo): ImageUpload {
// CF deployment with `PUBLIC_IMAGES` binding + `PUBLIC_IMAGES_URL` env var →
// writes via R2 binding (zero-auth, zero-egress), reads via R2's public
// domain (direct browser fetch, no Worker round-trip per image).
// Everything else → falls back to the user-configured `mode='public'` S3
// storage in the `storages` table.
async function getBackend(platform: Platform): Promise<Backend> {
const r2 = platform.getBinding<R2BucketLike>('PUBLIC_IMAGES')
const publicUrl = platform.getEnv('PUBLIC_IMAGES_URL')
if (r2 && publicUrl) {
return { kind: 'r2', bucket: r2, publicUrlBase: publicUrl.replace(/\/$/, '') }
}
try {
const storage = await storages.select('public')
return { kind: 's3', storage }
} catch {
return { kind: 'none' }
}
// Host user avatars + team logos on the ZPan Cloud avatar service. Requires the
// instance to be paired to Cloud (an active license binding with a refresh token);
// an unbound instance can't host images, so upload returns `cloud_required` (503)
// and delete is a best-effort no-op. Never throws on the unbound path.
export function createImageUploadGateway(
licenseBinding: LicenseBindingRepo,
licensingCloud: LicensingCloudGateway,
): ImageUpload {
function cloudBaseUrl(platform: Platform): string {
return platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
}
return {
async uploadPublicImage(platform, prefix, id, file): Promise<ImageUploadResult> {
if (!isImageMime(file.type)) {
return { ok: false, status: 400, error: 'Only PNG, JPG, and WebP images are allowed' }
const contentType = file.type
if (!isAvatarContentType(contentType)) {
return { ok: false, status: 400, error: 'Only PNG, JPG, WebP, and GIF images are allowed' }
}
if (file.size > MAX_IMAGE_SIZE) {
return { ok: false, status: 413, error: 'File too large. Max 2 MiB.' }
if (file.size > MAX_AVATAR_BYTES) {
return { ok: false, status: 413, error: 'File too large. Max 1 MiB.' }
}
const backend = await getBackend(platform)
if (backend.kind === 'none') {
return { ok: false, status: 503, error: 'No public storage configured' }
const binding = await licenseBinding.loadActiveLicenseBinding()
if (!binding?.refreshToken) return { ok: false, status: 503, error: 'cloud_required' }
const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken)
try {
const res = await uploadAvatar(client, { scope: prefixToScope(prefix), id, body: file, contentType })
if (!res.ok) return cloudUploadError(res)
const parsed = avatarUploadResponseSchema.safeParse(await res.json())
if (!parsed.success) return { ok: false, status: 500, error: 'invalid_cloud_response' }
return { ok: true, url: parsed.data.url }
} catch {
return { ok: false, status: 500, error: 'cloud_request_failed' }
}
const key = imageKey(prefix, id, file.type)
const bytes = new Uint8Array(await file.arrayBuffer())
if (backend.kind === 'r2') {
await backend.bucket.put(key, bytes, { httpMetadata: { contentType: file.type } })
return { ok: true, url: `${backend.publicUrlBase}/${key}` }
}
await s3.putObject(backend.storage, key, bytes, file.type)
return { ok: true, url: s3.getPublicUrl(backend.storage, key) }
},
// Best-effort delete of every mime variant of a public image. DB clearing is
// the caller's responsibility — this only touches the backend object store.
// Best-effort delete of the Cloud-hosted image. DB clearing is the caller's
// responsibility — this only removes the object from the Cloud avatar service.
// An unbound instance has nothing to delete, so it is a silent no-op.
async deletePublicImageVariants(platform, prefix, id): Promise<void> {
const backend = await getBackend(platform)
if (backend.kind === 'none') return
if (backend.kind === 'r2') {
await Promise.allSettled(IMAGE_MIMES.map((mime) => backend.bucket.delete(imageKey(prefix, id, mime))))
return
}
const binding = await licenseBinding.loadActiveLicenseBinding()
if (!binding?.refreshToken) return
const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken)
try {
await Promise.allSettled(
IMAGE_MIMES.map((mime) => s3.deleteObject(backend.storage, imageKey(prefix, id, mime))),
)
await deleteAvatar(client, { scope: prefixToScope(prefix), id })
} catch (err) {
console.warn('[image-upload] S3 cleanup skipped:', err)
console.warn('[image-upload] cloud avatar delete skipped:', err)
}
},
}
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { deleteAvatar, uploadAvatar } from 'zpan-cloud-sdk'
import { CloudInvalidResponseError, CloudNetworkError, CloudUnboundError } from '../../usecases/ports'
import {
createAvatarUploadClient,
createBoundCloudClient,
createPairing,
pollPairing,
@@ -241,4 +243,35 @@ describe('licensing-cloud', () => {
expect(result).toEqual({ state: 'revoked' })
})
})
describe('createAvatarUploadClient', () => {
it('uploadAvatar sends the image content type AND the bearer token (both survive the per-request merge)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'https://cloud/x.png', key: 'k' }, 201))
const client = createAvatarUploadClient(BASE_URL, 'rt-avatar')
const body = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
await uploadAvatar(client, { scope: 'user', id: 'u1', body, contentType: 'image/png' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/avatars/user/u1')
expect(init.method).toBe('PUT')
// The image content type must reach Cloud (not application/json) AND the
// Authorization header must NOT be dropped by hono's per-request header merge.
expect(new Headers(init.headers).get('content-type')).toBe('image/png')
expect(new Headers(init.headers).get('authorization')).toBe('Bearer rt-avatar')
expect(init.body).toBe(body)
})
it('deleteAvatar sends an authenticated DELETE to /avatars/:scope/:id', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, 204))
const client = createAvatarUploadClient(BASE_URL, 'rt-avatar')
await deleteAvatar(client, { scope: 'team', id: 'team-1' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/avatars/team/team-1')
expect(init.method).toBe('DELETE')
expect(new Headers(init.headers).get('authorization')).toBe('Bearer rt-avatar')
})
})
})
+17 -1
View File
@@ -1,5 +1,6 @@
import { hc } from 'hono/client'
import type { z } from 'zod'
import { type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
import { type AppType, type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
import {
type CloudInstanceInfo,
CloudInvalidResponseError,
@@ -22,6 +23,20 @@ export function createBoundCloudClient(baseUrl: string, refreshToken: string): C
return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), token: refreshToken, headers: JSON_HEADERS })
}
// A bound client for the Cloud avatar service. Avatar uploads stream raw image
// bytes with an image `Content-Type`, so this client must NOT carry JSON_HEADERS.
// It is also built with `hc` directly (not `createCloudClient`) so the bearer
// token stays a PLAIN-OBJECT header: the SDK's `uploadAvatar` sets a per-request
// `Content-Type`, and hono's `hc` drops a client-level `Headers` instance when it
// merges per-request headers (its `deepMerge` spreads a `Headers` instance to
// `{}`), which would strip Authorization. A plain object survives the merge, so
// both the bearer token and the image content-type reach Cloud.
export function createAvatarUploadClient(baseUrl: string, refreshToken: string): CloudClient {
return hc<AppType>(cloudApiBaseUrl(baseUrl), {
init: { headers: { authorization: `Bearer ${refreshToken}` } },
})
}
function createAnonymousCloudClient(baseUrl: string): CloudClient {
return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), headers: JSON_HEADERS })
}
@@ -174,6 +189,7 @@ export function createLicensingCloudGateway(): LicensingCloudGateway {
unbindCloudLicense,
confirmCloudLicense,
createBoundCloudClient,
createAvatarUploadClient,
requestCloudJson,
}
}
-1
View File
@@ -95,7 +95,6 @@ function makeStorage(overrides: Partial<Storage> = {}): Storage {
return {
id: 's1',
title: 'Test',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -61,8 +61,8 @@ const STORAGE_ID = 'st-conflict'
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test', 'private', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -41,8 +41,8 @@ function commitConflictPlan(db: TestDb, orgId: string, plan: ConflictPlan, userI
async function insertStorage(db: TestDb, id = 'st-1') {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test', 'private', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -44,8 +44,8 @@ async function insertStorage(db: TestDb, opts: { id?: string; used?: number } =
const id = opts.id ?? 'st-1'
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test S3', 'private', 'test-bucket', 'https://s3.example.com', 'us-east-1',
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test S3', 'test-bucket', 'https://s3.example.com', 'us-east-1',
'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, ${opts.used ?? 0}, 'active', ${now}, ${now})
`)
return id
@@ -8,7 +8,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -23,7 +22,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -38,7 +36,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -54,7 +51,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -69,7 +65,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -84,7 +79,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'public',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'auto',
@@ -100,7 +94,6 @@ describe('createStorage', () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'Persisted',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -118,7 +111,6 @@ describe('updateStorage', () => {
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
return createStorageRepo(db).create({
title: 'Original',
mode: 'private',
bucket: 'original-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -152,7 +144,6 @@ describe('updateStorage', () => {
const created = await seed(db)
const updated = await createStorageRepo(db).update(created.id, {
title: 'Updated',
mode: 'public',
bucket: 'new-bucket',
endpoint: 'https://r2.example.com',
region: 'auto',
@@ -163,7 +154,6 @@ describe('updateStorage', () => {
status: 'disabled',
})
expect(updated?.title).toBe('Updated')
expect(updated?.mode).toBe('public')
expect(updated?.bucket).toBe('new-bucket')
expect(updated?.endpoint).toBe('https://r2.example.com')
expect(updated?.region).toBe('auto')
@@ -203,7 +193,6 @@ describe('listStorages', () => {
const { db } = await createTestApp()
await createStorageRepo(db).create({
title: 'First',
mode: 'private',
bucket: 'b1',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -213,7 +202,6 @@ describe('listStorages', () => {
})
await createStorageRepo(db).create({
title: 'Second',
mode: 'public',
bucket: 'b2',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -238,7 +226,6 @@ describe('getStorage', () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'Findable',
mode: 'private',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -254,12 +241,10 @@ describe('getStorage', () => {
describe('selectStorage', () => {
async function seedActive(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
mode: 'private' | 'public',
opts: { capacity?: number; used?: number; status?: string } = {},
) {
return createStorageRepo(db).create({
title: 'Seed',
mode,
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -269,29 +254,16 @@ describe('selectStorage', () => {
})
}
it('returns an active private storage with unlimited capacity', async () => {
it('returns an active storage with unlimited capacity', async () => {
const { db } = await createTestApp()
const created = await seedActive(db, 'private')
const found = await createStorageRepo(db).select('private')
const created = await seedActive(db)
const found = await createStorageRepo(db).select()
expect(found.id).toBe(created.id)
})
it('returns an active public storage when requested', async () => {
it('throws when no active storage exists', async () => {
const { db } = await createTestApp()
const created = await seedActive(db, 'public')
const found = await createStorageRepo(db).select('public')
expect(found.id).toBe(created.id)
})
it('throws when no active storage exists for the requested mode', async () => {
const { db } = await createTestApp()
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
})
it('throws when storage is present but mode does not match', async () => {
const { db } = await createTestApp()
await seedActive(db, 'public')
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
})
})
@@ -306,7 +278,6 @@ describe('deleteStorage', () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'Deletable',
mode: 'private',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -323,7 +294,6 @@ describe('deleteStorage', () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'In Use',
mode: 'private',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
+2 -10
View File
@@ -32,7 +32,6 @@ export function createStorageRepo(db: Database): StorageRepo {
const row: StorageRow = {
id: nanoid(),
title: input.title,
mode: input.mode,
bucket: input.bucket,
endpoint: input.endpoint,
region: input.region ?? 'auto',
@@ -65,7 +64,6 @@ export function createStorageRepo(db: Database): StorageRepo {
const now = new Date()
const updated = {
title: input.title ?? existing.title,
mode: input.mode ?? existing.mode,
bucket: input.bucket ?? existing.bucket,
endpoint: input.endpoint ?? existing.endpoint,
region: input.region ?? existing.region,
@@ -95,17 +93,11 @@ export function createStorageRepo(db: Database): StorageRepo {
return 'ok'
},
async select(mode) {
async select() {
const rows = await db
.select()
.from(storages)
.where(
and(
eq(storages.mode, mode),
eq(storages.status, 'active'),
or(eq(storages.capacity, 0), lt(storages.used, storages.capacity)),
),
)
.where(and(eq(storages.status, 'active'), or(eq(storages.capacity, 0), lt(storages.used, storages.capacity))))
.orderBy(asc(storages.createdAt))
.limit(1)
+5 -3
View File
@@ -56,6 +56,8 @@ export function createDeps(platform: Platform): Deps {
const s3 = new S3Service()
const storages = createStorageRepo(db)
const systemOptions = createSystemOptionsRepo(db)
const licenseBinding = createLicenseBindingRepo(db)
const licensingCloud = createLicensingCloudGateway()
return {
activity: createActivityRepo(db),
announcements: createAnnouncementRepo(db),
@@ -74,10 +76,10 @@ export function createDeps(platform: Platform): Deps {
invites: createInviteRepo(db),
imageHostingConfigs: createImageHostingConfigRepo(db),
imageHosting: createImageHostingRepo(db),
imageUpload: createImageUploadGateway(s3, storages),
imageUpload: createImageUploadGateway(licenseBinding, licensingCloud),
instance: createInstanceRepo(db),
licenseBinding: createLicenseBindingRepo(db),
licensingCloud: createLicensingCloudGateway(),
licenseBinding,
licensingCloud,
matter: createMatterRepo(db),
memberCount: createMemberCountRepo(db),
notifications: createNotificationRepo(db),
-1
View File
@@ -58,7 +58,6 @@ export const webdavLocks = sqliteTable(
export const storages = sqliteTable('storages', {
id: text('id').primaryKey(),
title: text('title').notNull(),
mode: text('mode').notNull(),
bucket: text('bucket').notNull(),
endpoint: text('endpoint').notNull(),
region: text('region').notNull().default('auto'),
@@ -350,8 +350,8 @@ describe('background jobs API', () => {
async function seedStorage(db: TestDb): Promise<void> {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('route-storage', 'Route Storage', 'private', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('route-storage', 'Route Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -76,12 +76,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
'remote-download-storage', 'Remote Download Storage', 'private', 'test-bucket',
'remote-download-storage', 'Remote Download Storage', 'test-bucket',
'https://s3.example.com', 'auto', 'test-access-key', 'test-secret-key',
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
)
@@ -999,7 +999,6 @@ describe('DELETE /api/image-hosting/config', () => {
await db.insert(schema.storages).values({
id: storageId,
title: 'Test Storage',
mode: 's3',
bucket: 'test',
endpoint: 'https://s3.example.com',
region: 'auto',
@@ -15,7 +15,6 @@ beforeEach(() => {
const validStorage = {
id: 'st-ihost-1',
title: 'Test S3',
mode: 'private',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -29,8 +28,8 @@ type TestAuth = Awaited<ReturnType<typeof createTestApp>>['auth']
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+2 -2
View File
@@ -117,8 +117,8 @@ async function signUp(app: ReturnType<typeof createApp>, db: Awaited<ReturnType<
async function insertStorage(db: Awaited<ReturnType<typeof buildAppWithDb>>['db'], id: string) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+18 -20
View File
@@ -90,7 +90,6 @@ afterEach(() => {
const validStorage = {
id: 'st-1',
title: 'Test S3',
mode: 'private',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -103,12 +102,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
const metered = opts.metered ? 1 : 0
await db.run(sql`
INSERT INTO storages (
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket},
${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
'', '', 0, 0, 'active', ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
@@ -839,8 +838,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
@@ -862,8 +861,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
@@ -886,8 +885,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
await createMatter(db, {
@@ -937,8 +936,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
orgId: 'org-1',
@@ -956,8 +955,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
orgId: 'org-1',
@@ -986,8 +985,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const source = await createMatter(db, {
orgId: 'org-1',
@@ -1428,7 +1427,6 @@ describe('Objects API — quota enforcement', () => {
const validStorage = {
id: 'st-quota',
title: 'Quota S3',
mode: 'private',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -1439,8 +1437,8 @@ describe('Objects API — quota enforcement', () => {
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], used = 0) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket},
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey},
${validStorage.secretKey}, '', '', 0, ${used}, 'active', ${now}, ${now})
`)
@@ -2067,12 +2065,12 @@ describe('object multipart upload API with S3-compatible storage', () => {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
'multipart-live-storage', 'Multipart Live Storage', 'private', 'test-bucket',
'multipart-live-storage', 'Multipart Live Storage', 'test-bucket',
${endpoint}, 'auto', 'test-access-key', 'test-secret-key',
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
)
+2 -2
View File
@@ -37,8 +37,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+4 -4
View File
@@ -21,8 +21,8 @@ beforeEach(() => {
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -601,8 +601,8 @@ describe('GET /r/:token — two-org isolation', () => {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
await insertImageHosting(db, orgId, { id: 'ih-iso1', token: 'ih_isolationtest' })
+2 -2
View File
@@ -64,8 +64,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+4 -5
View File
@@ -15,7 +15,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
const validStorage = {
id: 'st-share-test',
title: 'Test S3',
mode: 'private',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -26,8 +25,8 @@ const validStorage = {
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -1060,8 +1059,8 @@ describe('Public share routes', () => {
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -18,7 +18,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
const validStorage = {
id: 'st-audit-test',
title: 'Audit Test S3',
mode: 'private',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -29,8 +28,8 @@ const validStorage = {
async function insertStorage(db: TestDb, id = validStorage.id) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -396,7 +395,6 @@ describe('Storage audit events', () => {
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'New Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -412,8 +410,6 @@ describe('Storage audit events', () => {
expect(evt?.targetName).toBe('New Storage')
// Must NOT store secret keys or access keys in metadata
assertNoSecrets(evt?.metadata ?? null)
const meta = JSON.parse(evt?.metadata ?? '{}') as { mode: string }
expect(meta.mode).toBe('private')
})
it('records storage_update when admin updates a storage', async () => {
@@ -426,7 +422,6 @@ describe('Storage audit events', () => {
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Original Storage',
mode: 'private',
bucket: 'my-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -460,7 +455,6 @@ describe('Storage audit events', () => {
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Deletable Storage',
mode: 'private',
bucket: 'del-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
-1
View File
@@ -35,7 +35,6 @@ async function adminHeaders(app: ReturnType<typeof buildApp>) {
const validStorage = {
title: 'CF Test S3',
mode: 'private',
bucket: 'cf-test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
+24 -51
View File
@@ -6,7 +6,6 @@ import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
const validStorage = {
title: 'Test S3',
mode: 'private',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -57,7 +56,6 @@ describe('Admin Storages API', () => {
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.title).toBe('Test S3')
expect(body.mode).toBe('private')
expect(body.bucket).toBe('test-bucket')
expect(body.status).toBe('active')
expect(body.capacity).toBe(0)
@@ -228,7 +226,6 @@ async function insertStorage(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
opts: {
id: string
mode: 'private' | 'public'
status?: string
capacity?: number
used?: number
@@ -241,99 +238,75 @@ async function insertStorage(
const status = opts.status ?? 'active'
const title = `Storage ${opts.id}`
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${opts.id}, ${title}, ${opts.mode}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${opts.id}, ${title}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
`)
}
describe('selectStorage service', () => {
it('returns the single active storage when capacity is unlimited (0) [spec: storages/select-active]', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0 })
await insertStorage(db, { id: 's1', capacity: 0, used: 0 })
const storage = await createStorageRepo(db).select('private')
const storage = await createStorageRepo(db).select()
expect(storage.id).toBe('s1')
})
it('returns storage when used is below capacity', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 50 })
await insertStorage(db, { id: 's1', capacity: 100, used: 50 })
const storage = await createStorageRepo(db).select('private')
const storage = await createStorageRepo(db).select()
expect(storage.id).toBe('s1')
})
it('skips storage where used equals capacity', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 100, createdAt: 1 })
await insertStorage(db, { id: 's2', mode: 'private', capacity: 200, used: 50, createdAt: 2 })
await insertStorage(db, { id: 's1', capacity: 100, used: 100, createdAt: 1 })
await insertStorage(db, { id: 's2', capacity: 200, used: 50, createdAt: 2 })
const storage = await createStorageRepo(db).select('private')
const storage = await createStorageRepo(db).select()
expect(storage.id).toBe('s2')
})
it('skips storage where used exceeds capacity', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 110, createdAt: 1 })
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
await insertStorage(db, { id: 's1', capacity: 100, used: 110, createdAt: 1 })
await insertStorage(db, { id: 's2', capacity: 0, used: 0, createdAt: 2 })
const storage = await createStorageRepo(db).select('private')
const storage = await createStorageRepo(db).select()
expect(storage.id).toBe('s2')
})
it('picks the oldest active storage first (sequential fill order)', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0, createdAt: 1 })
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
await insertStorage(db, { id: 's1', capacity: 0, used: 0, createdAt: 1 })
await insertStorage(db, { id: 's2', capacity: 0, used: 0, createdAt: 2 })
const storage = await createStorageRepo(db).select('private')
const storage = await createStorageRepo(db).select()
expect(storage.id).toBe('s1')
})
it('ignores disabled storages', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', status: 'disabled', capacity: 0, createdAt: 1 })
await insertStorage(db, { id: 's2', mode: 'private', status: 'active', capacity: 0, createdAt: 2 })
await insertStorage(db, { id: 's1', status: 'disabled', capacity: 0, createdAt: 1 })
await insertStorage(db, { id: 's2', status: 'active', capacity: 0, createdAt: 2 })
const storage = await createStorageRepo(db).select('private')
const storage = await createStorageRepo(db).select()
expect(storage.id).toBe('s2')
})
it('ignores storages of a different mode', async () => {
it('throws when no active storage exists', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
})
it('throws when no active storage exists for the requested mode', async () => {
it('throws when all storages are at full capacity', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', capacity: 50, used: 50 })
await insertStorage(db, { id: 's2', capacity: 100, used: 100 })
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
})
it('throws when all storages of the mode are at full capacity', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 50, used: 50 })
await insertStorage(db, { id: 's2', mode: 'private', capacity: 100, used: 100 })
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
})
it('returns a public storage when mode is public', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
const storage = await createStorageRepo(db).select('public')
expect(storage.id).toBe('s1')
})
it('does not return a public storage when private mode is requested', async () => {
const { db } = await createTestApp()
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, createdAt: 1 })
await insertStorage(db, { id: 's2', mode: 'public', capacity: 0, createdAt: 2 })
const storage = await createStorageRepo(db).select('private')
expect(storage.id).toBe('s1')
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
})
})
-1
View File
@@ -13,7 +13,6 @@ const storageSchema = z
.object({
id: z.string(),
title: z.string(),
mode: z.string(),
bucket: z.string(),
endpoint: z.string(),
region: z.string(),
@@ -42,12 +42,12 @@ async function insertStorage(db: Database) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
${STORAGE_ID}, 'Cloud Traffic S3', 'private', 'test-bucket', 'https://s3.amazonaws.com',
${STORAGE_ID}, 'Cloud Traffic S3', 'test-bucket', 'https://s3.amazonaws.com',
'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', true, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
`)
+63 -30
View File
@@ -1,10 +1,9 @@
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../adapters/gateways/s3.js'
import { createTeamInviteRepo } from '../adapters/repos/team-invite.js'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
import { createTestApp, seedBusinessLicense } from '../test/setup.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
@@ -659,12 +658,35 @@ describe('GET /api/teams/:teamId/activity — isolation', () => {
// ─── Org logo (PUT/DELETE /:teamId/logo) ─────────────────────────────────────
async function insertPublicStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('st-logo', 'Public', 'public', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKID', 'secret', '', '', 0, 0, 'active', ${now}, ${now})
`)
const CLOUD_LOGO_URL = 'https://avatars.zpan.cloud/team/logo.png'
// Stub the Cloud avatar service and capture each request so tests can assert the
// /avatars/team/:id path, the image content type, and the bearer auth that reach
// Cloud. `seedBusinessLicense` makes the instance Cloud-paired.
function stubCloudAvatarFetch() {
const calls: { url: string; method: string; contentType: string | null; authorization: string | null }[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url)
if (u.includes('/avatars/')) {
const headers = new Headers(init?.headers)
calls.push({
url: u,
method: init?.method ?? 'GET',
contentType: headers.get('content-type'),
authorization: headers.get('authorization'),
})
if (init?.method === 'DELETE') return new Response(null, { status: 204 })
return new Response(JSON.stringify({ url: CLOUD_LOGO_URL, key: 'avatars/team/logo' }), {
status: 201,
headers: { 'content-type': 'application/json' },
})
}
return new Response('unexpected fetch', { status: 404 })
}),
)
return calls
}
function makeFile(type: string, bytes = 16): File {
@@ -674,7 +696,7 @@ function makeFile(type: string, bytes = 16): File {
describe('PUT /api/teams/:teamId/logo', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16)
vi.unstubAllGlobals()
})
it('returns 401 without auth', async () => {
@@ -690,7 +712,6 @@ describe('PUT /api/teams/:teamId/logo', () => {
const { headers, userId } = await signUpAndGetUser(app, `m-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'member')
await insertPublicStorage(db)
const form = new FormData()
form.set('file', makeFile('image/png'))
@@ -698,33 +719,37 @@ describe('PUT /api/teams/:teamId/logo', () => {
expect(res.status).toBe(403)
})
it('returns 400 when mime is invalid (gif)', async () => {
it('returns 400 for an unsupported mime, before any Cloud call', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'owner')
await insertPublicStorage(db)
const calls = stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/gif'))
form.set('file', makeFile('application/pdf'))
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
expect(res.status).toBe(400)
expect(calls).toHaveLength(0)
})
it('returns 413 when file > 2 MiB', async () => {
it('returns 413 when file > 1 MiB, before any Cloud call', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'owner')
await insertPublicStorage(db)
const calls = stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/png', 3 * 1024 * 1024))
form.set('file', makeFile('image/png', 2 * 1024 * 1024))
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
expect(res.status).toBe(413)
expect(calls).toHaveLength(0)
})
it('returns 503 when no public storage is configured', async () => {
it('returns 503 cloud_required when the instance is not paired to Cloud', async () => {
const { app, db } = await createTestApp()
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
@@ -736,32 +761,37 @@ describe('PUT /api/teams/:teamId/logo', () => {
expect(res.status).toBe(503)
})
it('uploads + writes organization.logo + returns URL (owner)', async () => {
it('hosts the logo on Cloud + writes organization.logo + returns URL (owner)', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'owner')
await insertPublicStorage(db)
const calls = stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/jpeg'))
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
expect(res.status).toBe(200)
const body = (await res.json()) as { url: string }
expect(body.url).toContain(`_system/org-logos/${orgId}`)
expect(body.url).toContain('.jpg')
expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1)
expect(body.url).toBe(CLOUD_LOGO_URL)
const put = calls.find((c) => c.method === 'PUT')
expect(put?.url).toContain(`/avatars/team/${orgId}`)
expect(put?.contentType).toBe('image/jpeg')
expect(put?.authorization).toBe('Bearer test-refresh-token')
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
expect(rows[0]?.logo).toBe(body.url)
expect(rows[0]?.logo).toBe(CLOUD_LOGO_URL)
})
it('succeeds for admin role (not just owner)', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const { headers, userId } = await signUpAndGetUser(app, `a-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'admin')
await insertPublicStorage(db)
stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/png'))
@@ -773,7 +803,7 @@ describe('PUT /api/teams/:teamId/logo', () => {
describe('DELETE /api/teams/:teamId/logo', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
vi.unstubAllGlobals()
})
it('returns 401 without auth', async () => {
@@ -792,34 +822,37 @@ describe('DELETE /api/teams/:teamId/logo', () => {
expect(res.status).toBe(403)
})
it('clears organization.logo + removes all mime variants from S3', async () => {
it('clears organization.logo + deletes the Cloud logo', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'owner')
await insertPublicStorage(db)
await db.run(sql`UPDATE organization SET logo = 'https://example.com/old.png' WHERE id = ${orgId}`)
const calls = stubCloudAvatarFetch()
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'DELETE', headers })
expect(res.status).toBe(204)
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
expect(rows[0]?.logo).toBeNull()
expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3)
const del = calls.find((c) => c.method === 'DELETE')
expect(del?.url).toContain(`/avatars/team/${orgId}`)
})
it('succeeds when no public storage (DB cleared, S3 skipped)', async () => {
it('succeeds when the instance is not paired to Cloud (DB cleared, Cloud delete skipped)', async () => {
const { app, db } = await createTestApp()
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'owner')
await db.run(sql`UPDATE organization SET logo = 'https://example.com/old.png' WHERE id = ${orgId}`)
const calls = stubCloudAvatarFetch()
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'DELETE', headers })
expect(res.status).toBe(204)
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
expect(rows[0]?.logo).toBeNull()
expect(calls).toHaveLength(0)
})
})
+4 -1
View File
@@ -9,6 +9,7 @@ import {
expired,
forbidden,
type InviteLinkInfo,
internalError,
noStorage,
notFound,
type PendingInvitation,
@@ -134,9 +135,11 @@ function failureError(failure: { status: 400 | 404; error: string }) {
}
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
function imageUploadError(status: 400 | 413 | 503, error: string) {
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
if (status === 413) return payloadTooLarge(error)
if (status === 503) return noStorage(error)
if (status === 403) return forbidden(error)
if (status === 500) return internalError(error)
return badRequest(error)
}
+63 -48
View File
@@ -1,10 +1,7 @@
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../adapters/gateways/s3.js'
import { buildBreadcrumb } from '../domain/breadcrumb.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js'
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
// Sign up first user (gets promoted to admin via hook)
@@ -374,12 +371,36 @@ describe('User entitlements API (admin)', () => {
// ─── User avatar (PUT/DELETE /api/users/me/avatar) ────────────────────────────
async function insertPublicStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('st-me', 'Public', 'public', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKID', 'secret', '', '', 0, 0, 'active', ${now}, ${now})
`)
const CLOUD_AVATAR_URL = 'https://avatars.zpan.cloud/user/u1.webp'
// Stub the Cloud avatar service and capture each avatar request so tests can
// assert the /avatars/:scope/:id path, the image content type, and the bearer
// auth that reach Cloud. `seedBusinessLicense` makes the instance Cloud-paired
// (active license binding, refresh token 'test-refresh-token').
function stubCloudAvatarFetch() {
const calls: { url: string; method: string; contentType: string | null; authorization: string | null }[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url)
if (u.includes('/avatars/')) {
const headers = new Headers(init?.headers)
calls.push({
url: u,
method: init?.method ?? 'GET',
contentType: headers.get('content-type'),
authorization: headers.get('authorization'),
})
if (init?.method === 'DELETE') return new Response(null, { status: 204 })
return new Response(JSON.stringify({ url: CLOUD_AVATAR_URL, key: 'avatars/user/u1' }), {
status: 201,
headers: { 'content-type': 'application/json' },
})
}
return new Response('unexpected fetch', { status: 404 })
}),
)
return calls
}
function makeFile(type: string, bytes = 16): File {
@@ -389,7 +410,7 @@ function makeFile(type: string, bytes = 16): File {
describe('PUT /api/users/me/avatar', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16)
vi.unstubAllGlobals()
})
it('returns 401 without auth [spec: avatar/auth-required]', async () => {
@@ -420,27 +441,31 @@ describe('PUT /api/users/me/avatar', () => {
expect(res.status).toBe(400)
})
it('returns 400 when mime is not PNG/JPG/WebP [spec: avatar/mime-validated]', async () => {
it('returns 400 for an unsupported mime, before any Cloud call [spec: avatar/mime-validated]', async () => {
const { app, db } = await createTestApp()
await insertPublicStorage(db)
await seedBusinessLicense(db)
const headers = await authedHeaders(app)
const calls = stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/gif'))
form.set('file', makeFile('application/pdf'))
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
expect(res.status).toBe(400)
expect(calls).toHaveLength(0)
})
it('returns 413 when file exceeds 2 MiB [spec: avatar/size-limit]', async () => {
it('returns 413 when the file exceeds 1 MiB, before any Cloud call [spec: avatar/size-limit]', async () => {
const { app, db } = await createTestApp()
await insertPublicStorage(db)
await seedBusinessLicense(db)
const headers = await authedHeaders(app)
const calls = stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/png', 3 * 1024 * 1024))
form.set('file', makeFile('image/png', 2 * 1024 * 1024))
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
expect(res.status).toBe(413)
expect(calls).toHaveLength(0)
})
it('returns 503 when no public storage is configured [spec: avatar/needs-storage]', async () => {
it('returns 503 cloud_required when the instance is not paired to Cloud [spec: avatar/needs-cloud]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const form = new FormData()
@@ -449,47 +474,33 @@ describe('PUT /api/users/me/avatar', () => {
expect(res.status).toBe(503)
})
it('uploads the file to S3, writes user.image, returns the URL [spec: avatar/upload]', async () => {
it('hosts the avatar on Cloud, writes user.image, returns the URL [spec: avatar/upload]', async () => {
const { app, db } = await createTestApp()
await insertPublicStorage(db)
await seedBusinessLicense(db)
const headers = await authedHeaders(app)
const calls = stubCloudAvatarFetch()
const form = new FormData()
form.set('file', makeFile('image/webp'))
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
expect(res.status).toBe(200)
const body = (await res.json()) as { url: string }
expect(body.url).toContain('_system/avatars/')
expect(body.url).toContain('.webp')
expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1)
expect(body.url).toBe(CLOUD_AVATAR_URL)
const put = calls.find((c) => c.method === 'PUT')
expect(put?.url).toMatch(/\/avatars\/user\//)
expect(put?.contentType).toBe('image/webp')
expect(put?.authorization).toBe('Bearer test-refresh-token')
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
expect(rows[0]?.image).toBe(body.url)
})
it('is idempotent — re-PUT with same mime returns the same URL [spec: avatar/idempotent]', async () => {
const { app, db } = await createTestApp()
await insertPublicStorage(db)
const headers = await authedHeaders(app)
const form1 = new FormData()
form1.set('file', makeFile('image/png'))
const res1 = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form1 })
const body1 = (await res1.json()) as { url: string }
const form2 = new FormData()
form2.set('file', makeFile('image/png'))
const res2 = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form2 })
const body2 = (await res2.json()) as { url: string }
expect(body1.url).toBe(body2.url)
expect(rows[0]?.image).toBe(CLOUD_AVATAR_URL)
})
})
describe('DELETE /api/users/me/avatar', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
vi.unstubAllGlobals()
})
it('returns 401 without auth', async () => {
@@ -498,30 +509,34 @@ describe('DELETE /api/users/me/avatar', () => {
expect(res.status).toBe(401)
})
it('clears user.image and removes all mime variants from S3 [spec: avatar/delete]', async () => {
it('clears user.image and deletes the Cloud avatar [spec: avatar/delete]', async () => {
const { app, db } = await createTestApp()
await insertPublicStorage(db)
await seedBusinessLicense(db)
const headers = await authedHeaders(app)
await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`)
const calls = stubCloudAvatarFetch()
const res = await app.request('/api/users/me/avatar', { method: 'DELETE', headers })
expect(res.status).toBe(204)
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
expect(rows[0]?.image).toBeNull()
// 3 mime variants attempted (png, jpg, webp)
expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3)
const del = calls.find((c) => c.method === 'DELETE')
expect(del?.url).toMatch(/\/avatars\/user\//)
})
it('succeeds when no public storage exists (DB cleared, S3 skipped) [spec: avatar/delete-no-storage]', async () => {
it('succeeds when the instance is not paired to Cloud (DB cleared, Cloud delete skipped) [spec: avatar/delete-unbound]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`)
const calls = stubCloudAvatarFetch()
const res = await app.request('/api/users/me/avatar', { method: 'DELETE', headers })
expect(res.status).toBe(204)
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
expect(rows[0]?.image).toBeNull()
expect(calls).toHaveLength(0)
})
})
+12 -2
View File
@@ -1,7 +1,15 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { badRequest, noStorage, notFound, payloadTooLarge, unsupportedMediaType } from '../usecases/ports'
import {
badRequest,
forbidden,
internalError,
noStorage,
notFound,
payloadTooLarge,
unsupportedMediaType,
} from '../usecases/ports'
import { getUserQuota } from '../usecases/quota'
import {
getPublicProfile,
@@ -52,9 +60,11 @@ function failureError(failure: { status: 400 | 404; error: string }) {
}
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
function imageUploadError(status: 400 | 413 | 503, error: string) {
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
if (status === 413) return payloadTooLarge(error)
if (status === 503) return noStorage(error)
if (status === 403) return forbidden(error)
if (status === 500) return internalError(error)
return badRequest(error)
}
+2 -4
View File
@@ -14,7 +14,6 @@ type TestApp = Awaited<ReturnType<typeof createTestApp>>
const storage = {
id: 'dav-storage',
title: 'DAV Storage',
mode: 'private',
bucket: 'dav-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -48,8 +47,8 @@ function streamBody(text: string): ReadableStream {
async function seedStorage(db: TestApp['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${storage.id}, ${storage.title}, ${storage.mode}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${storage.id}, ${storage.title}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -2044,7 +2043,6 @@ describe('WebDAV over real HTTP (npm client)', () => {
const e2eStorage = {
id: 'webdav-e2e-storage',
title: 'WebDAV E2E Storage',
mode: 'private',
bucket: 'webdav-e2e-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -33,8 +33,8 @@ async function signUpAndGetOrgId(app: ReturnType<typeof createApp>, db: TestDb)
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF Domain S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF Domain S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -21,8 +21,8 @@ async function getOrgId(db: TestDb): Promise<string> {
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
-1
View File
@@ -160,7 +160,6 @@ const APP_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS storages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
mode TEXT NOT NULL,
bucket TEXT NOT NULL,
endpoint TEXT NOT NULL,
region TEXT NOT NULL DEFAULT 'auto',
+2 -2
View File
@@ -830,8 +830,8 @@ describe('archive processing', () => {
async function seedStorage(db: TestDb): Promise<void> {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Archive Storage', 'private', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Archive Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+2 -2
View File
@@ -125,7 +125,7 @@ async function runCompressionJob(
})
}
const targetStorage = await deps.storages.select('private')
const targetStorage = await deps.storages.select()
const key = buildObjectKey({ uid: userId, orgId, rawExt: '.zip' })
let objectWritten = false
let outputBytes = 0
@@ -205,7 +205,7 @@ async function runExtractionJob(
const progress = createArchiveProgressReporter(deps, orgId, jobId, sourceHead.size, plan.fileCount)
await progress.report(true)
const targetFolder = request.targetFolder ?? zipMatter.parent
const targetStorage = await deps.storages.select('private')
const targetStorage = await deps.storages.select()
const writtenKeys: string[] = []
const createdMatterIds: string[] = []
const folderParents = new Map<string, string>()
+2 -2
View File
@@ -27,7 +27,7 @@ import {
// Fakes for the ports the image-hosting usecase touches. Each test overrides the
// handful of methods it exercises; the rest throw so an unexpected call is loud.
const sampleStorage = { id: 'st-1', title: 'S3', mode: 'private' } as StorageRecord
const sampleStorage = { id: 'st-1', title: 'S3' } as StorageRecord
const sampleConfig: ImageHostingConfigRecord = {
orgId: 'o1',
@@ -158,7 +158,7 @@ describe('image-hosting usecase', () => {
const out = await uploadImageHosting(deps, { orgId: 'o1', path: 'a.png', mime: 'image/png', bytes })
expect(out.ok).toBe(true)
if (out.ok) expect(out.row.path).toBe('a.png')
expect(select).toHaveBeenCalledWith('private')
expect(select).toHaveBeenCalledWith()
expect(create).toHaveBeenCalledWith(expect.objectContaining({ orgId: 'o1', status: 'draft', size: 100 }))
expect(putObject).toHaveBeenCalledTimes(1)
expect(setActive).toHaveBeenCalledTimes(1)
+2 -2
View File
@@ -115,7 +115,7 @@ export async function uploadImageHosting(
): Promise<UploadImageHostingOutcome> {
let storage: StorageRecord
try {
storage = await deps.storages.select('private')
storage = await deps.storages.select()
} catch {
return { ok: false, error: noStorage() }
}
@@ -152,7 +152,7 @@ export async function presignImageHostingUpload(
): Promise<PresignImageHostingOutcome> {
let storage: StorageRecord
try {
storage = await deps.storages.select('private')
storage = await deps.storages.select()
} catch {
return { ok: false, error: noStorage() }
}
+2 -2
View File
@@ -39,8 +39,8 @@ const saveShareToDrive = (db: Database, input: SaveShareInput) => saveShareToDri
async function seedStorage(db: ReturnType<typeof buildDb>, id: string) {
await db.run(
`INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('${id}', 'CF Test S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
`INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('${id}', 'CF Test S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
)
}
+4 -4
View File
@@ -53,8 +53,8 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
async function insertStorage(db: TestDb, id = STORAGE_ID) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -824,8 +824,8 @@ describe('trash purge', () => {
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('st-1', 'Test S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('st-1', 'Test S3', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
}
-1
View File
@@ -51,7 +51,6 @@ vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
const storage = {
id: 'st-1',
title: 'S3',
mode: 'private',
egressCreditBillingEnabled: false,
egressCreditUnitBytes: 0,
egressCreditPerUnit: 0,
+2 -2
View File
@@ -175,7 +175,7 @@ export async function createObject(
let storage: StorageRecord
try {
storage = await deps.storages.select('private')
storage = await deps.storages.select()
} catch (error) {
if (error instanceof Error && error.message === 'No available storage') {
return { ok: false, error: noStorage() }
@@ -1067,7 +1067,7 @@ export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterTo
const sourceStorage = await deps.storages.get(sourceMatter.storageId)
if (!sourceStorage) throw new Error('Source storage not found')
const targetStorage = await deps.storages.select('private')
const targetStorage = await deps.storages.select()
if (sourceMatter.dirtype === DirType.FILE) {
const newMatter = await saveFile(
+1
View File
@@ -38,6 +38,7 @@ export const expired = (message = 'Expired') => new AppError(410, message)
export const conflict = (message: string, reason?: string) => new AppError(409, message, { reason })
export const badRequest = (message: string, reason?: string) => new AppError(400, message, { reason })
export const badGateway = (message: string, reason?: string) => new AppError(502, message, { reason })
export const internalError = (message = 'Internal error', reason?: string) => new AppError(500, message, { reason })
export const payloadTooLarge = (message = 'Payload too large') =>
new AppError(413, message, { reason: ErrorReason.PAYLOAD_TOO_LARGE })
+15 -8
View File
@@ -1,15 +1,22 @@
import type { Platform } from '../../platform/interface'
export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp'] as const
export type ImageMime = (typeof IMAGE_MIMES)[number]
export const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2 MiB
// User avatars (`AVATAR_PREFIX`) and team/org logos (`LOGO_PREFIX`) are hosted on
// the ZPan Cloud avatar service. The prefix the usecase passes encodes which one
// it is; the gateway maps it to the Cloud avatar scope (`user` / `team`).
export const AVATAR_PREFIX = '_system/avatars'
export const LOGO_PREFIX = '_system/org-logos'
export type ImageUploadResult = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string }
// Cloud-hosting outcome. A failure carries the HTTP status the http layer renders:
// 400 unsupported image type · 413 too large · 403 license inactive ·
// 500 unexpected cloud error · 503 instance not paired to Cloud (cloud_required).
export type ImageUploadResult =
| { ok: true; url: string }
| { ok: false; status: 400 | 403 | 413 | 500 | 503; error: string }
// Stream-proxy public images (avatars, org logos) to the workspace's public
// image backend: an R2 binding on CF (zero-auth, zero-egress) or the
// user-configured public S3 storage everywhere else. The platform is passed
// per call because the R2 binding + public-URL env are request-scoped.
// Upload/delete public images (avatars, org logos) via the Cloud avatar service.
// `platform` is passed per call because the active license binding + cloud base
// URL are request-scoped. Validation (mime/size) and Cloud error mapping live in
// the gateway; the unbound instance surfaces as `{ status: 503, 'cloud_required' }`.
export interface ImageUpload {
uploadPublicImage(platform: Platform, prefix: string, id: string, file: File): Promise<ImageUploadResult>
deletePublicImageVariants(platform: Platform, prefix: string, id: string): Promise<void>
+3
View File
@@ -85,6 +85,9 @@ export interface LicensingCloudGateway {
unbindCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void>
confirmCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void>
createBoundCloudClient(baseUrl: string, refreshToken: string): CloudClient
// A bound client for the Cloud avatar service: no forced JSON content-type, and
// an auth header that survives the SDK's per-request Content-Type override.
createAvatarUploadClient(baseUrl: string, refreshToken: string): CloudClient
requestCloudJson<T, U = T>(
response: Promise<{ status: number; ok: boolean; json(): Promise<T>; text(): Promise<string> }>,
responseSchema?: z.ZodType<U>,
+3 -1
View File
@@ -17,5 +17,7 @@ export interface StorageRepo {
count(): Promise<number>
update(id: string, input: UpdateStorageInput): Promise<StorageRecord | null>
delete(id: string): Promise<DeleteStorageResult>
select(mode: 'private' | 'public'): Promise<StorageRecord>
// Picks the oldest active storage with available capacity (uploads land here).
// Throws 'No available storage' when none qualifies.
select(): Promise<StorageRecord>
}
+1 -2
View File
@@ -19,11 +19,10 @@ const BUSINESS: BindingState = { bound: true, active: true, edition: 'business'
const edition = (state: BindingState) => vi.mocked(loadBindingState).mockResolvedValue(state)
const sampleStorage = { id: 'st-1', title: 'My S3', mode: 'private' } as StorageRecord
const sampleStorage = { id: 'st-1', title: 'My S3' } as StorageRecord
const validInput: CreateStorageInput = {
title: 'My S3',
mode: 'private',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
-3
View File
@@ -92,7 +92,6 @@ export async function createStorage(
targetType: 'storage',
targetId: storage.id,
targetName: storage.title,
metadata: { mode: storage.mode },
})
return { ok: true, storage }
}
@@ -118,7 +117,6 @@ export async function updateStorage(
targetType: 'storage',
targetId: storage.id,
targetName: storage.title,
metadata: { mode: storage.mode },
})
return { ok: true, storage }
}
@@ -139,7 +137,6 @@ export async function deleteStorage(
targetType: 'storage',
targetId: id,
targetName: existing?.title ?? id,
metadata: { mode: existing?.mode },
})
return { ok: true }
}
+21 -22
View File
@@ -16,25 +16,24 @@
// failure outward unchanged so the http layer maps {status} directly.
import type { Platform } from '../platform/interface'
import type {
ActivityEventWithUser,
ActivityRepo,
EntitlementResult,
ImageUpload,
ImageUploadResult,
InviteLinkInfo,
OrgRepo,
PendingInvitation,
QuotaEntitlementItem,
TeamInviteRepo,
TeamRepo,
TeamSummary,
UserAdminRepo,
UserOperationFailure,
import {
type ActivityEventWithUser,
type ActivityRepo,
type EntitlementResult,
type ImageUpload,
type ImageUploadResult,
type InviteLinkInfo,
LOGO_PREFIX,
type OrgRepo,
type PendingInvitation,
type QuotaEntitlementItem,
type TeamInviteRepo,
type TeamRepo,
type TeamSummary,
type UserAdminRepo,
type UserOperationFailure,
} from './ports'
const LOGO_PREFIX = '_system/org-logos'
export type TeamDeps = {
teams: TeamRepo
teamInvites: TeamInviteRepo
@@ -137,14 +136,14 @@ export async function listActivity(
// ─── User-facing: org logo ───────────────────────────────────────────────────
// Logo writes require owner or admin. The MIME/size validation (and the
// no-public-storage case) is owned by imageUpload, which returns
// { ok:false, status } for the 400/413/503 outcomes; setTeamLogo threads that
// status outward unchanged. A failed role check is the only 403 it raises itself.
// Logo writes require owner or admin. The MIME/size validation (and the unbound
// instance case) is owned by imageUpload, which returns { ok:false, status } for
// the 400/403/413/500/503 outcomes; setTeamLogo threads that status outward
// unchanged. A failed role check is the only 403 it raises itself.
export type SetTeamLogoOutcome =
| { ok: true; url: string }
| { ok: false; reason: 'forbidden' }
| { ok: false; reason: 'upload_failed'; status: 400 | 413 | 503; error: string }
| { ok: false; reason: 'upload_failed'; status: 400 | 403 | 413 | 500 | 503; error: string }
export async function setTeamLogo(
deps: Pick<TeamDeps, 'org' | 'teams' | 'imageUpload' | 'activity'>,
+20 -17
View File
@@ -14,15 +14,16 @@
// outward unchanged so the http layer maps {status} directly.
import type { Platform } from '../platform/interface'
import type {
ActivityRepo,
EntitlementResult,
ImageUpload,
ProfileRepo,
PublicUser,
QuotaEntitlementItem,
UserAdminRepo,
UserOperationFailure,
import {
type ActivityRepo,
AVATAR_PREFIX,
type EntitlementResult,
type ImageUpload,
type ProfileRepo,
type PublicUser,
type QuotaEntitlementItem,
type UserAdminRepo,
type UserOperationFailure,
} from './ports'
export type UserDeps = {
@@ -155,20 +156,22 @@ export async function revokeUserEntitlement(
// ── self: the authenticated user's own avatar ────────────────────────────────
const AVATAR_PREFIX = '_system/avatars'
export type AvatarDeps = {
imageUpload: ImageUpload
profiles: ProfileRepo
}
// The image gateway can reject with a status (400 bad mime, 413 too large, 503
// no public storage). The http layer turns this into the error body + status;
// the decision of *which* status lives in the gateway, surfaced verbatim here.
export type UpdateAvatarOutcome = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string }
// The image gateway can reject with a status (400 bad mime, 413 too large, 403
// license inactive, 500 cloud error, 503 instance not paired to Cloud). The http
// layer turns this into the error body + status; the decision of *which* status
// lives in the gateway, surfaced verbatim here.
export type UpdateAvatarOutcome =
| { ok: true; url: string }
| { ok: false; status: 400 | 403 | 413 | 500 | 503; error: string }
// `platform` is a request-bound capability (R2 binding + public-URL env are
// request-scoped), so it is a plain function param — not stored on AvatarDeps.
// `platform` is a request-bound capability (the active license binding + cloud
// base URL are request-scoped), so it is a plain function param — not stored on
// AvatarDeps.
export async function updateAvatar(
deps: AvatarDeps,
params: { platform: Platform; userId: string; file: File },
-1
View File
@@ -48,7 +48,6 @@ vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
const storage = {
id: 'st-1',
title: 'S3',
mode: 'private',
egressCreditBillingEnabled: false,
egressCreditUnitBytes: 0,
egressCreditPerUnit: 0,
+3 -5
View File
@@ -250,9 +250,7 @@ export async function putWebDavFile(
},
): Promise<PutWebDavOutcome> {
const { orgId, userId, target, fileName, parent, contentType, contentLength, body } = params
const storage = target.matter
? await deps.storages.get(target.matter.storageId)
: await deps.storages.select('private')
const storage = target.matter ? await deps.storages.get(target.matter.storageId) : await deps.storages.select()
if (!storage) return { ok: false, reason: 'no_storage' }
const objectKey =
@@ -350,7 +348,7 @@ export async function createWebDavCollection(
deps: Pick<Deps, 'matter' | 'storages'>,
params: { orgId: string; userId: string; name: string; parent: string },
): Promise<void> {
const storage = await deps.storages.select('private')
const storage = await deps.storages.select()
await deps.matter.create({
orgId: params.orgId,
userId: params.userId,
@@ -613,7 +611,7 @@ export async function createWebDavLock(
const { orgId, userId, target } = params
const created = !target.matter && Boolean(target.name)
if (created) {
const storage = await deps.storages.select('private')
const storage = await deps.storages.select()
const objectKey = buildObjectKey({ uid: userId, orgId, rawExt: fileExt(target.name) })
await deps.s3.putObject(storage, objectKey, new Uint8Array(), 'application/octet-stream')
await deps.matter.create({