mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
committed by
GitHub
parent
da286e9db3
commit
bba443817a
@@ -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
|
||||
|
||||
@@ -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
@@ -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()}
|
||||
)
|
||||
`)
|
||||
}
|
||||
@@ -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
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [] }]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user