fix: harden legacy downloader bootstrap (#536)

* fix: harden legacy downloader bootstrap

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

* fix: cover downloader bootstrap hardening

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

* fix: document downloader bootstrap auth policy

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

---------

Co-authored-by: Ethan Cole <ethan-cole@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban[bot]
2026-07-29 01:42:47 -04:00
committed by GitHub
parent da286e9db3
commit bba443817a
31 changed files with 6163 additions and 72 deletions
+5 -3
View File
@@ -21,7 +21,8 @@ revoke local tokens without a custom authorization script.
Standard Agent device authorization is deferred to v2.9.x. The existing
`zpan-cli` device flow remains a narrowly scoped compatibility bootstrap for
downloader registration and does not manufacture an Agent API key or a general
OAuth grant.
OAuth grant. Its device-issued bearer is normalized as a single-use downloader
registration credential and is consumed after successful downloader creation.
Anonymous upload and preview-and-claim are explicitly excluded. Every Agent file
operation belongs to an existing user-authorized workspace from the beginning.
@@ -429,8 +430,9 @@ Credentials are never recorded or redisplayed.
- ZPan has bearer sessions and device authorization but is not yet an OAuth
authorization server with Agent resource scopes and workspace grants.
- Device authorization validates only `zpan-cli` and currently yields a
user-oriented bearer token.
- Legacy device authorization validates only `zpan-cli` with
`downloader:register` and yields only a single-use downloader bootstrap
credential.
- `shared/api-key-templates.ts` lacks an Agent template.
- `server/http/objects.ts` rejects ordinary API-key principals.
- authenticated shares, quota, trash, and several task routes require a user
+4 -3
View File
@@ -11,7 +11,8 @@ The authentication decision follows the current FlareAuth + Restish v2 pattern:
Standard Agent device authorization is deferred to v2.9.x. The existing
`zpan-cli` device flow remains only as a compatibility bootstrap for downloader
registration and does not issue a general Agent credential.
registration. Its bearer is a single-use downloader registration credential, not
a browser session or general Agent credential.
OAuth grants and API keys are separate because they represent different actors:
delegated user access versus a service credential. An Agent never receives a
@@ -276,8 +277,8 @@ Destructive and public-sharing scopes remain separately selectable.
- ZPan is not currently an OAuth authorization server for Agent resource
scopes.
- The existing device authorization plugin returns a user-oriented bearer token
and validates only the legacy `zpan-cli` client ID.
- The legacy `zpan-cli` device flow is intentionally limited to the exact
`downloader:register` scope and downloader registration endpoint.
- Object routes currently reject ordinary API-key principals with a blanket
session-only gate.
- Authenticated share and quota routes currently require a user session.
@@ -0,0 +1,17 @@
CREATE TABLE `downloader_bootstrap_credentials` (
`id` text PRIMARY KEY NOT NULL,
`token_hash` text NOT NULL,
`user_id` text NOT NULL,
`device_code` text NOT NULL,
`client_id` text NOT NULL,
`scope` text NOT NULL,
`expires_at` integer NOT NULL,
`consumed_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `downloader_bootstrap_credentials_token_hash_unique` ON `downloader_bootstrap_credentials` (`token_hash`);--> statement-breakpoint
CREATE INDEX `downloader_bootstrap_token_hash_idx` ON `downloader_bootstrap_credentials` (`token_hash`);--> statement-breakpoint
CREATE INDEX `downloader_bootstrap_user_idx` ON `downloader_bootstrap_credentials` (`user_id`);--> statement-breakpoint
CREATE INDEX `downloader_bootstrap_consumed_idx` ON `downloader_bootstrap_credentials` (`consumed_at`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -554,6 +554,13 @@
"when": 1785201664275,
"tag": "0079_image-domain-providers",
"breakpoints": true
},
{
"idx": 80,
"version": "6",
"when": 1785289409896,
"tag": "0080_downloader-bootstrap-credentials",
"breakpoints": true
}
]
}
@@ -0,0 +1,158 @@
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { downloaderBootstrapCredential, session, user } from '../../db/auth-schema'
import { downloaders } from '../../db/schema'
import { createTestApp } from '../../test/setup'
import type { CreateDownloaderRecordInput } from '../../usecases/ports'
import { createDownloaderBootstrapCredentialRepo } from './downloader-bootstrap'
const now = new Date('2026-07-29T00:00:00.000Z')
describe('downloader bootstrap credential repo', () => {
it('resolves active, expired, and consumed bootstrap credentials', async () => {
const { db, platform } = await createTestApp()
await seedUser(db, 'user-1')
const repo = createDownloaderBootstrapCredentialRepo(db, hashOnlyTokens())
await repo.issue({
platform,
token: 'active-token',
userId: 'user-1',
deviceCode: 'device-active',
expiresAt: new Date(now.getTime() + 60_000),
})
await repo.issue({
platform,
token: 'expired-token',
userId: 'user-1',
deviceCode: 'device-expired',
expiresAt: new Date(now.getTime() - 1),
})
await expect(repo.resolve(platform, 'missing-token', now)).resolves.toBeNull()
await expect(repo.resolve(platform, 'active-token', now)).resolves.toEqual({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
active: true,
})
await expect(repo.resolve(platform, 'expired-token', now)).resolves.toEqual({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
active: false,
})
await db
.update(downloaderBootstrapCredential)
.set({ consumedAt: now })
.where(eq(downloaderBootstrapCredential.tokenHash, 'hash:active-token'))
await expect(repo.resolve(platform, 'active-token', now)).resolves.toMatchObject({ active: false })
})
it('consumes a bootstrap credential once', async () => {
const { db, platform } = await createTestApp()
await seedUser(db, 'user-1')
const repo = createDownloaderBootstrapCredentialRepo(db, hashOnlyTokens())
await repo.issue({
platform,
token: 'consume-token',
userId: 'user-1',
deviceCode: 'device-consume',
expiresAt: new Date(now.getTime() + 60_000),
})
await expect(repo.consume(platform, 'consume-token', now)).resolves.toEqual({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
active: false,
})
await expect(repo.consume(platform, 'consume-token', now)).resolves.toBeNull()
await expect(repo.consume(platform, 'missing-token', now)).resolves.toBeNull()
})
it('atomically consumes bootstrap credentials while registering downloaders', async () => {
const { db, platform } = await createTestApp()
await seedUser(db, 'user-1')
const repo = createDownloaderBootstrapCredentialRepo(db, hashOnlyTokens())
await repo.issue({
platform,
token: 'register-token',
userId: 'user-1',
deviceCode: 'device-register',
expiresAt: new Date(now.getTime() + 60_000),
})
await db.insert(session).values({
id: 'session-1',
token: 'register-token',
userId: 'user-1',
expiresAt: new Date(now.getTime() + 60_000),
createdAt: now,
updatedAt: now,
})
await expect(
repo.registerDownloader({
platform,
token: 'register-token',
now,
downloader: downloaderRecord('downloader-1', 'user-1'),
}),
).resolves.toBe(true)
await expect(
repo.registerDownloader({
platform,
token: 'register-token',
now,
downloader: downloaderRecord('downloader-2', 'user-1'),
}),
).resolves.toBe(false)
await expect(db.select().from(downloaders).where(eq(downloaders.id, 'downloader-1'))).resolves.toHaveLength(1)
await expect(db.select().from(downloaders).where(eq(downloaders.id, 'downloader-2'))).resolves.toHaveLength(0)
await expect(db.select().from(session).where(eq(session.token, 'register-token'))).resolves.toHaveLength(0)
})
})
function hashOnlyTokens() {
return {
hashDownloadToken: async (_platform: unknown, token: string) => `hash:${token}`,
}
}
async function seedUser(db: Awaited<ReturnType<typeof createTestApp>>['db'], id: string) {
await db.insert(user).values({
id,
name: 'Bootstrap User',
email: `${id}@example.com`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
}
function downloaderRecord(id: string, createdBy: string): CreateDownloaderRecordInput {
return {
id,
name: 'Edge worker',
tokenHash: `hash:${id}`,
tokenJti: `jti:${id}`,
version: '1.0.0',
hostname: 'edge-1',
platform: 'linux',
arch: 'amd64',
engine: 'aria2',
capabilities: ['http'],
maxConcurrentTasks: 2,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 1024,
remoteDownloadCreditUnitBytes: 100 * 1024 * 1024,
createdBy,
now,
}
}
@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../db/transaction', () => ({
executeRows: vi.fn(),
executeWriteTransactionWithResults: vi.fn(),
}))
import { executeRows, executeWriteTransactionWithResults } from '../../db/transaction'
import type { Platform } from '../../platform/interface'
import { createDownloaderBootstrapCredentialRepo } from './downloader-bootstrap'
const platform: Platform = {
db: {} as never,
getEnv: () => undefined,
getBinding: () => undefined,
}
function createDb(selectRow?: Record<string, unknown> | null, consumeRows: Array<{ userId: string }> = []) {
const selectLimit = vi.fn(async () => (selectRow ? [selectRow] : []))
const selectWhere = vi.fn(() => ({ limit: selectLimit }))
const selectFrom = vi.fn(() => ({ where: selectWhere }))
const select = vi.fn(() => ({ from: selectFrom }))
const updateAll = vi.fn(() => consumeRows)
const updateReturning = vi.fn(() => ({ all: updateAll }))
const updateWhere = vi.fn(() => ({ returning: updateReturning, all: updateAll }))
const updateSet = vi.fn(() => ({ where: updateWhere }))
const update = vi.fn(() => ({ set: updateSet }))
const insertSelect = vi.fn(() => ({ run: vi.fn() }))
const insertValues = vi.fn(() => ({ run: vi.fn() }))
const insert = vi.fn(() => ({ values: insertValues, select: insertSelect }))
const deleteWhere = vi.fn(() => ({ run: vi.fn() }))
const deleteFn = vi.fn(() => ({ where: deleteWhere }))
return {
db: {
select,
update,
insert,
delete: deleteFn,
} as never,
selectLimit,
updateAll,
insertSelect,
insertValues,
deleteWhere,
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(executeRows).mockImplementation(async (query) => ('all' in query ? query.all() : []))
})
describe('createDownloaderBootstrapCredentialRepo', () => {
it('stores a hashed bootstrap credential for later registration', async () => {
const { db, insertValues } = createDb()
const hashDownloadToken = vi.fn(async () => 'hashed-token')
const repo = createDownloaderBootstrapCredentialRepo(db, { hashDownloadToken })
await repo.issue({
platform,
token: 'bootstrap-token',
userId: 'user-1',
deviceCode: 'device-code-1',
expiresAt: new Date('2026-07-29T13:00:00.000Z'),
})
expect(hashDownloadToken).toHaveBeenCalledWith(platform, 'bootstrap-token')
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
tokenHash: 'hashed-token',
userId: 'user-1',
deviceCode: 'device-code-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
expiresAt: new Date('2026-07-29T13:00:00.000Z'),
createdAt: expect.any(Date),
}),
)
})
it('returns null when resolve does not find a matching credential', async () => {
const { db } = createDb(null)
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(repo.resolve(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toBeNull()
})
it('marks consumed credentials as inactive when resolved', async () => {
const { db } = createDb({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
expiresAt: new Date('2026-07-29T13:00:00.000Z'),
consumedAt: new Date('2026-07-29T12:30:00.000Z'),
})
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(repo.resolve(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toEqual({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
active: false,
})
})
it('marks expired credentials as inactive when resolved', async () => {
const { db } = createDb({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
expiresAt: new Date('2026-07-29T11:59:59.000Z'),
consumedAt: null,
})
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(repo.resolve(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toEqual({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
active: false,
})
})
it('returns null when consume cannot update an active credential', async () => {
const { db } = createDb(null, [])
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(repo.consume(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toBeNull()
})
it('returns the consumed credential metadata when consume succeeds', async () => {
const { db } = createDb(null, [{ userId: 'user-1' }])
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(repo.consume(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toEqual({
userId: 'user-1',
clientId: 'zpan-cli',
scope: 'downloader:register',
active: false,
})
})
it('returns true when downloader registration consumes exactly one credential', async () => {
const { db, insertSelect, deleteWhere } = createDb(null, [{ userId: 'user-1' }])
vi.mocked(executeWriteTransactionWithResults).mockImplementation(async (_db, queries) => [
undefined,
queries[1].all?.(),
undefined,
])
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(
repo.registerDownloader({
platform,
token: 'bootstrap-token',
now: new Date('2026-07-29T12:00:00.000Z'),
downloader: {
id: 'downloader-1',
name: 'Bootstrap downloader',
tokenHash: 'downloader-hash',
tokenJti: 'token-jti',
version: '1.2.3',
hostname: 'bootstrap-edge',
platform: 'linux',
arch: 'amd64',
engine: 'aria2',
capabilities: ['http'],
maxConcurrentTasks: 2,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 4096,
remoteDownloadCreditUnitBytes: 104857600,
createdBy: 'user-1',
now: new Date('2026-07-29T12:00:00.000Z'),
},
}),
).resolves.toBe(true)
expect(executeWriteTransactionWithResults).toHaveBeenCalledWith(db, expect.any(Array), [1])
expect(insertSelect).toHaveBeenCalled()
expect(deleteWhere).toHaveBeenCalled()
})
it('returns false when downloader registration does not consume a credential', async () => {
const { db } = createDb()
vi.mocked(executeWriteTransactionWithResults).mockImplementation(async (_db, queries) => {
const consumed = queries[1].all?.()
return [undefined, Array.isArray(consumed) ? [] : consumed, undefined]
})
const repo = createDownloaderBootstrapCredentialRepo(db, {
hashDownloadToken: vi.fn(async () => 'hashed-token'),
})
await expect(
repo.registerDownloader({
platform,
token: 'bootstrap-token',
now: new Date('2026-07-29T12:00:00.000Z'),
downloader: {
id: 'downloader-1',
name: 'Bootstrap downloader',
tokenHash: 'downloader-hash',
tokenJti: 'token-jti',
version: '1.2.3',
hostname: 'bootstrap-edge',
platform: 'linux',
arch: 'amd64',
engine: 'aria2',
capabilities: ['http'],
maxConcurrentTasks: 2,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 4096,
remoteDownloadCreditUnitBytes: 104857600,
createdBy: 'user-1',
now: new Date('2026-07-29T12:00:00.000Z'),
},
}),
).resolves.toBe(false)
})
})
@@ -0,0 +1,155 @@
import { and, eq, gt, isNull, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { downloaderBootstrapCredential, session } from '../../db/auth-schema'
import { downloaders } from '../../db/schema'
import { executeRows, executeWriteTransactionWithResults } from '../../db/transaction'
import { LEGACY_DOWNLOADER_CLIENT_ID, LEGACY_DOWNLOADER_REGISTER_SCOPE } from '../../domain/legacy-downloader-bootstrap'
import type { Database } from '../../platform/interface'
import type { DownloaderBootstrapCredentialRepo, DownloadTokenGateway } from '../../usecases/ports'
import { downloaderInsertValues } from './downloader'
export function createDownloaderBootstrapCredentialRepo(
db: Database,
tokens: Pick<DownloadTokenGateway, 'hashDownloadToken'>,
): DownloaderBootstrapCredentialRepo {
return {
async issue(input) {
await db.insert(downloaderBootstrapCredential).values({
id: nanoid(),
tokenHash: await tokens.hashDownloadToken(input.platform, input.token),
userId: input.userId,
deviceCode: input.deviceCode,
clientId: LEGACY_DOWNLOADER_CLIENT_ID,
scope: LEGACY_DOWNLOADER_REGISTER_SCOPE,
expiresAt: input.expiresAt,
createdAt: new Date(),
})
},
async resolve(platform, token, now) {
const tokenHash = await tokens.hashDownloadToken(platform, token)
const [row] = await db
.select({
userId: downloaderBootstrapCredential.userId,
clientId: downloaderBootstrapCredential.clientId,
scope: downloaderBootstrapCredential.scope,
expiresAt: downloaderBootstrapCredential.expiresAt,
consumedAt: downloaderBootstrapCredential.consumedAt,
})
.from(downloaderBootstrapCredential)
.where(
and(
eq(downloaderBootstrapCredential.tokenHash, tokenHash),
eq(downloaderBootstrapCredential.clientId, LEGACY_DOWNLOADER_CLIENT_ID),
eq(downloaderBootstrapCredential.scope, LEGACY_DOWNLOADER_REGISTER_SCOPE),
),
)
.limit(1)
if (!row) return null
return {
userId: row.userId,
clientId: LEGACY_DOWNLOADER_CLIENT_ID,
scope: LEGACY_DOWNLOADER_REGISTER_SCOPE,
active: row.consumedAt === null && row.expiresAt > now,
}
},
async consume(platform, token, now) {
const tokenHash = await tokens.hashDownloadToken(platform, token)
const [row] = await executeRows<{ userId: string }>({
all: () =>
consumeBootstrapQuery(db, tokenHash, now)
.returning({
userId: downloaderBootstrapCredential.userId,
})
.all(),
})
if (!row) return null
return {
userId: row.userId,
clientId: LEGACY_DOWNLOADER_CLIENT_ID,
scope: LEGACY_DOWNLOADER_REGISTER_SCOPE,
active: false,
}
},
async registerDownloader(input) {
const tokenHash = await tokens.hashDownloadToken(input.platform, input.token)
const consumeBootstrap = {
all: () =>
consumeBootstrapQuery(db, tokenHash, input.now)
.returning({
userId: downloaderBootstrapCredential.userId,
})
.all(),
}
const insertDownloader = conditionalDownloaderInsertQuery(db, input.downloader, tokenHash)
const deleteBootstrapSession = db.delete(session).where(eq(session.token, input.token))
const [, consumeResult] = await executeWriteTransactionWithResults(
db,
[insertDownloader, consumeBootstrap, deleteBootstrapSession],
[1],
)
return Array.isArray(consumeResult) && consumeResult.length === 1
},
}
}
function consumeBootstrapQuery(db: Database, tokenHash: string, now: Date) {
return db
.update(downloaderBootstrapCredential)
.set({ consumedAt: now })
.where(
and(
eq(downloaderBootstrapCredential.tokenHash, tokenHash),
eq(downloaderBootstrapCredential.clientId, LEGACY_DOWNLOADER_CLIENT_ID),
eq(downloaderBootstrapCredential.scope, LEGACY_DOWNLOADER_REGISTER_SCOPE),
isNull(downloaderBootstrapCredential.consumedAt),
gt(downloaderBootstrapCredential.expiresAt, now),
),
)
}
function conditionalDownloaderInsertQuery(
db: Database,
input: Parameters<typeof downloaderInsertValues>[0],
tokenHash: string,
) {
const values = downloaderInsertValues(input)
return db.insert(downloaders).select(sql`
SELECT
${values.id},
${values.name},
${values.tokenHash},
${values.tokenJti},
${values.status},
${values.enabled ? 1 : 0},
${values.version},
${values.hostname},
${values.platform},
${values.arch},
${values.engine},
${values.capabilities},
${values.maxConcurrentTasks},
${values.currentTasks},
${values.downloadBps},
${values.uploadBps},
${values.freeDiskBytes},
${values.remoteDownloadCreditBillingEnabled ? 1 : 0},
${values.remoteDownloadCreditUnitBytes},
${values.remoteDownloadCreditPerUnit},
${values.lastHeartbeatAt},
${values.createdBy},
${values.createdAt.getTime()},
${values.updatedAt.getTime()}
WHERE EXISTS (
SELECT 1
FROM ${downloaderBootstrapCredential}
WHERE ${downloaderBootstrapCredential.tokenHash} = ${tokenHash}
AND ${downloaderBootstrapCredential.clientId} = ${LEGACY_DOWNLOADER_CLIENT_ID}
AND ${downloaderBootstrapCredential.scope} = ${LEGACY_DOWNLOADER_REGISTER_SCOPE}
AND ${downloaderBootstrapCredential.consumedAt} IS NULL
AND ${downloaderBootstrapCredential.expiresAt} > ${input.now.getTime()}
)
`)
}
+30 -26
View File
@@ -80,6 +80,35 @@ function toDownloader(row: DownloaderRow): Downloader {
const DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT = 1
export function downloaderInsertValues(input: CreateDownloaderRecordInput) {
return {
id: input.id,
name: input.name,
tokenHash: input.tokenHash,
tokenJti: input.tokenJti,
status: 'offline',
enabled: true,
version: input.version,
hostname: input.hostname,
platform: input.platform,
arch: input.arch,
engine: input.engine,
capabilities: JSON.stringify(input.capabilities),
maxConcurrentTasks: input.maxConcurrentTasks,
currentTasks: input.currentTasks,
downloadBps: input.downloadBps,
uploadBps: input.uploadBps,
freeDiskBytes: input.freeDiskBytes,
remoteDownloadCreditBillingEnabled: false,
remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes,
remoteDownloadCreditPerUnit: DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT,
lastHeartbeatAt: null,
createdBy: input.createdBy,
createdAt: input.now,
updatedAt: input.now,
}
}
export function createDownloaderRepo(db: Database): DownloaderRepo {
async function findRow(id: string): Promise<DownloaderRow | null> {
const rows = await db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1)
@@ -88,32 +117,7 @@ export function createDownloaderRepo(db: Database): DownloaderRepo {
return {
async insert(input: CreateDownloaderRecordInput) {
await db.insert(downloaders).values({
id: input.id,
name: input.name,
tokenHash: input.tokenHash,
tokenJti: input.tokenJti,
status: 'offline',
enabled: true,
version: input.version,
hostname: input.hostname,
platform: input.platform,
arch: input.arch,
engine: input.engine,
capabilities: JSON.stringify(input.capabilities),
maxConcurrentTasks: input.maxConcurrentTasks,
currentTasks: input.currentTasks,
downloadBps: input.downloadBps,
uploadBps: input.uploadBps,
freeDiskBytes: input.freeDiskBytes,
remoteDownloadCreditBillingEnabled: false,
remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes,
remoteDownloadCreditPerUnit: DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT,
lastHeartbeatAt: null,
createdBy: input.createdBy,
createdAt: input.now,
updatedAt: input.now,
})
await db.insert(downloaders).values(downloaderInsertValues(input))
},
async list() {
+60 -1
View File
@@ -40,6 +40,8 @@ import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs'
import { createEmailGateway } from './adapters/gateways/email'
import { deleteApiKeysScopedToOrganization } from './adapters/repos/api-key-scopes'
import { createAuditRepo } from './adapters/repos/audit'
import { createDownloadTokenGateway } from './adapters/repos/download-tokens'
import { createDownloaderBootstrapCredentialRepo } from './adapters/repos/downloader-bootstrap'
import { createInviteRepo } from './adapters/repos/invite'
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
import { createMemberCountRepo } from './adapters/repos/member-count'
@@ -54,6 +56,11 @@ import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
import { executeWriteTransaction } from './db/transaction'
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha'
import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from './domain/email-verification'
import {
LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG,
LEGACY_DOWNLOADER_CLIENT_ID,
LEGACY_DOWNLOADER_REGISTER_SCOPE,
} from './domain/legacy-downloader-bootstrap'
import { currentTrafficPeriod } from './domain/quota'
import { recordAuditEffect } from './lib/audit'
import { isLocalNetworkOrigin } from './lib/local-origin'
@@ -227,6 +234,12 @@ function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function returnedAccessToken(value: unknown): string | null {
const returned = recordValue(value)
const token = returned?.access_token
return typeof token === 'string' && token.length > 0 ? token : null
}
async function ensureUserRegistrationAudit(db: Database, userId: string, firstAccountId?: string): Promise<void> {
const [firstAccount] = await db
.select({ id: authSchema.account.id, providerId: authSchema.account.providerId })
@@ -321,6 +334,8 @@ export async function createAuth(
const dbProxy = platformProxy ? platformProxy.db : createDbProxy(rawDb)
const db = dbProxy
const downloadTokens = createDownloadTokenGateway()
const downloaderBootstrapCredentials = createDownloaderBootstrapCredentialRepo(db, downloadTokens)
// The email gateway needs a Platform for the Cloudflare EMAIL binding. On the
// bare-Database path (tests, Node fallbacks) there is no platform, so wrap the
// db proxy in a binding-free Platform — matching the previous behaviour where
@@ -395,6 +410,19 @@ export async function createAuth(
if (ctx.path === '/delete-user') {
throw new APIError('FORBIDDEN', { message: 'Self-service account deletion is not available' })
}
if (ctx.path === '/device/code') {
const body = ctx.body as Record<string, unknown> | undefined
if (body?.client_id !== LEGACY_DOWNLOADER_CLIENT_ID) {
throw new APIError('BAD_REQUEST', { error: 'invalid_client', error_description: 'Invalid client ID' })
}
if (body.scope !== LEGACY_DOWNLOADER_REGISTER_SCOPE) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_request',
error_description: 'Invalid downloader registration scope',
})
}
return
}
if (ctx.path !== '/api-key/create') return
const body = ctx.body as Record<string, unknown> | undefined
@@ -431,6 +459,37 @@ export async function createAuth(
// failed, so it skips anything that returned an APIError.
after: createAuthMiddleware(async (ctx) => {
if (ctx.context.returned instanceof APIError) return
if (ctx.path === '/device/token') {
const accessToken = returnedAccessToken(ctx.context.returned)
const returned = recordValue(ctx.context.returned)
const body = ctx.body as Record<string, unknown> | undefined
if (
accessToken &&
returned?.scope === LEGACY_DOWNLOADER_REGISTER_SCOPE &&
typeof body?.device_code === 'string'
) {
const [bootstrapSession] = await db
.select({ userId: authSchema.session.userId, expiresAt: authSchema.session.expiresAt })
.from(authSchema.session)
.where(eq(authSchema.session.token, accessToken))
.limit(1)
if (bootstrapSession) {
await Promise.all([
downloaderBootstrapCredentials.issue({
platform: authPlatform,
token: accessToken,
userId: bootstrapSession.userId,
deviceCode: body.device_code,
expiresAt: bootstrapSession.expiresAt,
}),
db
.update(authSchema.session)
.set({ activeOrganizationId: LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG })
.where(eq(authSchema.session.token, accessToken)),
])
}
}
}
const session = await getSessionFromCtx(ctx)
const actorId = session?.user?.id
if (!actorId) return
@@ -545,7 +604,7 @@ export async function createAuth(
deviceAuthorization({
schema: {},
verificationUri: '/device',
validateClient: async (clientId) => clientId === 'zpan-cli',
validateClient: async (clientId) => clientId === LEGACY_DOWNLOADER_CLIENT_ID,
}),
apiKey([
{
+4 -1
View File
@@ -25,6 +25,7 @@ import { createCloudTrafficReportRepo } from './adapters/repos/cloud-traffic-rep
import { createDownloadTaskRepo } from './adapters/repos/download-task'
import { createDownloadTokenGateway } from './adapters/repos/download-tokens'
import { createDownloaderRepo } from './adapters/repos/downloader'
import { createDownloaderBootstrapCredentialRepo } from './adapters/repos/downloader-bootstrap'
import { createImageHostingRepo } from './adapters/repos/image-hosting'
import { createImageHostingConfigRepo } from './adapters/repos/image-hosting-config'
import { createInstanceRepo } from './adapters/repos/instance'
@@ -75,6 +76,7 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}):
distributed: cacheNamespace ? createCloudflareKvBackend(cacheNamespace) : undefined,
})
const storages = createStorageRepo(db, cache)
const downloadTokens = createDownloadTokenGateway()
return {
audit: createAuditRepo(db),
adminStats: createAdminStatsRepo(db),
@@ -89,8 +91,9 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}):
cloudStore: createCloudStoreRepo(db),
cloudTrafficReports: createCloudTrafficReportRepo(db),
downloaders: createDownloaderRepo(db),
downloaderBootstrapCredentials: createDownloaderBootstrapCredentialRepo(db, downloadTokens),
downloadTasks: createDownloadTaskRepo(db),
downloadTokens: createDownloadTokenGateway(),
downloadTokens,
email: createEmailGateway(systemOptions),
invites: createInviteRepo(db),
imageHostingConfigs: createImageHostingConfigRepo(db),
+45 -1
View File
@@ -1,5 +1,6 @@
import { getTableConfig } from 'drizzle-orm/sqlite-core'
import { describe, expect, it } from 'vitest'
import { user } from './auth-schema.js'
import { downloaderBootstrapCredential, user } from './auth-schema.js'
describe('auth-schema user table', () => {
it('has a username column', () => {
@@ -42,3 +43,46 @@ describe('auth-schema user table', () => {
expect(user.displayUsername.notNull).toBe(false)
})
})
describe('downloaderBootstrapCredential table', () => {
it('stores the bootstrap token hash as a unique text column', () => {
expect(downloaderBootstrapCredential.tokenHash.name).toBe('token_hash')
expect(downloaderBootstrapCredential.tokenHash.columnType).toBe('SQLiteText')
expect(downloaderBootstrapCredential.tokenHash.notNull).toBe(true)
expect(downloaderBootstrapCredential.tokenHash.isUnique).toBe(true)
})
it('stores required downloader bootstrap metadata', () => {
expect(downloaderBootstrapCredential.userId.name).toBe('user_id')
expect(downloaderBootstrapCredential.userId.notNull).toBe(true)
expect(downloaderBootstrapCredential.deviceCode.name).toBe('device_code')
expect(downloaderBootstrapCredential.deviceCode.notNull).toBe(true)
expect(downloaderBootstrapCredential.clientId.name).toBe('client_id')
expect(downloaderBootstrapCredential.clientId.notNull).toBe(true)
expect(downloaderBootstrapCredential.scope.name).toBe('scope')
expect(downloaderBootstrapCredential.scope.notNull).toBe(true)
})
it('tracks expiry, optional consumption, and creation timestamps', () => {
expect(downloaderBootstrapCredential.expiresAt.name).toBe('expires_at')
expect(downloaderBootstrapCredential.expiresAt.columnType).toBe('SQLiteTimestamp')
expect(downloaderBootstrapCredential.expiresAt.notNull).toBe(true)
expect(downloaderBootstrapCredential.consumedAt.name).toBe('consumed_at')
expect(downloaderBootstrapCredential.consumedAt.columnType).toBe('SQLiteTimestamp')
expect(downloaderBootstrapCredential.consumedAt.notNull).toBe(false)
expect(downloaderBootstrapCredential.createdAt.name).toBe('created_at')
expect(downloaderBootstrapCredential.createdAt.notNull).toBe(true)
})
it('declares the bootstrap lookup indexes and user foreign key', () => {
const { foreignKeys, indexes } = getTableConfig(downloaderBootstrapCredential)
expect(indexes.map((index) => index.config.name).sort()).toEqual([
'downloader_bootstrap_consumed_idx',
'downloader_bootstrap_token_hash_idx',
'downloader_bootstrap_user_idx',
])
expect(foreignKeys).toHaveLength(1)
expect(foreignKeys[0].reference().foreignColumns[0].name).toBe('id')
})
})
+24
View File
@@ -216,6 +216,30 @@ export const deviceCode = sqliteTable(
],
)
export const downloaderBootstrapCredential = sqliteTable(
'downloader_bootstrap_credentials',
{
id: text('id').primaryKey(),
tokenHash: text('token_hash').notNull().unique(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
deviceCode: text('device_code').notNull(),
clientId: text('client_id').notNull(),
scope: text('scope').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
consumedAt: integer('consumed_at', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [
index('downloader_bootstrap_token_hash_idx').on(table.tokenHash),
index('downloader_bootstrap_user_idx').on(table.userId),
index('downloader_bootstrap_consumed_idx').on(table.consumedAt),
],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
@@ -0,0 +1,12 @@
export const LEGACY_DOWNLOADER_CLIENT_ID = 'zpan-cli'
export const LEGACY_DOWNLOADER_REGISTER_SCOPE = 'downloader:register'
export const LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG = '__zpan_legacy_downloader_bootstrap__'
export function isLegacyDownloaderBootstrapSession(session: { activeOrganizationId?: string } | undefined): boolean {
return session?.activeOrganizationId === LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG
}
export function isDownloaderBootstrapRegistrationRequest(method: string, path: string): boolean {
const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path
return method.toUpperCase() === 'POST' && normalizedPath === '/api/downloads/downloaders'
}
@@ -92,9 +92,8 @@ async function seedCloudBinding(db: Awaited<ReturnType<typeof createTestApp>>['d
await seedBusinessLicense(db)
}
async function registerDownloaderThroughDeviceLogin(
async function issueDownloaderBootstrapToken(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
name: string,
headers?: { Cookie: string },
) {
const admin = headers ?? (await adminHeaders(app))
@@ -130,11 +129,21 @@ async function registerDownloaderThroughDeviceLogin(
}),
})
expect(tokenRes.status).toBe(200)
const token = (await tokenRes.json()) as { access_token: string }
const token = (await tokenRes.json()) as { access_token: string; scope: string }
expect(token.scope).toBe('downloader:register')
return token.access_token
}
async function registerDownloaderThroughDeviceLogin(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
name: string,
headers?: { Cookie: string },
) {
const token = await issueDownloaderBootstrapToken(app, headers)
const createDownloaderRes = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' },
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, heartbeat }),
})
expect(createDownloaderRes.status).toBe(201)
@@ -174,6 +183,105 @@ describe('Download tasks API integration', () => {
const created = await registerDownloaderThroughDeviceLogin(app, 'device-login-downloader')
expect(created.downloader.name).toBe('device-login-downloader')
expect(created.token).toBeTruthy()
await recordDownloaderHeartbeat(app, created.token)
})
it('requires the exact legacy downloader client and scope [spec: download-tasks/device-bootstrap-scope]', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const wrongScope = await app.request('/api/auth/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-cli', scope: 'objects:read' }),
})
expect(wrongScope.status).toBe(400)
const missingScope = await app.request('/api/auth/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-cli' }),
})
expect(missingScope.status).toBe(400)
const wrongClient = await app.request('/api/auth/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-agent', scope: 'downloader:register' }),
})
expect(wrongClient.status).toBe(400)
})
it('limits downloader bootstrap tokens to one successful registration [spec: download-tasks/device-bootstrap-single-use]', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const token = await issueDownloaderBootstrapToken(app)
const first = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'first-bootstrap-registration', heartbeat }),
})
expect(first.status).toBe(201)
const registered = (await first.json()) as { token: string }
await recordDownloaderHeartbeat(app, registered.token)
const replay = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'replayed-bootstrap-registration', heartbeat }),
})
expect(replay.status).toBe(401)
})
it('keeps bootstrap credentials usable when transactional registration fails [spec: download-tasks/device-bootstrap-rollback]', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const token = await issueDownloaderBootstrapToken(app)
await db.run(sql`
CREATE TRIGGER fail_downloader_insert
BEFORE INSERT ON downloaders
BEGIN
SELECT RAISE(ABORT, 'forced_downloader_insert_failure');
END;
`)
const failed = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'failed-bootstrap-registration', heartbeat }),
})
expect(failed.status).toBe(500)
await db.run(sql`DROP TRIGGER fail_downloader_insert`)
const retry = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'retried-bootstrap-registration', heartbeat }),
})
expect(retry.status).toBe(201)
})
it('rejects downloader bootstrap tokens on non-registration APIs [spec: download-tasks/device-bootstrap-silo]', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const token = await issueDownloaderBootstrapToken(app)
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
const denied = await Promise.all([
app.request('/api/downloads/downloaders', { headers }),
app.request('/api/downloads/downloaders/me/heartbeats', {
method: 'POST',
headers,
body: JSON.stringify(heartbeat),
}),
app.request('/api/downloads/tasks', { headers }),
app.request('/api/objects', { headers }),
app.request('/api/quotas/me', { headers }),
app.request('/api/shares', { headers }),
])
expect(denied.map((res) => res.status)).toEqual([401, 401, 401, 401, 401, 401])
})
it('continues task listings with an opaque page token without duplicates', async () => {
+10 -4
View File
@@ -11,10 +11,11 @@ import {
} from '@shared/schemas'
import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants'
import { hasFeature } from '../../domain/licensing'
import { requireAdmin, requireDownloader } from '../../middleware/auth'
import { requireAdmin, requireDownloader, requireDownloaderRegistration } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import {
createDownloader,
createDownloaderWithBootstrapCredential,
deleteDownloader,
listDownloaders,
recordDownloaderHeartbeat,
@@ -44,14 +45,14 @@ const listRoute = authRoute(
)
const createRouteDoc = authRoute(
{ access: 'admin' },
{ access: 'anyOf', policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }] },
{
operationId: 'createDownloader',
summary: 'Register downloader',
tags: ['Downloaders'],
method: 'post',
path: '/',
middleware: [requireAdmin] as const,
middleware: [requireDownloaderRegistration] as const,
request: jsonBody(createDownloaderSchema),
responses: {
201: jsonContent(createDownloaderResponseSchema, 'Downloader registration'),
@@ -154,7 +155,12 @@ const downloadersRoute = new OpenAPIHono<Env>()
},
})
}
const result = await createDownloader(deps, c.get('platform'), c.req.valid('json'), userId)
const principal = c.get('principal')
const input = c.req.valid('json')
const result =
principal?.kind === 'downloader-bootstrap'
? await createDownloaderWithBootstrapCredential(deps, c.get('platform'), input, userId, principal.sessionToken)
: await createDownloader(deps, c.get('platform'), input, userId)
return c.json(result, 201)
})
.openapi(updateRoute, async (c) => {
+3 -1
View File
@@ -55,7 +55,9 @@ function openApiSecurity(auth: RouteAuthorizationDeclaration): Record<string, st
if (auth.access === 'anyOf') return auth.policies.flatMap(openApiSecurity)
if (auth.access === 'public' || auth.access === 'internal' || auth.access === 'signed-webhook') return []
if (auth.access === 'admin' || auth.access === 'session') return [{ cookieAuth: [] }]
if (auth.access === 'downloader' || auth.access === 'task-upload-token') return [{ bearerAuth: [] }]
if (auth.access === 'downloader' || auth.access === 'downloader-bootstrap' || auth.access === 'task-upload-token') {
return [{ bearerAuth: [] }]
}
return auth.scopes?.length
? [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }]
: [{ bearerAuth: [] }, { cookieAuth: [] }]
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { auditActor } from './audit-actor'
import type { AuthPrincipal } from './platform'
describe('auditActor', () => {
it('records downloader bootstrap principals as user actors', () => {
const principal: AuthPrincipal = {
kind: 'downloader-bootstrap',
userId: 'user-1',
sessionToken: 'bootstrap-token',
scope: 'downloader:register',
authMethod: 'bearer',
}
expect(auditActor(principal)).toEqual({
userId: 'user-1',
actorType: 'user',
actorRef: null,
})
})
})
+3
View File
@@ -12,5 +12,8 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor {
if (principal.kind === 'downloader') {
return { userId: null, actorType: 'downloader', actorRef: principal.downloaderId }
}
if (principal.kind === 'downloader-bootstrap') {
return { userId: principal.userId, actorType: 'user', actorRef: null }
}
return { userId: principal.createdByUserId, actorType: 'task-upload', actorRef: principal.taskId }
}
+52
View File
@@ -1,5 +1,11 @@
import { isAuthorizationScope, permissionScopes } from '@shared/authorization'
import { createMiddleware } from 'hono/factory'
import {
isDownloaderBootstrapRegistrationRequest,
isLegacyDownloaderBootstrapSession,
LEGACY_DOWNLOADER_CLIENT_ID,
LEGACY_DOWNLOADER_REGISTER_SCOPE,
} from '../domain/legacy-downloader-bootstrap'
import { ApiKeyRateLimitError, type CachePolicy, forbidden, rateLimited, unauthorized } from '../usecases/ports'
import { anonymousAuthzContext, type Env } from './platform'
@@ -110,10 +116,40 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
await next()
return
}
const bootstrap = await deps.downloaderBootstrapCredentials.resolve(platform, token, new Date())
if (bootstrap) {
c.set('userId', bootstrap.userId)
c.set('userRole', null)
c.set('orgId', null)
c.set('principal', {
kind: 'downloader-bootstrap',
userId: bootstrap.userId,
sessionToken: token,
scope: LEGACY_DOWNLOADER_REGISTER_SCOPE,
authMethod: 'bearer',
})
c.set('authzContext', {
credential: 'downloader-bootstrap',
userId: bootstrap.userId,
orgId: null,
fixedOrgId: null,
grantedScopes: new Set(),
actor: { type: 'user', ref: bootstrap.userId },
state: { clientId: LEGACY_DOWNLOADER_CLIENT_ID, scope: LEGACY_DOWNLOADER_REGISTER_SCOPE },
})
if (!bootstrap.active || !isDownloaderBootstrapRegistrationRequest(c.req.method, c.req.path)) {
throw unauthorized('Unauthorized')
}
await next()
return
}
await next()
return
}
const auth = c.get('auth')
const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null
if (isLegacyDownloaderBootstrapSession(result?.session)) throw unauthorized('Unauthorized')
c.set('userId', result?.user?.id ?? null)
c.set('userRole', result?.user?.role ?? null)
@@ -180,6 +216,22 @@ export const requireAdmin = createMiddleware<Env>(async (c, next) => {
await next()
})
export const requireDownloaderRegistration = createMiddleware<Env>(async (c, next) => {
const principal = c.get('principal')
if (principal?.kind === 'downloader-bootstrap') {
await next()
return
}
if (principal?.kind !== 'user') throw unauthorized('Unauthorized')
const freshSession = (await c.get('auth').api.getSession({
headers: c.req.raw.headers,
query: { disableCookieCache: true },
})) as SessionWithPlugins | null
if (!freshSession?.user?.id) throw unauthorized('Unauthorized')
if (freshSession.user.role !== 'admin') throw forbidden('Forbidden')
await next()
})
// requireTeamRole enforces a minimum role level for the current org.
// Personal orgs bypass the check — the owner of a personal space has full access.
// Must be used after requireAuth so orgId and userId are guaranteed non-null.
+45 -5
View File
@@ -55,9 +55,7 @@ async function getUserId(db: TestDb, email: string): Promise<string> {
return rows[0].id
}
// Registers a downloader and returns its bearer token. Mirrors the device-login
// flow the CLI uses; needed to mint a `downloader` principal.
async function registerDownloader(app: TestApp, name: string): Promise<string> {
async function issueBootstrapToken(app: TestApp): Promise<string> {
const admin = await adminHeaders(app)
const codeRes = await app.request('/api/auth/device/code', {
method: 'POST',
@@ -65,7 +63,6 @@ async function registerDownloader(app: TestApp, name: string): Promise<string> {
body: JSON.stringify({ client_id: 'zpan-cli', scope: 'downloader:register' }),
})
const code = (await codeRes.json()) as { device_code: string; user_code: string }
// Claim the user code with the admin session before approving (device flow).
await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, { headers: admin })
await app.request('/api/auth/device/approve', {
method: 'POST',
@@ -82,9 +79,16 @@ async function registerDownloader(app: TestApp, name: string): Promise<string> {
}),
})
const token = (await tokenRes.json()) as { access_token: string }
return token.access_token
}
// Registers a downloader and returns its bearer token. Mirrors the device-login
// flow the CLI uses; needed to mint a `downloader` principal.
async function registerDownloader(app: TestApp, name: string): Promise<string> {
const accessToken = await issueBootstrapToken(app)
const createRes = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' },
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
heartbeat: {
@@ -191,6 +195,42 @@ describe('requirePermission middleware', () => {
expect(body.error.status).toBe('UNAUTHENTICATED')
})
it('returns 401 for a bootstrap bearer on routes that would allow a session principal', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
mountProbes(app)
const bootstrapToken = await issueBootstrapToken(app)
const res = await app.request('/api/test-authz/api-perm', {
headers: { Authorization: `Bearer ${bootstrapToken}` },
})
expect(res.status).toBe(401)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Unauthorized')
expect(body.error.status).toBe('UNAUTHENTICATED')
})
it('does not normalize untracked Better Auth bearer sessions as user principals', async () => {
const { app, db } = await createTestApp()
mountProbes(app)
const cookieHeaders = await authedHeaders(app, 'bearer-session@example.com')
const cookieAllowed = await app.request('/api/test-authz/api-perm', { headers: cookieHeaders })
expect(cookieAllowed.status).toBe(200)
const [session] = await db.all<{ token: string }>(sql`
SELECT s.token
FROM session s
INNER JOIN user u ON u.id = s.user_id
WHERE u.email = 'bearer-session@example.com'
ORDER BY s.created_at DESC
LIMIT 1
`)
const bearerDenied = await app.request('/api/test-authz/api-perm', {
headers: { Authorization: `Bearer ${session.token}` },
})
expect(bearerDenied.status).toBe(401)
})
it('returns 403 when a team member role is below the required minTeamRole', async () => {
const { app, db } = await createTestApp()
mountProbes(app)
+2
View File
@@ -20,6 +20,7 @@ export type RouteAuthorizationDeclaration =
| { access: 'admin' }
| { access: 'session'; minTeamRole?: TeamRole }
| { access: 'downloader' }
| { access: 'downloader-bootstrap' }
| { access: 'signed-webhook' }
| { access: 'task-upload-token' }
| { access: 'anyOf'; policies: readonly RouteAuthorizationDeclaration[] }
@@ -63,6 +64,7 @@ export async function evaluateAuthorization(input: {
? { allowed: true, effectiveOrgId: null, reason: 'allowed' }
: deny(context, 401, 'actor_not_allowed', declaration)
}
if (context.credential === 'downloader-bootstrap') return deny(context, 401, 'actor_not_allowed', declaration)
const requiredScopes = declaration.scopes ?? []
if (context.grantedScopes) {
+16
View File
@@ -57,6 +57,13 @@ export type AuthPrincipal =
downloaderId: string
authMethod: 'bearer'
}
| {
kind: 'downloader-bootstrap'
userId: string
sessionToken: string
scope: 'downloader:register'
authMethod: 'bearer'
}
| {
kind: 'download-task-upload'
downloaderId: string
@@ -97,6 +104,15 @@ export type AuthzContext =
actor: { type: 'downloader'; ref: string }
state: Record<string, unknown>
}
| {
credential: 'downloader-bootstrap'
userId: string
orgId: null
fixedOrgId: null
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'user'; ref: string }
state: { clientId: 'zpan-cli'; scope: 'downloader:register' }
}
| {
credential: 'download-task-upload'
userId: string
+15
View File
@@ -148,6 +148,21 @@ describe('global OpenAPI document', () => {
expect(findOperationsMissingAuthContract(handWrittenPaths)).toEqual([])
})
it('documents downloader registration as admin or one-purpose bootstrap bearer auth', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
const doc = (await res.json()) as {
paths: Record<string, Record<string, { security?: unknown; 'x-zpan-auth'?: unknown }>>
}
const operation = doc.paths['/api/downloads/downloaders']?.post
expect(operation?.security).toEqual([{ cookieAuth: [] }, { bearerAuth: [] }])
expect(operation?.['x-zpan-auth']).toEqual({
access: 'anyOf',
policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }],
})
})
it('documents owner role requirements for store operations that enforce owner team role', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
+14
View File
@@ -114,6 +114,20 @@ const AUTH_SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS deviceCode_device_code_idx ON deviceCode(device_code);
CREATE INDEX IF NOT EXISTS deviceCode_user_code_idx ON deviceCode(user_code);
CREATE INDEX IF NOT EXISTS deviceCode_status_idx ON deviceCode(status);
CREATE TABLE IF NOT EXISTS downloader_bootstrap_credentials (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
device_code TEXT NOT NULL,
client_id TEXT NOT NULL,
scope TEXT NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS downloader_bootstrap_token_hash_idx ON downloader_bootstrap_credentials(token_hash);
CREATE INDEX IF NOT EXISTS downloader_bootstrap_user_idx ON downloader_bootstrap_credentials(user_id);
CREATE INDEX IF NOT EXISTS downloader_bootstrap_consumed_idx ON downloader_bootstrap_credentials(consumed_at);
`
const APP_SCHEMA_SQL = `
+2
View File
@@ -14,6 +14,7 @@ import type {
ChangelogProvider,
CloudStoreRepo,
CloudTrafficReportRepo,
DownloaderBootstrapCredentialRepo,
DownloaderRepo,
DownloadTaskRepo,
DownloadTokenGateway,
@@ -66,6 +67,7 @@ export interface Deps {
cloudStore: CloudStoreRepo
cloudTrafficReports: CloudTrafficReportRepo
downloaders: DownloaderRepo
downloaderBootstrapCredentials: DownloaderBootstrapCredentialRepo
downloadTasks: DownloadTaskRepo
downloadTokens: DownloadTokenGateway
email: EmailGateway
+106 -1
View File
@@ -1,9 +1,15 @@
import type { CreateDownloaderInput } from '@shared/schemas'
import type { BindingState, Downloader } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DownloaderRecord, DownloaderRepo } from '../ports'
import { type AppError, DownloadError } from '../ports'
import { loadBindingState } from '../site/licensing'
import { type DownloadsDeps, downloaderHeartbeatPersistence, updateDownloaderCreditBilling } from './downloads'
import {
createDownloaderWithBootstrapCredential,
type DownloadsDeps,
downloaderHeartbeatPersistence,
updateDownloaderCreditBilling,
} from './downloads'
vi.mock('../site/licensing', () => ({ loadBindingState: vi.fn() }))
@@ -64,6 +70,7 @@ function makeDeps(downloaders: Partial<DownloaderRepo> = {}) {
return {
deps: {
downloaders: repo,
downloaderBootstrapCredentials: {},
downloadTasks: {},
downloadTokens: {},
licenseBinding: {},
@@ -197,3 +204,101 @@ describe('updateDownloaderCreditBilling', () => {
expect(update).not.toHaveBeenCalled()
})
})
describe('createDownloaderWithBootstrapCredential', () => {
const input = {
name: 'Bootstrap downloader',
heartbeat: {
version: '1.2.3',
hostname: 'bootstrap-edge',
platform: 'linux',
arch: 'amd64',
engine: 'aria2',
capabilities: ['http'],
maxConcurrentTasks: 2,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 4_096,
},
} satisfies CreateDownloaderInput
it('returns the created downloader and token after bootstrap registration succeeds', async () => {
const platform = {
db: {} as never,
getEnv: () => undefined,
getBinding: () => undefined,
}
const get = vi.fn(async () => downloader)
const registerDownloader = vi.fn(async () => true)
const deps = {
...makeDeps({ get }).deps,
downloaderBootstrapCredentials: {
issue: vi.fn(),
resolve: vi.fn(),
consume: vi.fn(),
registerDownloader,
},
downloadTokens: {
signDownloadToken: vi.fn(async () => 'signed-token'),
hashDownloadToken: vi.fn(async () => 'hashed-token'),
verifyDownloadToken: vi.fn(),
resolveDownloaderToken: vi.fn(),
resolveTaskUploadToken: vi.fn(),
},
} satisfies DownloadsDeps
await expect(
createDownloaderWithBootstrapCredential(deps, platform, input, 'user-1', 'bootstrap-token'),
).resolves.toEqual({
downloader,
token: 'signed-token',
})
expect(get).toHaveBeenCalledWith(expect.any(String))
})
it('rejects when the bootstrap credential cannot be consumed during registration', async () => {
const platform = {
db: {} as never,
getEnv: () => undefined,
getBinding: () => undefined,
}
const get = vi.fn(async () => downloader)
const registerDownloader = vi.fn(async () => false)
const deps = {
...makeDeps({ get }).deps,
downloaderBootstrapCredentials: {
issue: vi.fn(),
resolve: vi.fn(),
consume: vi.fn(),
registerDownloader,
},
downloadTokens: {
signDownloadToken: vi.fn(async () => 'signed-token'),
hashDownloadToken: vi.fn(async () => 'hashed-token'),
verifyDownloadToken: vi.fn(),
resolveDownloaderToken: vi.fn(),
resolveTaskUploadToken: vi.fn(),
},
} satisfies DownloadsDeps
await expect(
createDownloaderWithBootstrapCredential(deps, platform, input, 'user-1', 'bootstrap-token'),
).rejects.toMatchObject({
name: 'AppError',
httpStatus: 401,
} satisfies Partial<AppError>)
expect(registerDownloader).toHaveBeenCalledWith({
platform,
token: 'bootstrap-token',
now: expect.any(Date),
downloader: expect.objectContaining({
name: input.name,
createdBy: 'user-1',
tokenHash: 'hashed-token',
}),
})
expect(get).not.toHaveBeenCalled()
})
})
+56 -22
View File
@@ -25,6 +25,7 @@ import type { Platform } from '../../platform/interface'
import type {
AuditEvent,
AuditRepo,
DownloaderBootstrapCredentialRepo,
DownloaderRecord,
DownloaderRepo,
DownloadTaskRecord,
@@ -38,7 +39,7 @@ import type {
StorageRepo,
UpdateDownloadTaskFields,
} from '../ports'
import { DownloadError, featureBlocked } from '../ports'
import { DownloadError, featureBlocked, unauthorized } from '../ports'
import { loadBindingState } from '../site/licensing'
import { ensureDownloadFolderPath } from './download-folders'
import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage'
@@ -51,6 +52,7 @@ import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './r
export type DownloadsDeps = {
downloaders: DownloaderRepo
downloaderBootstrapCredentials: DownloaderBootstrapCredentialRepo
downloadTasks: DownloadTaskRepo
downloadTokens: DownloadTokenGateway
licenseBinding: LicenseBindingRepo
@@ -125,8 +127,38 @@ export async function createDownloader(
platform: Platform,
input: CreateDownloaderInput,
userId: string,
): Promise<{ downloader: Downloader; token: string }> {
const registration = await prepareDownloaderRegistration(deps, platform, input, userId, new Date())
await deps.downloaders.insert(registration.record)
return { downloader: await deps.downloaders.get(registration.record.id), token: registration.token }
}
export async function createDownloaderWithBootstrapCredential(
deps: DownloadsDeps,
platform: Platform,
input: CreateDownloaderInput,
userId: string,
bootstrapToken: string,
): Promise<{ downloader: Downloader; token: string }> {
const now = new Date()
const registration = await prepareDownloaderRegistration(deps, platform, input, userId, now)
const registered = await deps.downloaderBootstrapCredentials.registerDownloader({
platform,
token: bootstrapToken,
now,
downloader: registration.record,
})
if (!registered) throw unauthorized()
return { downloader: await deps.downloaders.get(registration.record.id), token: registration.token }
}
async function prepareDownloaderRegistration(
deps: DownloadsDeps,
platform: Platform,
input: CreateDownloaderInput,
userId: string,
now: Date,
) {
const id = nanoid()
const jti = nanoid()
const token = await deps.downloadTokens.signDownloadToken(platform, {
@@ -136,27 +168,29 @@ export async function createDownloader(
jti,
iat: Math.floor(now.getTime() / 1000),
})
await deps.downloaders.insert({
id,
name: input.name,
tokenHash: await deps.downloadTokens.hashDownloadToken(platform, token),
tokenJti: jti,
version: input.heartbeat.version,
hostname: input.heartbeat.hostname,
platform: input.heartbeat.platform,
arch: input.heartbeat.arch,
engine: input.heartbeat.engine,
capabilities: input.heartbeat.capabilities,
maxConcurrentTasks: input.heartbeat.maxConcurrentTasks,
currentTasks: input.heartbeat.currentTasks,
downloadBps: input.heartbeat.downloadBps,
uploadBps: input.heartbeat.uploadBps,
freeDiskBytes: input.heartbeat.freeDiskBytes,
remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES,
createdBy: userId,
now,
})
return { downloader: await deps.downloaders.get(id), token }
return {
record: {
id,
name: input.name,
tokenHash: await deps.downloadTokens.hashDownloadToken(platform, token),
tokenJti: jti,
version: input.heartbeat.version,
hostname: input.heartbeat.hostname,
platform: input.heartbeat.platform,
arch: input.heartbeat.arch,
engine: input.heartbeat.engine,
capabilities: input.heartbeat.capabilities,
maxConcurrentTasks: input.heartbeat.maxConcurrentTasks,
currentTasks: input.heartbeat.currentTasks,
downloadBps: input.heartbeat.downloadBps,
uploadBps: input.heartbeat.uploadBps,
freeDiskBytes: input.heartbeat.freeDiskBytes,
remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES,
createdBy: userId,
now,
},
token,
}
}
export async function listDownloaders(deps: DownloadsDeps): Promise<Downloader[]> {
+1
View File
@@ -16,6 +16,7 @@ export * from './ports/changelog'
export * from './ports/cloud-store'
export * from './ports/cloud-traffic-report'
export * from './ports/download-tokens'
export * from './ports/downloader-bootstrap'
export * from './ports/downloads'
export * from './ports/email'
export * from './ports/image-domain-provider'
@@ -0,0 +1,27 @@
import type { Platform } from '../../platform/interface'
import type { CreateDownloaderRecordInput } from './downloads'
export interface DownloaderBootstrapCredential {
userId: string
clientId: 'zpan-cli'
scope: 'downloader:register'
active: boolean
}
export interface DownloaderBootstrapCredentialRepo {
issue(input: {
platform: Platform
token: string
userId: string
deviceCode: string
expiresAt: Date
}): Promise<void>
resolve(platform: Platform, token: string, now: Date): Promise<DownloaderBootstrapCredential | null>
consume(platform: Platform, token: string, now: Date): Promise<DownloaderBootstrapCredential | null>
registerDownloader(input: {
platform: Platform
token: string
now: Date
downloader: CreateDownloaderRecordInput
}): Promise<boolean>
}
+24
View File
@@ -9,6 +9,30 @@ Feature: Remote download tasks
When a downloader registers
Then it is registered through BetterAuth device login
@download-tasks/device-bootstrap-scope @api
Scenario: The legacy downloader device flow requires its exact client and scope
Given a legacy device-code request
When the client id or scope differs from zpan-cli downloader registration
Then the request is rejected
@download-tasks/device-bootstrap-single-use @api
Scenario: A downloader bootstrap token is single-use
Given an approved legacy downloader bootstrap token
When downloader registration succeeds
Then replaying the same bootstrap token is rejected
@download-tasks/device-bootstrap-rollback @api
Scenario: Failed downloader registration does not consume the bootstrap token
Given an approved legacy downloader bootstrap token
When downloader registration fails inside the database transaction
Then the same bootstrap token can be retried successfully
@download-tasks/device-bootstrap-silo @api
Scenario: Downloader bootstrap tokens cannot call non-registration APIs
Given an approved legacy downloader bootstrap token
When it is used on APIs other than downloader registration
Then those APIs reject it
@download-tasks/list-status-multi @api
Scenario: Task listing accepts multiple status values
Given tasks in several statuses