mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +08:00
feat: attribute files and downloads to actors (#559)
* feat: attribute files and downloads to actors * chore: refresh preview after staging migration * fix: use a dash for missing actor identity * fix: complete actor attribution and file creator UI * fix: refine file creator identity UI
This commit is contained in:
@@ -75,6 +75,7 @@ export function createArchiveJobsGateway(platform: Platform): ArchiveJobsGateway
|
||||
await processArchiveJob(deps, {
|
||||
orgId: message.orgId,
|
||||
userId: message.userId,
|
||||
createdBy: message.createdBy,
|
||||
request: message.request,
|
||||
jobId: message.jobId,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apikey, oauthClient } from '../../db/auth-schema'
|
||||
import { apikey, oauthClient, user } from '../../db/auth-schema'
|
||||
import { downloaders } from '../../db/schema'
|
||||
import { createTestApp } from '../../test/setup'
|
||||
import { createAuditActorDirectoryRepo } from './audit-actor-directory'
|
||||
|
||||
describe('audit actor directory repository', () => {
|
||||
it('resolves user display profiles in one local lookup', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await db.insert(user).values({
|
||||
id: 'user-profile-1',
|
||||
name: 'Amber',
|
||||
email: 'amber-profile@example.com',
|
||||
image: 'https://example.com/amber.png',
|
||||
})
|
||||
const directory = createAuditActorDirectoryRepo(db)
|
||||
|
||||
await expect(directory.findUserProfiles(['user-profile-1', 'missing'])).resolves.toEqual(
|
||||
new Map([['user-profile-1', { name: 'Amber', image: 'https://example.com/amber.png', resolved: true }]]),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the username when a user has no profile name', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await db.insert(user).values({
|
||||
id: 'username-only-profile',
|
||||
name: '',
|
||||
username: 'amber',
|
||||
displayUsername: 'Amber',
|
||||
email: 'username-only@example.com',
|
||||
})
|
||||
const directory = createAuditActorDirectoryRepo(db)
|
||||
|
||||
await expect(directory.findUserProfiles(['username-only-profile'])).resolves.toEqual(
|
||||
new Map([['username-only-profile', { name: 'Amber', image: null, resolved: true }]]),
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves API key names in one local lookup', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await db.insert(apikey).values({
|
||||
@@ -89,5 +120,6 @@ describe('audit actor directory repository', () => {
|
||||
|
||||
await expect(directory.findApiKeyNames([])).resolves.toEqual(new Map())
|
||||
await expect(directory.findDeviceNames([])).resolves.toEqual(new Map())
|
||||
await expect(directory.findUserProfiles([])).resolves.toEqual(new Map())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { apikey, oauthClient } from '../../db/auth-schema'
|
||||
import { apikey, oauthClient, user } from '../../db/auth-schema'
|
||||
import { downloaders } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { AuditActorDirectory } from '../../usecases/ports'
|
||||
|
||||
export function createAuditActorDirectoryRepo(db: Database): AuditActorDirectory {
|
||||
return {
|
||||
async findUserProfiles(userIds) {
|
||||
const uniqueIds = [...new Set(userIds)]
|
||||
if (uniqueIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
displayUsername: user.displayUsername,
|
||||
image: user.image,
|
||||
})
|
||||
.from(user)
|
||||
.where(inArray(user.id, uniqueIds))
|
||||
return new Map(
|
||||
rows.map(
|
||||
(row) =>
|
||||
[
|
||||
row.id,
|
||||
{
|
||||
name: row.name || row.displayUsername || row.username || row.id,
|
||||
image: row.image,
|
||||
resolved: true,
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
async findApiKeyNames(keyIds) {
|
||||
const uniqueIds = [...new Set(keyIds)]
|
||||
if (uniqueIds.length === 0) return new Map()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toDownloadTaskListItem } from '@shared/download-task'
|
||||
import { generateId } from '@shared/ids'
|
||||
import { downloadTaskRuntimeSchema } from '@shared/schemas'
|
||||
import { type ActorType, downloadTaskRuntimeSchema } from '@shared/schemas'
|
||||
import type { DownloadTask, DownloadTaskRuntime } from '@shared/types'
|
||||
import {
|
||||
and,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'drizzle-orm'
|
||||
import { downloaders, downloadTasks } from '../../db/schema'
|
||||
import { executeWriteTransaction, executeWriteTransactionWithResults } from '../../db/transaction'
|
||||
import { fallbackActorAttribution } from '../../domain/actor-attribution'
|
||||
import { parseDownloadTaskEvents } from '../../domain/download-task-events'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import {
|
||||
@@ -203,6 +204,9 @@ function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
createdByUserId: row.createdByUserId,
|
||||
requestedByActorType: row.requestedByActorType as DownloadTaskRecord['requestedByActorType'],
|
||||
requestedByActorRef: row.requestedByActorRef,
|
||||
requestedByActorIssuer: row.requestedByActorIssuer,
|
||||
sourceType: row.sourceType,
|
||||
sourceUri: row.sourceUri,
|
||||
displayName: row.displayName,
|
||||
@@ -239,10 +243,19 @@ function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
|
||||
|
||||
function toDownloadTask(row: DownloadTaskRow, control?: { action: 'delete'; requestedAt: string }): DownloadTask {
|
||||
const runtime = parseTaskRuntime(row.runtime)
|
||||
const requestedBy =
|
||||
row.requestedByActorType && row.requestedByActorRef
|
||||
? fallbackActorAttribution({
|
||||
type: row.requestedByActorType as ActorType,
|
||||
ref: row.requestedByActorRef,
|
||||
issuer: row.requestedByActorIssuer,
|
||||
})
|
||||
: null
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
createdBy: row.createdByUserId,
|
||||
requestedBy,
|
||||
spec: {
|
||||
source: {
|
||||
type: row.sourceType as DownloadTask['spec']['source']['type'],
|
||||
@@ -261,7 +274,11 @@ function toDownloadTask(row: DownloadTaskRow, control?: { action: 'delete'; requ
|
||||
state: row.status as DownloadTask['status']['state'],
|
||||
attempt: row.attempt,
|
||||
assignment: row.assignedDownloaderId
|
||||
? { downloaderId: row.assignedDownloaderId, assignedAt: row.assignedAt?.toISOString() ?? null }
|
||||
? {
|
||||
downloaderId: row.assignedDownloaderId,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
executor: fallbackActorAttribution({ type: 'device', ref: row.assignedDownloaderId, issuer: null }),
|
||||
}
|
||||
: null,
|
||||
progress: runtime?.progress ?? emptyTaskProgress(),
|
||||
billing: {
|
||||
@@ -361,6 +378,9 @@ export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
|
||||
id: input.id,
|
||||
orgId: input.orgId,
|
||||
createdByUserId: input.createdByUserId,
|
||||
requestedByActorType: input.requestedByActorType ?? 'user',
|
||||
requestedByActorRef: input.requestedByActorRef ?? input.createdByUserId,
|
||||
requestedByActorIssuer: input.requestedByActorIssuer ?? null,
|
||||
sourceType: input.sourceType,
|
||||
sourceUri: input.sourceUri,
|
||||
displayName: input.displayName,
|
||||
|
||||
@@ -310,6 +310,9 @@ export function createMatterRepo(db: Database): MatterRepo {
|
||||
status: input.status,
|
||||
trashedAt: null,
|
||||
purgedAt: null,
|
||||
createdByActorType: input.createdByActorType ?? null,
|
||||
createdByActorRef: input.createdByActorRef ?? null,
|
||||
createdByActorIssuer: input.createdByActorIssuer ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -521,6 +524,9 @@ export function createMatterRepo(db: Database): MatterRepo {
|
||||
status: 'active',
|
||||
trashedAt: null,
|
||||
purgedAt: null,
|
||||
createdByActorType: opts.createdByActorType ?? null,
|
||||
createdByActorRef: opts.createdByActorRef ?? null,
|
||||
createdByActorIssuer: opts.createdByActorIssuer ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export const matters = sqliteTable(
|
||||
status: text('status').notNull().default('draft'), // draft, active
|
||||
trashedAt: integer('trashed_at'), // null = live, epoch ms = in trash (soft delete)
|
||||
purgedAt: integer('purged_at'), // null = retained/billable, epoch ms = content permanently removed
|
||||
createdByActorType: text('created_by_actor_type'),
|
||||
createdByActorRef: text('created_by_actor_ref'),
|
||||
createdByActorIssuer: text('created_by_actor_issuer'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
},
|
||||
@@ -412,6 +415,9 @@ export const downloadTasks = sqliteTable(
|
||||
id: text('id').primaryKey(),
|
||||
orgId: text('org_id').notNull(),
|
||||
createdByUserId: text('created_by_user_id').notNull(),
|
||||
requestedByActorType: text('requested_by_actor_type'),
|
||||
requestedByActorRef: text('requested_by_actor_ref'),
|
||||
requestedByActorIssuer: text('requested_by_actor_issuer'),
|
||||
sourceType: text('source_type').notNull(),
|
||||
sourceUri: text('source_uri').notNull(),
|
||||
displayName: text('display_name'),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ActorAttribution } from '@shared/schemas'
|
||||
|
||||
type ActorIdentity = Pick<ActorAttribution, 'type' | 'ref' | 'issuer'>
|
||||
type ActorProfile = Pick<ActorAttribution, 'name' | 'image' | 'resolved'>
|
||||
|
||||
export function fallbackActorProfile(identity: ActorIdentity): ActorProfile {
|
||||
const ref = identity.ref ?? 'unknown'
|
||||
const labels: Partial<Record<ActorIdentity['type'], string>> = {
|
||||
user: 'User',
|
||||
api_key: 'API key',
|
||||
oauth: 'Agent',
|
||||
agent: 'Agent',
|
||||
device: 'Device',
|
||||
system: 'System',
|
||||
anonymous: 'Anonymous',
|
||||
'task-upload': 'Task upload',
|
||||
}
|
||||
return { name: `${labels[identity.type] ?? 'Actor'} · ${ref}`, image: null, resolved: false }
|
||||
}
|
||||
|
||||
export function fallbackActorAttribution(identity: ActorIdentity): ActorAttribution {
|
||||
return { ...identity, ...fallbackActorProfile(identity) }
|
||||
}
|
||||
@@ -143,6 +143,7 @@ describe('background jobs API', () => {
|
||||
const created = (await res.json()) as { id: string; status: string }
|
||||
expect(created.status).toBe('queued')
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].createdBy).toMatchObject({ type: 'user', issuer: null })
|
||||
await expect(createBackgroundJobRepo(db).get(orgId, created.id)).resolves.toMatchObject({ status: 'queued' })
|
||||
|
||||
await createArchiveJobsGateway(platform).runMessage(messages[0])
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
listBackgroundJobsQuerySchema,
|
||||
opaqueIdSchema,
|
||||
} from '../../shared/schemas'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { authzActorIdentity, type Env } from '../middleware/platform'
|
||||
import {
|
||||
cancelBackgroundJob,
|
||||
createBackgroundJob,
|
||||
@@ -203,7 +203,12 @@ const backgroundJobs = app
|
||||
const orgId = requireOrg(c)
|
||||
const userId = c.get('userId')
|
||||
if (!userId) throw new BackgroundJobError('not_found')
|
||||
return c.json(await createBackgroundJob(c.get('deps'), { orgId, userId, request: c.req.valid('json') }), 201)
|
||||
const createdBy = authzActorIdentity(c.get('authzContext'))
|
||||
if (!createdBy) throw new Error('authenticated_actor_missing')
|
||||
return c.json(
|
||||
await createBackgroundJob(c.get('deps'), { orgId, userId, createdBy, request: c.req.valid('json') }),
|
||||
201,
|
||||
)
|
||||
})
|
||||
.openapi(getJobRoute, async (c) =>
|
||||
c.json(await getBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 200),
|
||||
@@ -211,8 +216,10 @@ const backgroundJobs = app
|
||||
.openapi(cancelJobRoute, async (c) =>
|
||||
c.json(await cancelBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 200),
|
||||
)
|
||||
.openapi(retryJobRoute, async (c) =>
|
||||
c.json(await retryBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 201),
|
||||
)
|
||||
.openapi(retryJobRoute, async (c) => {
|
||||
const createdBy = authzActorIdentity(c.get('authzContext'))
|
||||
if (!createdBy) throw new Error('authenticated_actor_missing')
|
||||
return c.json(await retryBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id, createdBy), 201)
|
||||
})
|
||||
|
||||
export default backgroundJobs
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(new Set(ids)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses the API key owner UID in the object storage key', async () => {
|
||||
it('uses the API key owner UID in the object storage key [spec: download-tasks/actor-attribution]', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const admin = await adminHeaders(app)
|
||||
@@ -359,9 +359,18 @@ describe('Download tasks API integration', () => {
|
||||
expect(createTaskRes.status).toBe(201)
|
||||
const task = (await createTaskRes.json()) as DownloadTask
|
||||
expect(task.createdBy).toBe(identity.userId)
|
||||
expect(task.requestedBy).toMatchObject({ type: 'api_key', name: 'API key · storage-key-owner', resolved: true })
|
||||
|
||||
const downloader = await registerDownloaderThroughDeviceLogin(app, 'api-key-storage-downloader', admin)
|
||||
const assigned = await claimTaskForDownloader(app, downloader.token, task.id)
|
||||
const detailRes = await app.request(`/api/downloads/tasks/${task.id}`, { headers: userHeaders })
|
||||
expect(detailRes.status).toBe(200)
|
||||
const detail = (await detailRes.json()) as DownloadTask
|
||||
expect(detail.status.assignment?.executor).toMatchObject({
|
||||
type: 'device',
|
||||
name: 'Device · api-key-storage-downloader',
|
||||
resolved: true,
|
||||
})
|
||||
const uploadToken = assigned.status.assignment?.uploadToken
|
||||
expect(uploadToken).toBeTruthy()
|
||||
|
||||
|
||||
@@ -283,7 +283,16 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
if (!orgId) throw unauthorized()
|
||||
// API keys carry their owning user's UID in userId, so downloader uploads
|
||||
// build the same org/uid object path as browser-created tasks.
|
||||
return c.json(await createDownloadTask(c.get('deps'), orgId, c.get('userId') as string, c.req.valid('json')), 201)
|
||||
const actor = c.get('authzContext').actor
|
||||
if (!actor) throw unauthorized()
|
||||
return c.json(
|
||||
await createDownloadTask(c.get('deps'), orgId, c.get('userId') as string, c.req.valid('json'), {
|
||||
type: actor.type,
|
||||
ref: actor.ref,
|
||||
issuer: 'issuer' in actor ? actor.issuer : null,
|
||||
}),
|
||||
201,
|
||||
)
|
||||
})
|
||||
.openapi(getRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
|
||||
@@ -2689,6 +2689,7 @@ async function createUserApiKey(
|
||||
const result = (await (auth.api as any).createApiKey({
|
||||
body: {
|
||||
configId: opts.orgId ? 'ihost' : 'webdav',
|
||||
name: 'test-api-key',
|
||||
userId,
|
||||
...(opts.orgId ? { organizationId: opts.orgId } : {}),
|
||||
permissions: opts.permissions,
|
||||
@@ -2803,10 +2804,11 @@ describe('Objects API — error branches', () => {
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
name: '藏.mp3',
|
||||
parent: targetFolder,
|
||||
createdBy: { type: 'device', resolved: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a folder with a workspace API key that has objects:create', async () => {
|
||||
it('creates a folder with a workspace API key that has objects:create [spec: objects/creator-attribution]', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -2820,7 +2822,24 @@ describe('Objects API — error branches', () => {
|
||||
body: JSON.stringify({ name: 'api-key-folder', type: 'folder', dirtype: 1, parent: '' }),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
await expect(res.json()).resolves.toMatchObject({ name: 'api-key-folder', orgId })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
name: 'api-key-folder',
|
||||
orgId,
|
||||
createdBy: { type: 'api_key', name: 'API key · test-api-key', resolved: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null instead of attributing a legacy object to the workspace owner [spec: objects/legacy-creator]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'legacy-creator-file', name: 'legacy.txt' })
|
||||
|
||||
const res = await app.request('/api/objects/legacy-creator-file', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toMatchObject({ id: 'legacy-creator-file', createdBy: null })
|
||||
})
|
||||
|
||||
it('returns 403 when an object API key is missing the route scope', async () => {
|
||||
|
||||
+34
-14
@@ -1,6 +1,7 @@
|
||||
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import {
|
||||
actorAttributionSchema,
|
||||
completeObjectUploadSchema,
|
||||
copyObjectBodySchema,
|
||||
createMatterSchema,
|
||||
@@ -29,11 +30,12 @@ import {
|
||||
type ObjectActor,
|
||||
ObjectUploadSessionError,
|
||||
presignUploadSessionParts,
|
||||
resolveMatterCreators,
|
||||
transferObject,
|
||||
trashObject,
|
||||
updateObject,
|
||||
} from '../usecases/object'
|
||||
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded } from '../usecases/ports'
|
||||
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded, unauthorized } from '../usecases/ports'
|
||||
import { describeCapacityRequirement } from '../usecases/store/store'
|
||||
import { recordDownloadIssued } from '../usecases/transfer-activity'
|
||||
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
@@ -60,6 +62,7 @@ const matterSchema = z
|
||||
storageId: opaqueIdSchema,
|
||||
status: z.string(),
|
||||
trashedAt: z.number().int().nullable(),
|
||||
createdBy: actorAttributionSchema.nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -71,7 +74,7 @@ type MatterDTO = z.infer<typeof matterSchema>
|
||||
// timestamps to ISO strings, pass everything else through. Its return type is the
|
||||
// schema's inferred type, so a drift between `Matter` and `matterSchema` is a
|
||||
// compile error — not a silent lie in the document.
|
||||
function toMatterDTO(m: Matter): MatterDTO {
|
||||
function toMatterDTO(m: Matter, createdBy: MatterDTO['createdBy']): MatterDTO {
|
||||
return {
|
||||
id: m.id,
|
||||
orgId: m.orgId,
|
||||
@@ -85,6 +88,7 @@ function toMatterDTO(m: Matter): MatterDTO {
|
||||
storageId: m.storageId,
|
||||
status: m.status,
|
||||
trashedAt: m.trashedAt,
|
||||
createdBy,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}
|
||||
@@ -101,8 +105,13 @@ const objectListItemSchema = matterSchema
|
||||
.openapi('ObjectListItem')
|
||||
type ObjectListItemDTO = z.infer<typeof objectListItemSchema>
|
||||
|
||||
function toObjectListItemDTO(item: MatterListItem): ObjectListItemDTO {
|
||||
return { ...toMatterDTO(item), hasChildren: item.hasChildren }
|
||||
function toObjectListItemDTO(item: MatterListItem, createdBy: MatterDTO['createdBy']): ObjectListItemDTO {
|
||||
return { ...toMatterDTO(item, createdBy), hasChildren: item.hasChildren }
|
||||
}
|
||||
|
||||
async function matterDTO(deps: Env['Variables']['deps'], matter: Matter): Promise<MatterDTO> {
|
||||
const creators = await resolveMatterCreators(deps, [matter])
|
||||
return toMatterDTO(matter, creators.get(matter.id) ?? null)
|
||||
}
|
||||
|
||||
const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
|
||||
@@ -177,9 +186,16 @@ function objectActor(c: Context<Env>): ObjectActor {
|
||||
taskId: principal.taskId,
|
||||
targetFolder: principal.targetFolder,
|
||||
createdByUserId: principal.createdByUserId,
|
||||
identity: { type: 'device', ref: principal.downloaderId, issuer: null },
|
||||
}
|
||||
}
|
||||
return { kind: 'user', userId: c.get('userId') as string }
|
||||
const actor = c.get('authzContext').actor
|
||||
if (!actor) throw unauthorized()
|
||||
return {
|
||||
kind: 'user',
|
||||
userId: c.get('userId') as string,
|
||||
identity: { type: actor.type, ref: actor.ref, issuer: 'issuer' in actor ? actor.issuer : null },
|
||||
}
|
||||
}
|
||||
|
||||
// The id recorded in matter/activity logs.
|
||||
@@ -456,9 +472,10 @@ const objects = app
|
||||
},
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
const creators = await resolveMatterCreators(c.get('deps'), result.result.items)
|
||||
return c.json(
|
||||
{
|
||||
items: result.result.items.map(toObjectListItemDTO),
|
||||
items: result.result.items.map((item) => toObjectListItemDTO(item, creators.get(item.id) ?? null)),
|
||||
nextPageToken: await encodeNextPageToken(c.get('platform'), result.result.nextBoundary, {
|
||||
query: fingerprint,
|
||||
codec: directoryCursorCodec,
|
||||
@@ -503,8 +520,9 @@ const objects = app
|
||||
402,
|
||||
)
|
||||
}
|
||||
if ('upload' in result) return c.json({ ...toMatterDTO(result.matter), upload: result.upload }, 201)
|
||||
return c.json(toMatterDTO(result.matter), 201)
|
||||
const matter = await matterDTO(c.get('deps'), result.matter)
|
||||
if ('upload' in result) return c.json({ ...matter, upload: result.upload }, 201)
|
||||
return c.json(matter, 201)
|
||||
})
|
||||
.openapi(presignPartsRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -542,7 +560,7 @@ const objects = app
|
||||
if ('error' in result) throw result.error // quota exceeded
|
||||
throw new ObjectUploadSessionError('not_found') // draft gone
|
||||
}
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
})
|
||||
.openapi(abortUploadRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -585,9 +603,9 @@ const objects = app
|
||||
},
|
||||
result.receipt.trafficEventId,
|
||||
)
|
||||
return c.json({ ...toMatterDTO(result.matter), downloadUrl: result.downloadUrl }, 200)
|
||||
return c.json({ ...(await matterDTO(c.get('deps'), result.matter)), downloadUrl: result.downloadUrl }, 200)
|
||||
}
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
}
|
||||
throw result.error
|
||||
})
|
||||
@@ -600,7 +618,7 @@ const objects = app
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
})
|
||||
// Soft delete: move a live object to trash. Permanent removal is
|
||||
// DELETE /trash/objects/{id}; discarding a draft is DELETE /{id}/uploads/{sid}.
|
||||
@@ -622,10 +640,11 @@ const objects = app
|
||||
const result = await copyObject(c.get('deps'), {
|
||||
orgId,
|
||||
userId: c.get('userId')!,
|
||||
actor: objectActor(c),
|
||||
input: { copyFrom: c.req.valid('param').id, parent: body.parent, onConflict: body.onConflict },
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(toMatterDTO(result.matter), 201)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 201)
|
||||
})
|
||||
.openapi(transferObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -635,13 +654,14 @@ const objects = app
|
||||
const result = await transferObject(c.get('deps'), {
|
||||
orgId,
|
||||
userId: c.get('userId')!,
|
||||
actor: objectActor(c),
|
||||
objectId: c.req.valid('param').id,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(
|
||||
{
|
||||
saved: result.result.saved.map(toMatterDTO),
|
||||
saved: await Promise.all(result.result.saved.map((matter) => matterDTO(c.get('deps'), matter))),
|
||||
skipped: result.result.skipped,
|
||||
sourceDeleted: result.result.sourceDeleted,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
shareRecipientViewSchema,
|
||||
} from '../../shared/schemas/share'
|
||||
import { transferAuditActor } from '../middleware/audit-transfers'
|
||||
import { boundWorkspaceOrgId, type Env } from '../middleware/platform'
|
||||
import { authzActorIdentity, boundWorkspaceOrgId, type Env } from '../middleware/platform'
|
||||
import type { Matter, ShareListItem } from '../usecases/ports'
|
||||
import { notFound } from '../usecases/ports'
|
||||
import {
|
||||
@@ -577,6 +577,8 @@ export const authedShares = authedApp
|
||||
.openapi(saveShareRoute, async (c) => {
|
||||
const token = c.req.valid('param').token
|
||||
const { targetOrgId, targetParent } = c.req.valid('json')
|
||||
const createdBy = authzActorIdentity(c.get('authzContext'))
|
||||
if (!createdBy) throw new Error('authenticated_actor_missing')
|
||||
const out = await saveShare(c.get('deps'), {
|
||||
token,
|
||||
currentUserId: c.get('userId')!,
|
||||
@@ -584,6 +586,7 @@ export const authedShares = authedApp
|
||||
boundTargetOrgId: boundWorkspaceOrgId(c.get('authzContext')),
|
||||
targetParent,
|
||||
accessCookie: getCookie(c, cookieName(token)),
|
||||
createdBy,
|
||||
})
|
||||
if (out.ok) return c.json({ saved: out.result.saved.map(toSavedMatterDTO), skipped: out.result.skipped }, 201)
|
||||
throw out.error
|
||||
|
||||
@@ -99,6 +99,10 @@ type DavAuth = {
|
||||
permissions: Record<string, string[]> | null
|
||||
}
|
||||
|
||||
function webDavActor(auth: DavAuth) {
|
||||
return { type: 'api_key' as const, ref: auth.keyId, issuer: null }
|
||||
}
|
||||
|
||||
interface NativeRateLimiter {
|
||||
limit(options: { key: string }): Promise<{ success: boolean }>
|
||||
}
|
||||
@@ -1278,6 +1282,7 @@ async function putFile(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
contentType,
|
||||
contentLength,
|
||||
body,
|
||||
createdBy: webDavActor(auth),
|
||||
onTiming: (phase, durationMs) => c.get('webDavTrace').push(`${phase}:${Math.round(durationMs)}`),
|
||||
})
|
||||
c.get('webDavTrace').push(`upload:${Math.round(performance.now() - startedAt)}`)
|
||||
@@ -1322,6 +1327,7 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
userId: auth.userId,
|
||||
name: target.name,
|
||||
parent: target.parent,
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
return new Response(null, { status: 201 })
|
||||
} catch (e) {
|
||||
@@ -1441,6 +1447,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
|
||||
targetMatter: target.matter,
|
||||
replacingTarget,
|
||||
depth,
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
if (!result.ok) return c.text('Storage not found', 404)
|
||||
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
|
||||
@@ -1463,6 +1470,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
|
||||
targetResourcePath: resourcePath(target),
|
||||
replacedMatterId: target.matter?.id ?? null,
|
||||
replacingTarget,
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
if (!result.ok) return c.text('Storage not found', 404)
|
||||
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
|
||||
@@ -1520,6 +1528,7 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
owner: lockInfo.owner,
|
||||
depth,
|
||||
timeoutSeconds: parseTimeout(c.req.header('Timeout')),
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
c.get('webDavTrace').push(`lock:${Math.round(performance.now() - startedAt)}`)
|
||||
if (!acquired) return xmlResponse(errorXml('no-conflicting-lock'), 423)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DavLock } from '../domain/webdav'
|
||||
import type { WebDavMountPath } from '../domain/webdav-public-url'
|
||||
import type { Platform } from '../platform/interface'
|
||||
import type { Deps } from '../usecases/deps'
|
||||
import type { WebDavTarget } from '../usecases/ports'
|
||||
import type { ActorIdentity, WebDavTarget } from '../usecases/ports'
|
||||
import type { TransferAuditTarget } from '../usecases/transfer-activity'
|
||||
|
||||
export type Env = {
|
||||
@@ -157,6 +157,15 @@ export function boundWorkspaceOrgId(context: AuthzContext): string | null {
|
||||
return context.workspace.mode === 'bound' ? context.workspace.orgId : null
|
||||
}
|
||||
|
||||
export function authzActorIdentity(context: AuthzContext): ActorIdentity | null {
|
||||
if (!context.actor) return null
|
||||
return {
|
||||
type: context.actor.type,
|
||||
ref: context.actor.ref,
|
||||
issuer: 'issuer' in context.actor ? context.actor.issuer : null,
|
||||
}
|
||||
}
|
||||
|
||||
export const anonymousAuthzContext = (): AuthzContext => ({
|
||||
credential: 'anonymous',
|
||||
userId: null,
|
||||
|
||||
@@ -318,6 +318,9 @@ const APP_SCHEMA_SQL = `
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
trashed_at INTEGER,
|
||||
purged_at INTEGER,
|
||||
created_by_actor_type TEXT,
|
||||
created_by_actor_ref TEXT,
|
||||
created_by_actor_issuer TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -698,6 +701,9 @@ const APP_SCHEMA_SQL = `
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
created_by_user_id TEXT NOT NULL,
|
||||
requested_by_actor_type TEXT,
|
||||
requested_by_actor_ref TEXT,
|
||||
requested_by_actor_issuer TEXT,
|
||||
source_type TEXT NOT NULL,
|
||||
source_uri TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
|
||||
@@ -222,21 +222,51 @@ describe('archive processing', () => {
|
||||
const job = await createArchiveJob(archiveDeps(db), {
|
||||
orgId: ORG_ID,
|
||||
userId: USER_ID,
|
||||
createdBy: { type: 'oauth', ref: 'agent-1', issuer: 'https://realm.example.com' },
|
||||
request: { type: 'archive_extract', matterId: 'zip-matter' },
|
||||
s3: s3 as unknown as S3Gateway,
|
||||
})
|
||||
|
||||
expect(job).toMatchObject({ status: 'completed', type: 'archive_extract' })
|
||||
expect(job.progress).toMatchObject({ outputBytes: 5, fileCount: 1 })
|
||||
const rows = await db.all<{ name: string; parent: string; dirtype: number; size: number; object: string }>(sql`
|
||||
SELECT name, parent, dirtype, size, object FROM matters
|
||||
const rows = await db.all<{
|
||||
name: string
|
||||
parent: string
|
||||
dirtype: number
|
||||
size: number
|
||||
object: string
|
||||
createdByActorType: string | null
|
||||
createdByActorRef: string | null
|
||||
createdByActorIssuer: string | null
|
||||
}>(sql`
|
||||
SELECT name, parent, dirtype, size, object,
|
||||
created_by_actor_type AS createdByActorType,
|
||||
created_by_actor_ref AS createdByActorRef,
|
||||
created_by_actor_issuer AS createdByActorIssuer
|
||||
FROM matters
|
||||
WHERE org_id = ${ORG_ID} AND status = 'active'
|
||||
ORDER BY dirtype DESC, name ASC
|
||||
`)
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'docs', parent: '', dirtype: 1, size: 0 }),
|
||||
expect.objectContaining({ name: 'hello.txt', parent: 'docs', dirtype: 0, size: 5 }),
|
||||
expect.objectContaining({
|
||||
name: 'docs',
|
||||
parent: '',
|
||||
dirtype: 1,
|
||||
size: 0,
|
||||
createdByActorType: 'oauth',
|
||||
createdByActorRef: 'agent-1',
|
||||
createdByActorIssuer: 'https://realm.example.com',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'hello.txt',
|
||||
parent: 'docs',
|
||||
dirtype: 0,
|
||||
size: 5,
|
||||
createdByActorType: 'oauth',
|
||||
createdByActorRef: 'agent-1',
|
||||
createdByActorIssuer: 'https://realm.example.com',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(s3.putKeys).toHaveLength(1)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CreateBackgroundJobRequest } from '@shared/schemas'
|
||||
import type { BackgroundJob } from '@shared/types'
|
||||
import { buildObjectKey } from '../lib/path-template'
|
||||
import type {
|
||||
ActorIdentity,
|
||||
ArchiveTargetFolderRepo,
|
||||
BackgroundJobRepo,
|
||||
MatterRepo,
|
||||
@@ -36,6 +37,7 @@ export type ArchiveProcessingDeps = {
|
||||
export interface CreateArchiveJobInput {
|
||||
orgId: string
|
||||
userId: string
|
||||
createdBy?: ActorIdentity
|
||||
request: CreateBackgroundJobRequest
|
||||
// Overrides deps.s3 for tests; production paths use the wired gateway.
|
||||
s3?: S3Gateway
|
||||
@@ -74,12 +76,13 @@ export async function processArchiveJob(
|
||||
input: CreateArchiveJobInput & { jobId: string },
|
||||
): Promise<BackgroundJob> {
|
||||
const s3 = input.s3 ?? deps.s3
|
||||
const createdBy = input.createdBy ?? { type: 'user', ref: input.userId, issuer: null }
|
||||
try {
|
||||
await deps.backgroundJobs.update(input.orgId, input.jobId, { status: 'running', startedAt: new Date() })
|
||||
const finished =
|
||||
input.request.type === 'archive_compress'
|
||||
? await runCompressionJob(deps, s3, input.jobId, input.orgId, input.userId, input.request)
|
||||
: await runExtractionJob(deps, s3, input.jobId, input.orgId, input.userId, input.request)
|
||||
? await runCompressionJob(deps, s3, input.jobId, input.orgId, input.userId, createdBy, input.request)
|
||||
: await runExtractionJob(deps, s3, input.jobId, input.orgId, input.userId, createdBy, input.request)
|
||||
await notifyArchiveJobFinished(deps, finished)
|
||||
return finished
|
||||
} catch (error) {
|
||||
@@ -100,6 +103,7 @@ async function runCompressionJob(
|
||||
jobId: string,
|
||||
orgId: string,
|
||||
userId: string,
|
||||
createdBy: ActorIdentity,
|
||||
request: Extract<CreateBackgroundJobRequest, { type: 'archive_compress' }>,
|
||||
): Promise<BackgroundJob> {
|
||||
if (request.targetFolder !== undefined)
|
||||
@@ -153,6 +157,9 @@ async function runCompressionJob(
|
||||
storageId: targetStorage.id,
|
||||
status: 'active',
|
||||
onConflict: 'rename',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
})
|
||||
|
||||
return deps.backgroundJobs.update(orgId, jobId, {
|
||||
@@ -184,6 +191,7 @@ async function runExtractionJob(
|
||||
jobId: string,
|
||||
orgId: string,
|
||||
userId: string,
|
||||
createdBy: ActorIdentity,
|
||||
request: Extract<CreateBackgroundJobRequest, { type: 'archive_extract' }>,
|
||||
): Promise<BackgroundJob> {
|
||||
const zipMatter = await deps.matter.get(request.matterId, orgId)
|
||||
@@ -243,6 +251,9 @@ async function runExtractionJob(
|
||||
storageId: targetStorage.id,
|
||||
status: 'active',
|
||||
onConflict: 'rename',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
})
|
||||
createdMatterIds.push(matter.id)
|
||||
})
|
||||
@@ -284,6 +295,9 @@ async function runExtractionJob(
|
||||
storageId: targetStorage.id,
|
||||
status: 'active',
|
||||
onConflict: 'rename',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
})
|
||||
createdMatterIds.push(folder.id)
|
||||
const matterPath = buildMatterPath(folder.parent, folder.name)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveActorAttributions } from './audit-actors'
|
||||
import { type ActorIdentity, actorIdentityKey } from './ports'
|
||||
|
||||
describe('resolveActorAttributions', () => {
|
||||
it('resolves user, API key, device, and agent identities through the shared directory', async () => {
|
||||
const identities: ActorIdentity[] = [
|
||||
{ type: 'user', ref: 'user-1', issuer: null },
|
||||
{ type: 'api_key', ref: 'key-1', issuer: null },
|
||||
{ type: 'device', ref: 'device-1', issuer: null },
|
||||
{ type: 'agent', ref: 'agent-1', issuer: 'https://realm.example.com' },
|
||||
]
|
||||
const resolve = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
actorIdentityKey(identities[3]!),
|
||||
{ name: 'Media Agent', image: 'https://realm.example.com/avatar.png', resolved: true },
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const actors = await resolveActorAttributions(
|
||||
{
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: async () =>
|
||||
new Map([['user-1', { name: 'Amber', image: 'https://example.com/amber.png', resolved: true }]]),
|
||||
findApiKeyNames: async () => new Map([['key-1', 'zme']]),
|
||||
findDeviceNames: async () => new Map([['device-1', 'zpan-downloader']]),
|
||||
listTrustedAgentIssuerOrigins: async () => new Set(['https://realm.example.com']),
|
||||
},
|
||||
agentInfo: { resolve },
|
||||
},
|
||||
identities,
|
||||
)
|
||||
|
||||
expect(actors.get(actorIdentityKey(identities[0]!))).toMatchObject({ name: 'Amber', image: expect.any(String) })
|
||||
expect(actors.get(actorIdentityKey(identities[1]!))).toMatchObject({ name: 'API key · zme', resolved: true })
|
||||
expect(actors.get(actorIdentityKey(identities[2]!))).toMatchObject({
|
||||
name: 'Device · zpan-downloader',
|
||||
resolved: true,
|
||||
})
|
||||
expect(actors.get(actorIdentityKey(identities[3]!))).toMatchObject({ name: 'Media Agent', resolved: true })
|
||||
})
|
||||
|
||||
it('keeps a stable identity fallback when display metadata is unavailable', async () => {
|
||||
const identity: ActorIdentity = { type: 'agent', ref: 'agent-missing', issuer: 'https://realm.example.com' }
|
||||
|
||||
const actors = await resolveActorAttributions(
|
||||
{
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: async () => new Map(),
|
||||
findApiKeyNames: async () => new Map(),
|
||||
findDeviceNames: async () => new Map(),
|
||||
listTrustedAgentIssuerOrigins: async () => new Set(),
|
||||
},
|
||||
agentInfo: { resolve: async () => new Map() },
|
||||
},
|
||||
[identity],
|
||||
)
|
||||
|
||||
expect(actors.get(actorIdentityKey(identity))).toEqual({
|
||||
...identity,
|
||||
name: 'Agent · agent-missing',
|
||||
image: null,
|
||||
resolved: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fallbackActorProfile } from '../domain/actor-attribution'
|
||||
import {
|
||||
type ActorAttribution,
|
||||
type ActorIdentity,
|
||||
type AgentInfoGateway,
|
||||
type AuditActorDirectory,
|
||||
type AuditActorIdentity,
|
||||
@@ -14,7 +17,7 @@ export async function resolveAuditActorProfiles<T extends AuditEventWithUser>(
|
||||
const identities = uniqueResolvableIdentities(events)
|
||||
if (identities.length === 0) return events
|
||||
|
||||
const profiles = await resolveProfiles(deps, identities)
|
||||
const profiles = await resolveActorProfiles(deps, identities)
|
||||
return events.map((event) => {
|
||||
const profile = profiles.get(
|
||||
auditActorIdentityKey({ type: event.actorType, ref: event.actorRef, issuer: event.actorIssuer }),
|
||||
@@ -23,11 +26,21 @@ export async function resolveAuditActorProfiles<T extends AuditEventWithUser>(
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveProfiles(
|
||||
export async function resolveActorProfiles(
|
||||
deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway },
|
||||
identities: readonly AuditActorIdentity[],
|
||||
identities: readonly ActorIdentity[],
|
||||
): Promise<ReadonlyMap<string, AuditActorProfile>> {
|
||||
const profiles = new Map<string, AuditActorProfile>()
|
||||
const userActors = identities.flatMap((identity) =>
|
||||
identity.type === 'user' && identity.ref ? [{ identity, ref: identity.ref }] : [],
|
||||
)
|
||||
if (userActors.length > 0) {
|
||||
const users = await deps.auditActorDirectory.findUserProfiles(userActors.map((actor) => actor.ref))
|
||||
for (const actor of userActors) {
|
||||
const profile = users.get(actor.ref)
|
||||
if (profile) profiles.set(auditActorIdentityKey(actor.identity), profile)
|
||||
}
|
||||
}
|
||||
const apiKeyActors = identities.flatMap((identity) =>
|
||||
identity.type === 'api_key' && identity.ref ? [{ identity, ref: identity.ref }] : [],
|
||||
)
|
||||
@@ -71,6 +84,20 @@ async function resolveProfiles(
|
||||
return profiles
|
||||
}
|
||||
|
||||
export async function resolveActorAttributions(
|
||||
deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway },
|
||||
identities: readonly ActorIdentity[],
|
||||
): Promise<ReadonlyMap<string, ActorAttribution>> {
|
||||
const unique = new Map(identities.map((identity) => [auditActorIdentityKey(identity), identity]))
|
||||
const profiles = await resolveActorProfiles(deps, [...unique.values()])
|
||||
return new Map(
|
||||
[...unique].map(([key, identity]) => {
|
||||
const profile = profiles.get(key) ?? fallbackActorProfile(identity)
|
||||
return [key, { ...identity, ...profile }] as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueResolvableIdentities(events: readonly AuditEventWithUser[]): AuditActorIdentity[] {
|
||||
const identities = new Map<string, AuditActorIdentity>()
|
||||
for (const event of events) {
|
||||
|
||||
@@ -22,6 +22,7 @@ const sampleJob = {
|
||||
} as BackgroundJob
|
||||
|
||||
const extractRequest: CreateBackgroundJobRequest = { type: 'archive_extract', matterId: 'm-1' }
|
||||
const agent = { type: 'oauth' as const, ref: 'agent-1', issuer: 'https://realm.example.com' }
|
||||
|
||||
// Fake ports as plain objects. backgroundJobs.create is what enqueueArchiveJob
|
||||
// (the real composed function) drives on create; everything else is forwarded.
|
||||
@@ -105,7 +106,12 @@ describe('background-job usecase', () => {
|
||||
it('enqueues the request then dispatches the job, and returns it', async () => {
|
||||
const create = vi.fn(async () => sampleJob)
|
||||
const { deps, dispatch } = makeDeps({ backgroundJobs: { create } })
|
||||
const job = await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', request: extractRequest })
|
||||
const job = await createBackgroundJob(deps, {
|
||||
orgId: 'org-1',
|
||||
userId: 'user-1',
|
||||
createdBy: agent,
|
||||
request: extractRequest,
|
||||
})
|
||||
|
||||
expect(job).toBe(sampleJob)
|
||||
// enqueueArchiveJob maps the request onto the create input.
|
||||
@@ -120,6 +126,7 @@ describe('background-job usecase', () => {
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
orgId: 'org-1',
|
||||
userId: 'user-1',
|
||||
createdBy: agent,
|
||||
request: extractRequest,
|
||||
jobId: 'job-1',
|
||||
} satisfies ArchiveJobMessage)
|
||||
@@ -135,7 +142,12 @@ describe('background-job usecase', () => {
|
||||
calls.push('dispatch')
|
||||
})
|
||||
const { deps } = makeDeps({ backgroundJobs: { create }, dispatch })
|
||||
await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', request: extractRequest })
|
||||
await createBackgroundJob(deps, {
|
||||
orgId: 'org-1',
|
||||
userId: 'user-1',
|
||||
createdBy: agent,
|
||||
request: extractRequest,
|
||||
})
|
||||
expect(calls).toEqual(['create', 'dispatch'])
|
||||
})
|
||||
|
||||
@@ -143,7 +155,7 @@ describe('background-job usecase', () => {
|
||||
const create = vi.fn(async () => sampleJob)
|
||||
const { deps } = makeDeps({ backgroundJobs: { create } })
|
||||
const request: CreateBackgroundJobRequest = { type: 'archive_extract', matterId: 'm-1', targetFolder: 'dest' }
|
||||
await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', request })
|
||||
await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', createdBy: agent, request })
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ targetFolder: 'dest', metadata: request }))
|
||||
})
|
||||
})
|
||||
@@ -154,7 +166,7 @@ describe('background-job usecase', () => {
|
||||
const retry = vi.fn(async () => retriedJob)
|
||||
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
|
||||
|
||||
const out = await retryBackgroundJob(deps, 'org-1', 'job-1')
|
||||
const out = await retryBackgroundJob(deps, 'org-1', 'job-1', agent)
|
||||
|
||||
expect(out).toBe(retriedJob)
|
||||
expect(retry).toHaveBeenCalledWith('org-1', 'job-1')
|
||||
@@ -162,6 +174,7 @@ describe('background-job usecase', () => {
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
orgId: 'org-1',
|
||||
userId: 'owner-9',
|
||||
createdBy: agent,
|
||||
request: extractRequest,
|
||||
jobId: 'job-2',
|
||||
} satisfies ArchiveJobMessage)
|
||||
@@ -172,7 +185,7 @@ describe('background-job usecase', () => {
|
||||
const retry = vi.fn(async () => retriedJob)
|
||||
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
|
||||
|
||||
const out = await retryBackgroundJob(deps, 'org-1', 'job-1')
|
||||
const out = await retryBackgroundJob(deps, 'org-1', 'job-1', agent)
|
||||
|
||||
expect(out).toBe(retriedJob)
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
@@ -181,7 +194,7 @@ describe('background-job usecase', () => {
|
||||
it('does not dispatch when the stored metadata is null', async () => {
|
||||
const retry = vi.fn(async () => ({ ...sampleJob, metadata: null }) as BackgroundJob)
|
||||
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
|
||||
await retryBackgroundJob(deps, 'org-1', 'job-1')
|
||||
await retryBackgroundJob(deps, 'org-1', 'job-1', agent)
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -190,7 +203,7 @@ describe('background-job usecase', () => {
|
||||
throw new BackgroundJobError('not_retryable')
|
||||
})
|
||||
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
|
||||
await expect(retryBackgroundJob(deps, 'org-1', 'job-1')).rejects.toMatchObject({ code: 'not_retryable' })
|
||||
await expect(retryBackgroundJob(deps, 'org-1', 'job-1', agent)).rejects.toMatchObject({ code: 'not_retryable' })
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { enqueueArchiveJob } from './archive-processing'
|
||||
// create dispatches on top of enqueueArchiveJob, which reaches across the archive
|
||||
// ports, so it forwards the whole Deps; the reads need only the narrow ports.
|
||||
import type { Deps } from './deps'
|
||||
import type { ListBackgroundJobsOptions } from './ports'
|
||||
import type { ActorIdentity, ListBackgroundJobsOptions } from './ports'
|
||||
|
||||
export function listBackgroundJobs(deps: Pick<Deps, 'backgroundJobs'>, orgId: string, opts: ListBackgroundJobsOptions) {
|
||||
return deps.backgroundJobs.list(orgId, opts)
|
||||
@@ -43,11 +43,11 @@ export function cancelBackgroundJob(
|
||||
|
||||
export async function createBackgroundJob(
|
||||
deps: Deps,
|
||||
params: { orgId: string; userId: string; request: CreateBackgroundJobRequest },
|
||||
params: { orgId: string; userId: string; createdBy: ActorIdentity; request: CreateBackgroundJobRequest },
|
||||
): Promise<BackgroundJob> {
|
||||
const { orgId, userId, request } = params
|
||||
const { orgId, userId, createdBy, request } = params
|
||||
const job = await enqueueArchiveJob(deps, { orgId, userId, request })
|
||||
await deps.archiveJobs.dispatch({ orgId, userId, request, jobId: job.id })
|
||||
await deps.archiveJobs.dispatch({ orgId, userId, createdBy, request, jobId: job.id })
|
||||
return job
|
||||
}
|
||||
|
||||
@@ -55,11 +55,12 @@ export async function retryBackgroundJob(
|
||||
deps: Pick<Deps, 'backgroundJobs' | 'archiveJobs'>,
|
||||
orgId: string,
|
||||
id: string,
|
||||
createdBy: ActorIdentity,
|
||||
): Promise<BackgroundJob> {
|
||||
const job = await deps.backgroundJobs.retry(orgId, id)
|
||||
const request = createBackgroundJobRequestSchema.safeParse(job.metadata)
|
||||
if (request.success) {
|
||||
await deps.archiveJobs.dispatch({ orgId, userId: job.userId, request: request.data, jobId: job.id })
|
||||
await deps.archiveJobs.dispatch({ orgId, userId: job.userId, createdBy, request: request.data, jobId: job.id })
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DirType, ObjectStatus } from '@shared/constants'
|
||||
import { formatError } from '../../lib/errors'
|
||||
import type { Deps } from '../deps'
|
||||
import type { ActorIdentity } from '../ports'
|
||||
import { AppError, type Matter, NameConflictError } from '../ports'
|
||||
|
||||
function joinPath(parent: string, name: string): string {
|
||||
@@ -16,7 +17,7 @@ function targetIsFile(path: string): AppError {
|
||||
|
||||
export async function ensureDownloadFolderPath(
|
||||
deps: Pick<Deps, 'matter' | 'storages'>,
|
||||
params: { orgId: string; folderPath: string },
|
||||
params: { orgId: string; folderPath: string; createdBy?: ActorIdentity },
|
||||
): Promise<string> {
|
||||
const parts = params.folderPath.split('/').filter(Boolean)
|
||||
if (parts.length === 0) return ''
|
||||
@@ -39,6 +40,9 @@ export async function ensureDownloadFolderPath(
|
||||
object: '',
|
||||
storageId,
|
||||
status: ObjectStatus.ACTIVE,
|
||||
createdByActorType: params.createdBy?.type,
|
||||
createdByActorRef: params.createdBy?.ref,
|
||||
createdByActorIssuer: params.createdBy?.issuer,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof NameConflictError) && !formatError(error).includes('UNIQUE constraint failed'))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CreateDownloaderInput } from '@shared/schemas'
|
||||
import type { BindingState, Downloader } from '@shared/types'
|
||||
import type { BindingState, Downloader, DownloadTask } from '@shared/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type { DownloaderRecord, DownloaderRepo } from '../ports'
|
||||
import { type AppError, DownloadError } from '../ports'
|
||||
import { loadBindingState } from '../site/licensing'
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
createDownloaderWithBootstrapCredential,
|
||||
type DownloadsDeps,
|
||||
downloaderHeartbeatPersistence,
|
||||
listDownloadTasks,
|
||||
updateDownloaderCreditBilling,
|
||||
} from './downloads'
|
||||
|
||||
@@ -205,6 +207,80 @@ describe('updateDownloaderCreditBilling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDownloadTasks', () => {
|
||||
it('resolves every requester and executor in one batch for the page', async () => {
|
||||
const requestedBy = (ref: string) => ({
|
||||
type: 'api_key' as const,
|
||||
ref,
|
||||
issuer: null,
|
||||
name: `API key · ${ref}`,
|
||||
image: null,
|
||||
resolved: false,
|
||||
})
|
||||
const tasks = ['key-1', 'key-2'].map(
|
||||
(ref, index) =>
|
||||
({
|
||||
id: `task-${index + 1}`,
|
||||
requestedBy: requestedBy(ref),
|
||||
status: {
|
||||
assignment: {
|
||||
downloaderId: `device-${index + 1}`,
|
||||
assignedAt: null,
|
||||
executor: {
|
||||
type: 'device',
|
||||
ref: `device-${index + 1}`,
|
||||
issuer: null,
|
||||
name: `Device · device-${index + 1}`,
|
||||
image: null,
|
||||
resolved: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as DownloadTask,
|
||||
)
|
||||
const findApiKeyNames = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
['key-1', 'zme'],
|
||||
['key-2', 'automation'],
|
||||
]),
|
||||
)
|
||||
const findDeviceNames = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
['device-1', 'Living room'],
|
||||
['device-2', 'Office'],
|
||||
]),
|
||||
)
|
||||
const listTrustedAgentIssuerOrigins = vi.fn(async () => new Set<string>())
|
||||
const agentResolve = vi.fn(async () => new Map())
|
||||
const deps = {
|
||||
downloadTasks: { list: vi.fn(async () => ({ items: tasks, rows: [], nextBoundary: null })) },
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: vi.fn(async () => new Map()),
|
||||
findApiKeyNames,
|
||||
findDeviceNames,
|
||||
listTrustedAgentIssuerOrigins,
|
||||
},
|
||||
agentInfo: { resolve: agentResolve },
|
||||
} as unknown as DownloadsDeps
|
||||
|
||||
const result = await listDownloadTasks(deps, {} as Platform, { pageSize: 20 })
|
||||
|
||||
expect(findApiKeyNames).toHaveBeenCalledTimes(1)
|
||||
expect(findApiKeyNames).toHaveBeenCalledWith(['key-1', 'key-2'])
|
||||
expect(findDeviceNames).toHaveBeenCalledTimes(1)
|
||||
expect(findDeviceNames).toHaveBeenCalledWith(['device-1', 'device-2'])
|
||||
expect(listTrustedAgentIssuerOrigins).not.toHaveBeenCalled()
|
||||
expect(agentResolve).not.toHaveBeenCalled()
|
||||
expect(result.items.map((task) => task.requestedBy?.name)).toEqual(['API key · zme', 'API key · automation'])
|
||||
expect(result.items.map((task) => task.status.assignment?.executor?.name)).toEqual([
|
||||
'Device · Living room',
|
||||
'Device · Office',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDownloaderWithBootstrapCredential', () => {
|
||||
const input = {
|
||||
name: 'Bootstrap downloader',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ActorAttribution,
|
||||
CreateDownloaderInput,
|
||||
CreateDownloadTaskInput,
|
||||
DownloaderHeartbeatInput,
|
||||
@@ -19,10 +20,15 @@ import type {
|
||||
} from '@shared/types'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
|
||||
import { generateId } from '../../../shared/ids'
|
||||
import { fallbackActorAttribution } from '../../domain/actor-attribution'
|
||||
import { parseDownloadTaskEvents } from '../../domain/download-task-events'
|
||||
import { hasFeature } from '../../domain/licensing'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import { resolveActorAttributions } from '../audit-actors'
|
||||
import type {
|
||||
ActorIdentity,
|
||||
AgentInfoGateway,
|
||||
AuditActorDirectory,
|
||||
AuditEvent,
|
||||
AuditRepo,
|
||||
DownloaderBootstrapCredentialRepo,
|
||||
@@ -39,7 +45,7 @@ import type {
|
||||
StorageRepo,
|
||||
UpdateDownloadTaskFields,
|
||||
} from '../ports'
|
||||
import { DownloadError, featureBlocked, unauthorized } from '../ports'
|
||||
import { actorIdentityKey, DownloadError, featureBlocked, unauthorized } from '../ports'
|
||||
import { loadBindingState } from '../site/licensing'
|
||||
import { ensureDownloadFolderPath } from './download-folders'
|
||||
import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage'
|
||||
@@ -51,6 +57,8 @@ import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './r
|
||||
// is platform-per-call, mirroring auth.ts).
|
||||
|
||||
export type DownloadsDeps = {
|
||||
auditActorDirectory: AuditActorDirectory
|
||||
agentInfo: AgentInfoGateway
|
||||
downloaders: DownloaderRepo
|
||||
downloaderBootstrapCredentials: DownloaderBootstrapCredentialRepo
|
||||
downloadTasks: DownloadTaskRepo
|
||||
@@ -311,10 +319,12 @@ export async function createDownloadTask(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
input: CreateDownloadTaskInput,
|
||||
requestedBy: ActorIdentity = { type: 'user', ref: userId, issuer: null },
|
||||
): Promise<DownloadTask> {
|
||||
const targetFolder = await ensureDownloadFolderPath(deps, {
|
||||
orgId,
|
||||
folderPath: input.targetFolder,
|
||||
createdBy: requestedBy,
|
||||
})
|
||||
const now = new Date()
|
||||
const id = generateId()
|
||||
@@ -322,6 +332,9 @@ export async function createDownloadTask(
|
||||
id,
|
||||
orgId,
|
||||
createdByUserId: userId,
|
||||
requestedByActorType: requestedBy.type,
|
||||
requestedByActorRef: requestedBy.ref ?? userId,
|
||||
requestedByActorIssuer: requestedBy.issuer,
|
||||
sourceType: input.source.type,
|
||||
sourceUri: input.source.uri,
|
||||
displayName: input.name ?? null,
|
||||
@@ -333,7 +346,7 @@ export async function createDownloadTask(
|
||||
assignedAt: null,
|
||||
now,
|
||||
})
|
||||
return deps.downloadTasks.get(orgId, id)
|
||||
return decorateDownloadTaskActors(deps, await deps.downloadTasks.get(orgId, id))
|
||||
}
|
||||
|
||||
export async function listDownloadTasks(
|
||||
@@ -345,10 +358,13 @@ export async function listDownloadTasks(
|
||||
nextBoundary: { createdAt: Date; id: string } | null
|
||||
}> {
|
||||
const { items, rows, nextBoundary } = await deps.downloadTasks.list(opts)
|
||||
if (!opts.includeUploadToken) return { items, nextBoundary }
|
||||
const decorated = await Promise.all(
|
||||
if (!opts.includeUploadToken) {
|
||||
return { items: await decorateDownloadTasksActors(deps, items), nextBoundary }
|
||||
}
|
||||
const tasksWithUploadTokens = await Promise.all(
|
||||
items.map((task, index) => decorateWithUploadToken(deps, platform, task, rows[index])),
|
||||
)
|
||||
const decorated = await decorateDownloadTasksActors(deps, tasksWithUploadTokens)
|
||||
return { items: decorated, nextBoundary }
|
||||
}
|
||||
|
||||
@@ -362,8 +378,39 @@ export async function listDownloadTaskItems(
|
||||
return deps.downloadTasks.listItems(opts)
|
||||
}
|
||||
|
||||
export function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string): Promise<DownloadTask> {
|
||||
return deps.downloadTasks.get(orgId, id)
|
||||
export async function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string): Promise<DownloadTask> {
|
||||
return decorateDownloadTaskActors(deps, await deps.downloadTasks.get(orgId, id))
|
||||
}
|
||||
|
||||
async function decorateDownloadTaskActors(deps: DownloadsDeps, task: DownloadTask): Promise<DownloadTask> {
|
||||
return (await decorateDownloadTasksActors(deps, [task]))[0]
|
||||
}
|
||||
|
||||
async function decorateDownloadTasksActors(
|
||||
deps: DownloadsDeps,
|
||||
tasks: readonly DownloadTask[],
|
||||
): Promise<DownloadTask[]> {
|
||||
const identities = tasks
|
||||
.flatMap((task) => [
|
||||
task.requestedBy && { type: task.requestedBy.type, ref: task.requestedBy.ref, issuer: task.requestedBy.issuer },
|
||||
task.status.assignment && { type: 'device' as const, ref: task.status.assignment.downloaderId, issuer: null },
|
||||
])
|
||||
.filter((identity): identity is ActorIdentity => identity !== null)
|
||||
const actors = await resolveActorAttributions(deps, identities)
|
||||
return tasks.map((task) => decorateDownloadTaskActor(task, actors))
|
||||
}
|
||||
|
||||
function decorateDownloadTaskActor(task: DownloadTask, actors: ReadonlyMap<string, ActorAttribution>): DownloadTask {
|
||||
const requestedBy = task.requestedBy ? (actors.get(actorIdentityKey(task.requestedBy)) ?? task.requestedBy) : null
|
||||
const assignment = task.status.assignment
|
||||
? {
|
||||
...task.status.assignment,
|
||||
executor:
|
||||
actors.get(actorIdentityKey({ type: 'device', ref: task.status.assignment.downloaderId, issuer: null })) ??
|
||||
task.status.assignment.executor,
|
||||
}
|
||||
: null
|
||||
return { ...task, requestedBy, status: { ...task.status, assignment } }
|
||||
}
|
||||
|
||||
export async function getDownloadTaskTimeline(
|
||||
@@ -1038,10 +1085,19 @@ function actionSeverity(action: string): DownloadTaskTimelineItem['severity'] {
|
||||
|
||||
function downloadTaskFromRecord(row: DownloadTaskRecord): DownloadTask {
|
||||
const runtime = parseTaskRuntime(row.runtime)
|
||||
const requestedBy =
|
||||
row.requestedByActorType && row.requestedByActorRef
|
||||
? fallbackActorAttribution({
|
||||
type: row.requestedByActorType,
|
||||
ref: row.requestedByActorRef,
|
||||
issuer: row.requestedByActorIssuer ?? null,
|
||||
})
|
||||
: null
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
createdBy: row.createdByUserId,
|
||||
requestedBy,
|
||||
spec: {
|
||||
source: {
|
||||
type: row.sourceType as DownloadTask['spec']['source']['type'],
|
||||
@@ -1060,7 +1116,11 @@ function downloadTaskFromRecord(row: DownloadTaskRecord): DownloadTask {
|
||||
state: row.status as DownloadTask['status']['state'],
|
||||
attempt: row.attempt,
|
||||
assignment: row.assignedDownloaderId
|
||||
? { downloaderId: row.assignedDownloaderId, assignedAt: row.assignedAt?.toISOString() ?? null }
|
||||
? {
|
||||
downloaderId: row.assignedDownloaderId,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
executor: fallbackActorAttribution({ type: 'device', ref: row.assignedDownloaderId, issuer: null }),
|
||||
}
|
||||
: null,
|
||||
progress: runtime?.progress ?? {
|
||||
download: { bytes: 0, totalBytes: null, bytesPerSecond: 0 },
|
||||
|
||||
@@ -397,6 +397,29 @@ describe('object usecase', () => {
|
||||
expect(presignUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the stable OAuth agent identity on a created object', async () => {
|
||||
const create = vi.fn(async () => folder('agent-folder', { name: 'Agent Folder' }))
|
||||
const { deps } = makeDeps({ matter: { create } })
|
||||
|
||||
await createObject(deps, {
|
||||
orgId: 'o1',
|
||||
actor: {
|
||||
kind: 'user',
|
||||
userId: 'owner-1',
|
||||
identity: { type: 'oauth', ref: 'agent-1', issuer: 'https://realm.example.com' },
|
||||
},
|
||||
input: { name: 'Agent Folder', type: 'folder', dirtype: DirType.USER_FOLDER, parent: '' },
|
||||
})
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdByActorType: 'oauth',
|
||||
createdByActorRef: 'agent-1',
|
||||
createdByActorIssuer: 'https://realm.example.com',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a small file draft and returns single-PUT upload instructions', async () => {
|
||||
const create = vi.fn(async (input: Parameters<MatterRepo['create']>[0]) => file('d1', input as Partial<Matter>))
|
||||
const presignUpload = vi.fn(async () => 'https://up')
|
||||
@@ -1664,7 +1687,12 @@ describe('object usecase', () => {
|
||||
expect(source.object).toBe('legacy_/source-file-.txt')
|
||||
expect(destinationKey).toMatch(/^o1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/)
|
||||
expect(copyObjectS3).toHaveBeenCalledWith(storage, source.object, storage, destinationKey)
|
||||
expect(copy).toHaveBeenCalledWith(source, 'Dest', destinationKey, { onConflict: undefined })
|
||||
expect(copy).toHaveBeenCalledWith(source, 'Dest', destinationKey, {
|
||||
onConflict: undefined,
|
||||
createdByActorType: 'user',
|
||||
createdByActorRef: 'u1',
|
||||
createdByActorIssuer: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns not_found for a missing source', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { DirType } from '@shared/constants'
|
||||
import type {
|
||||
ActorAttribution,
|
||||
CompleteObjectUploadInput,
|
||||
ConflictStrategy,
|
||||
CreateMatterInput,
|
||||
@@ -21,11 +22,14 @@ import type {
|
||||
} from '@shared/schemas'
|
||||
import type { ObjectUploadInstructions } from '@shared/types'
|
||||
import { buildObjectKey, fileExt } from '../lib/path-template'
|
||||
import { resolveActorAttributions } from './audit-actors'
|
||||
import type { Deps } from './deps'
|
||||
import { assertFolderNotUsedByDownload, ensureDownloadFolderPath } from './downloads/download-folders'
|
||||
import { assertTaskUploadAllowed } from './downloads/downloads'
|
||||
import {
|
||||
type ActorIdentity,
|
||||
type AppError,
|
||||
actorIdentityKey,
|
||||
badRequest,
|
||||
forbidden,
|
||||
insufficientCredits,
|
||||
@@ -61,13 +65,14 @@ export interface CopyObjectInput {
|
||||
// upload token acts on behalf of the task creator but is logged as the
|
||||
// downloader and is constrained to its authorized target folder.
|
||||
export type ObjectActor =
|
||||
| { kind: 'user'; userId: string }
|
||||
| { kind: 'user'; userId: string; identity?: ActorIdentity }
|
||||
| {
|
||||
kind: 'download-task-upload'
|
||||
downloaderId: string
|
||||
taskId: string
|
||||
targetFolder: string
|
||||
createdByUserId: string
|
||||
identity?: ActorIdentity
|
||||
}
|
||||
|
||||
// The user id used to build object keys (whose owner is the task creator for
|
||||
@@ -81,6 +86,13 @@ function actorLogId(actor: ObjectActor): string {
|
||||
return actor.kind === 'download-task-upload' ? `downloader:${actor.downloaderId}` : actor.userId
|
||||
}
|
||||
|
||||
function creatorIdentity(actor: ObjectActor): ActorIdentity {
|
||||
if (actor.identity) return actor.identity
|
||||
return actor.kind === 'download-task-upload'
|
||||
? { type: 'device', ref: actor.downloaderId, issuer: null }
|
||||
: { type: 'user', ref: actor.userId, issuer: null }
|
||||
}
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
|
||||
|
||||
// Whether the user may write (editor+) in the org. Personal orgs grant full
|
||||
@@ -164,6 +176,7 @@ export async function createObject(
|
||||
params: { orgId: string; actor: ObjectActor; input: CreateMatterInput },
|
||||
): Promise<CreateObjectOutcome> {
|
||||
const { orgId, actor, input } = params
|
||||
const createdBy = creatorIdentity(actor)
|
||||
const { name, type, dirtype, onConflict } = input
|
||||
let { parent } = input
|
||||
const isFolder = dirtype !== DirType.FILE
|
||||
@@ -180,6 +193,7 @@ export async function createObject(
|
||||
parent = await ensureDownloadFolderPath(deps, {
|
||||
orgId,
|
||||
folderPath: parent,
|
||||
createdBy,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -224,6 +238,9 @@ export async function createObject(
|
||||
object: objectKey,
|
||||
storageId: storage.id,
|
||||
status: isFolder ? 'active' : 'draft',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
onConflict,
|
||||
})
|
||||
|
||||
@@ -242,6 +259,36 @@ export async function createObject(
|
||||
return { ok: true, matter, upload }
|
||||
}
|
||||
|
||||
export async function resolveMatterCreators(
|
||||
deps: Pick<Deps, 'auditActorDirectory' | 'agentInfo'>,
|
||||
matters: readonly Matter[],
|
||||
): Promise<ReadonlyMap<string, ActorAttribution>> {
|
||||
const identities = matters.flatMap((matter) =>
|
||||
matter.createdByActorType && matter.createdByActorRef
|
||||
? [
|
||||
{
|
||||
type: matter.createdByActorType as ActorIdentity['type'],
|
||||
ref: matter.createdByActorRef,
|
||||
issuer: matter.createdByActorIssuer ?? null,
|
||||
} satisfies ActorIdentity,
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const actors = await resolveActorAttributions(deps, identities)
|
||||
return new Map(
|
||||
matters.flatMap((matter) => {
|
||||
if (!matter.createdByActorType || !matter.createdByActorRef) return []
|
||||
const identity = {
|
||||
type: matter.createdByActorType as ActorIdentity['type'],
|
||||
ref: matter.createdByActorRef,
|
||||
issuer: matter.createdByActorIssuer ?? null,
|
||||
}
|
||||
const actor = actors.get(actorIdentityKey(identity))
|
||||
return actor ? [[matter.id, actor] as const] : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Decides the S3 mechanism, presigns every URL up front, and records the upload
|
||||
// session. The chosen onConflict is stored on the session so completion can apply
|
||||
// a deferred 'replace' (createMatter keeps the incumbent until bytes land).
|
||||
@@ -860,9 +907,10 @@ export type CopyObjectOutcome = { ok: true; matter: Matter } | { ok: false; erro
|
||||
|
||||
export async function copyObject(
|
||||
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'quota' | 'storageUsage'>,
|
||||
params: { orgId: string; userId: string; input: CopyObjectInput },
|
||||
params: { orgId: string; userId: string; actor?: ObjectActor; input: CopyObjectInput },
|
||||
): Promise<CopyObjectOutcome> {
|
||||
const { orgId, userId, input } = params
|
||||
const identity = params.actor ? creatorIdentity(params.actor) : { type: 'user' as const, ref: userId, issuer: null }
|
||||
const { copyFrom, parent, onConflict } = input
|
||||
const source = await deps.matter.get(copyFrom, orgId)
|
||||
if (!source || source.trashedAt != null) return { ok: false, error: notFound() }
|
||||
@@ -884,7 +932,12 @@ export async function copyObject(
|
||||
await deps.s3.copyObject(objectStorage, source.object, objectStorage, newObject)
|
||||
ctx.onRollback(() => deps.s3.deleteObject(objectStorage, newObject))
|
||||
}
|
||||
return deps.matter.copy(source, parent, newObject, { onConflict })
|
||||
return deps.matter.copy(source, parent, newObject, {
|
||||
onConflict,
|
||||
createdByActorType: identity.type,
|
||||
createdByActorRef: identity.ref,
|
||||
createdByActorIssuer: identity.issuer,
|
||||
})
|
||||
},
|
||||
)
|
||||
return { ok: true, matter: copy }
|
||||
@@ -898,7 +951,7 @@ export type TransferObjectOutcome = { ok: true; result: TransferObjectResult } |
|
||||
|
||||
export async function transferObject(
|
||||
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'quota' | 'storageUsage' | 'share' | 'org' | 'downloadTasks'>,
|
||||
params: { orgId: string; userId: string; objectId: string; input: TransferMatterInput },
|
||||
params: { orgId: string; userId: string; actor?: ObjectActor; objectId: string; input: TransferMatterInput },
|
||||
): Promise<TransferObjectOutcome> {
|
||||
const { orgId, userId, objectId, input } = params
|
||||
const { targetOrgId, targetParent, mode } = input
|
||||
@@ -927,6 +980,7 @@ export async function transferObject(
|
||||
currentUserId: userId,
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
createdBy: params.actor ? creatorIdentity(params.actor) : undefined,
|
||||
})
|
||||
|
||||
// Move = copy + delete source. Only delete when every file copied — a partial
|
||||
@@ -1130,6 +1184,7 @@ export interface SaveShareInput {
|
||||
targetOrgId: string
|
||||
targetParent: string
|
||||
teamQuotaEnabled?: boolean
|
||||
createdBy?: ActorIdentity
|
||||
}
|
||||
|
||||
export interface SaveShareResult {
|
||||
@@ -1143,6 +1198,7 @@ export interface CopyMatterToOrgInput {
|
||||
targetOrgId: string
|
||||
targetParent: string
|
||||
teamQuotaEnabled?: boolean
|
||||
createdBy?: ActorIdentity
|
||||
}
|
||||
|
||||
function buildPath(parent: string, name: string): string {
|
||||
@@ -1158,6 +1214,7 @@ async function saveFile(
|
||||
targetOrgId: string,
|
||||
targetParent: string,
|
||||
teamQuotaEnabled = true,
|
||||
createdBy: ActorIdentity = { type: 'user', ref: currentUserId, issuer: null },
|
||||
): Promise<Matter> {
|
||||
const bytes = sourceMatter.size ?? 0
|
||||
const dstKey = buildObjectKey({ uid: currentUserId, orgId: targetOrgId, rawExt: fileExt(sourceMatter.name) })
|
||||
@@ -1184,6 +1241,9 @@ async function saveFile(
|
||||
storageId: targetStorage.id,
|
||||
status: 'active',
|
||||
onConflict: 'rename',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
})
|
||||
|
||||
return newMatter
|
||||
@@ -1200,6 +1260,7 @@ async function saveFolderRecursive(
|
||||
targetOrgId: string,
|
||||
targetParent: string,
|
||||
teamQuotaEnabled = true,
|
||||
createdBy: ActorIdentity = { type: 'user', ref: currentUserId, issuer: null },
|
||||
): Promise<SaveShareResult> {
|
||||
const saved: Matter[] = []
|
||||
const skipped: Array<{ name: string; reason: string }> = []
|
||||
@@ -1215,6 +1276,9 @@ async function saveFolderRecursive(
|
||||
storageId: targetStorage.id,
|
||||
status: 'active',
|
||||
onConflict: 'rename',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
})
|
||||
saved.push(rootFolder)
|
||||
|
||||
@@ -1241,6 +1305,7 @@ async function saveFolderRecursive(
|
||||
targetOrgId,
|
||||
targetPath,
|
||||
teamQuotaEnabled,
|
||||
createdBy,
|
||||
)
|
||||
saved.push(newFile)
|
||||
} catch (e) {
|
||||
@@ -1258,6 +1323,9 @@ async function saveFolderRecursive(
|
||||
storageId: targetStorage.id,
|
||||
status: 'active',
|
||||
onConflict: 'rename',
|
||||
createdByActorType: createdBy.type,
|
||||
createdByActorRef: createdBy.ref,
|
||||
createdByActorIssuer: createdBy.issuer,
|
||||
})
|
||||
saved.push(newFolder)
|
||||
queue.push({
|
||||
@@ -1275,7 +1343,7 @@ async function saveFolderRecursive(
|
||||
// target org per file; files that fail (e.g. quota) are reported in `skipped`
|
||||
// rather than failing the whole operation.
|
||||
export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterToOrgInput): Promise<SaveShareResult> {
|
||||
const { sourceMatter, currentUserId, targetOrgId, targetParent, teamQuotaEnabled = true } = input
|
||||
const { sourceMatter, currentUserId, targetOrgId, targetParent, teamQuotaEnabled = true, createdBy } = input
|
||||
|
||||
const sourceStorage = await deps.storages.get(sourceMatter.storageId)
|
||||
if (!sourceStorage) throw new Error('Source storage not found')
|
||||
@@ -1292,6 +1360,7 @@ export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterTo
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
teamQuotaEnabled,
|
||||
createdBy,
|
||||
)
|
||||
return { saved: [newMatter], skipped: [] }
|
||||
}
|
||||
@@ -1305,6 +1374,7 @@ export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterTo
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
teamQuotaEnabled,
|
||||
createdBy,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { CreateBackgroundJobRequest } from '@shared/schemas'
|
||||
import type { ActorIdentity } from './audit'
|
||||
|
||||
export interface ArchiveJobMessage {
|
||||
jobId: string
|
||||
orgId: string
|
||||
userId: string
|
||||
createdBy: ActorIdentity
|
||||
request: CreateBackgroundJobRequest
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Plain, framework-free DTOs and the repository port for audit events.
|
||||
|
||||
export type AuditActorType = 'user' | 'api_key' | 'oauth' | 'agent' | 'anonymous' | 'system' | 'device' | 'task-upload'
|
||||
import type { ActorAttribution, ActorType } from '@shared/schemas'
|
||||
|
||||
export type AuditActorType = ActorType
|
||||
|
||||
export interface RecordAuditEventInput {
|
||||
orgId: string
|
||||
@@ -73,19 +75,20 @@ export interface AuditRepo {
|
||||
): Promise<{ items: AuditEvent[]; total: number; page: number; pageSize: number }>
|
||||
}
|
||||
|
||||
export interface AuditActorIdentity {
|
||||
type: AuditActorType
|
||||
export interface ActorIdentity {
|
||||
type: ActorType
|
||||
ref: string | null
|
||||
issuer: string | null
|
||||
}
|
||||
|
||||
export interface AuditActorProfile {
|
||||
export interface ActorProfile {
|
||||
name: string
|
||||
image: string | null
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export interface AuditActorDirectory {
|
||||
export interface ActorDirectory {
|
||||
findUserProfiles(userIds: readonly string[]): Promise<ReadonlyMap<string, ActorProfile>>
|
||||
findApiKeyNames(keyIds: readonly string[]): Promise<ReadonlyMap<string, string>>
|
||||
findDeviceNames(deviceIds: readonly string[]): Promise<ReadonlyMap<string, string>>
|
||||
listTrustedAgentIssuerOrigins(): Promise<ReadonlySet<string>>
|
||||
@@ -95,11 +98,18 @@ export interface AgentInfoGateway {
|
||||
// Profiles are display-only and never authoritative. An omitted identity
|
||||
// tells the caller to retain the stable issuer/subject fallback.
|
||||
resolve(
|
||||
actors: readonly AuditActorIdentity[],
|
||||
actors: readonly ActorIdentity[],
|
||||
trustedIssuerOrigins: ReadonlySet<string>,
|
||||
): Promise<ReadonlyMap<string, AuditActorProfile>>
|
||||
): Promise<ReadonlyMap<string, ActorProfile>>
|
||||
}
|
||||
|
||||
export function auditActorIdentityKey(actor: AuditActorIdentity): string {
|
||||
export type AuditActorIdentity = ActorIdentity
|
||||
export type AuditActorProfile = ActorProfile
|
||||
export type AuditActorDirectory = ActorDirectory
|
||||
export type { ActorAttribution }
|
||||
|
||||
export function actorIdentityKey(actor: ActorIdentity): string {
|
||||
return JSON.stringify([actor.type, actor.issuer, actor.ref])
|
||||
}
|
||||
|
||||
export const auditActorIdentityKey = actorIdentityKey
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ActorType } from '@shared/schemas'
|
||||
import type { Downloader, DownloadTask, DownloadTaskListItem } from '@shared/types'
|
||||
|
||||
// ─── Errors ──────────────────────────────────────────────────────────────────
|
||||
@@ -51,6 +52,9 @@ export interface DownloadTaskRecord {
|
||||
id: string
|
||||
orgId: string
|
||||
createdByUserId: string
|
||||
requestedByActorType?: ActorType | null
|
||||
requestedByActorRef?: string | null
|
||||
requestedByActorIssuer?: string | null
|
||||
sourceType: string
|
||||
sourceUri: string
|
||||
displayName: string | null
|
||||
@@ -145,6 +149,9 @@ export interface CreateDownloadTaskRecordInput {
|
||||
id: string
|
||||
orgId: string
|
||||
createdByUserId: string
|
||||
requestedByActorType?: ActorType
|
||||
requestedByActorRef?: string
|
||||
requestedByActorIssuer?: string | null
|
||||
sourceType: string
|
||||
sourceUri: string
|
||||
displayName: string | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConflictStrategy } from '@shared/schemas'
|
||||
import type { ActorType, ConflictStrategy } from '@shared/schemas'
|
||||
|
||||
export type { ConflictStrategy } from '@shared/schemas'
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface Matter {
|
||||
status: string
|
||||
trashedAt: number | null
|
||||
purgedAt: number | null
|
||||
createdByActorType?: string | null
|
||||
createdByActorRef?: string | null
|
||||
createdByActorIssuer?: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
@@ -34,6 +37,9 @@ export interface CreateMatterInput {
|
||||
object: string
|
||||
storageId: string
|
||||
status: string
|
||||
createdByActorType?: ActorType | null
|
||||
createdByActorRef?: string | null
|
||||
createdByActorIssuer?: string | null
|
||||
/** How to handle name collision with an existing active sibling. Default 'fail'. */
|
||||
onConflict?: ConflictStrategy
|
||||
}
|
||||
@@ -69,6 +75,9 @@ export interface UpdateMatterInput {
|
||||
|
||||
export interface CopyMatterOptions {
|
||||
onConflict?: ConflictStrategy
|
||||
createdByActorType?: ActorType | null
|
||||
createdByActorRef?: string | null
|
||||
createdByActorIssuer?: string | null
|
||||
}
|
||||
|
||||
export interface ConflictResolveOptions {
|
||||
|
||||
@@ -1110,6 +1110,7 @@ describe('saveShare', () => {
|
||||
targetOrgId: 'o-2',
|
||||
targetParent: '',
|
||||
accessCookie: undefined,
|
||||
createdBy: { type: 'oauth' as const, ref: 'agent-1', issuer: 'https://realm.example.com' },
|
||||
}
|
||||
|
||||
it('returns matter_trashed when the share target was trashed', async () => {
|
||||
@@ -1183,6 +1184,7 @@ describe('saveShare', () => {
|
||||
currentUserId: 'u1',
|
||||
targetOrgId: 'o-2',
|
||||
targetParent: 'dest',
|
||||
createdBy: { type: 'oauth', ref: 'agent-1', issuer: 'https://realm.example.com' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { verifyPassword as verifyPasswordHash } from '../lib/password'
|
||||
import type { Platform } from '../platform/interface'
|
||||
import { type SaveToDriveDeps, saveShareToDrive } from './object'
|
||||
import {
|
||||
type ActorIdentity,
|
||||
AppError,
|
||||
badRequest,
|
||||
CreateShareError,
|
||||
@@ -641,6 +642,7 @@ export type SaveShareParams = {
|
||||
boundTargetOrgId?: string | null
|
||||
targetParent: string
|
||||
accessCookie: string | undefined
|
||||
createdBy: ActorIdentity
|
||||
}
|
||||
|
||||
export type SaveShareOutcome =
|
||||
@@ -674,7 +676,13 @@ export async function saveShare(deps: ShareDeps, params: SaveShareParams): Promi
|
||||
const totalBytes = await deps.share.computeSourceBytes(matter)
|
||||
if (!(await deps.share.hasQuotaForBytes(targetOrgId, totalBytes))) return { ok: false, error: quotaExceeded() }
|
||||
|
||||
const result = await saveShareToDrive(deps, { matter, currentUserId, targetOrgId, targetParent })
|
||||
const result = await saveShareToDrive(deps, {
|
||||
matter,
|
||||
currentUserId,
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
createdBy: params.createdBy,
|
||||
})
|
||||
return { ok: true, result }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ describe('audit usecase', () => {
|
||||
const listAdminAudit = vi.fn(async () => result)
|
||||
const resolve = vi.fn(async () => new Map())
|
||||
const findApiKeyNames = vi.fn(async () => new Map())
|
||||
const findUserProfiles = vi.fn(async () => new Map())
|
||||
const findDeviceNames = vi.fn(async () => new Map())
|
||||
const listTrustedAgentIssuerOrigins = vi.fn(async () => new Set<string>())
|
||||
const out = await listAuditEvents(
|
||||
{
|
||||
audit: { listAdminAudit } as Pick<AuditRepo, 'listAdminAudit'>,
|
||||
auditActorDirectory: { findApiKeyNames, findDeviceNames, listTrustedAgentIssuerOrigins },
|
||||
auditActorDirectory: { findUserProfiles, findApiKeyNames, findDeviceNames, listTrustedAgentIssuerOrigins },
|
||||
agentInfo: { resolve } as AgentInfoGateway,
|
||||
},
|
||||
{
|
||||
@@ -56,6 +57,7 @@ describe('audit usecase', () => {
|
||||
{
|
||||
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: async () => new Map(),
|
||||
findApiKeyNames: async () => new Map(),
|
||||
findDeviceNames: async () => new Map(),
|
||||
listTrustedAgentIssuerOrigins: async () => new Set(['https://id.realmroot.dev']),
|
||||
@@ -93,6 +95,7 @@ describe('audit usecase', () => {
|
||||
{
|
||||
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: async () => new Map(),
|
||||
findApiKeyNames: async () => new Map([['key-1', 'CME downloader']]),
|
||||
findDeviceNames: async () => new Map(),
|
||||
listTrustedAgentIssuerOrigins: async () => new Set(),
|
||||
@@ -132,6 +135,7 @@ describe('audit usecase', () => {
|
||||
{
|
||||
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: async () => new Map(),
|
||||
findApiKeyNames: async () => new Map(),
|
||||
findDeviceNames: async () => new Map([['device-1', 'Office Mac']]),
|
||||
listTrustedAgentIssuerOrigins: async () => new Set(),
|
||||
|
||||
@@ -87,6 +87,7 @@ function makeDeps(
|
||||
const deps: TeamDeps = {
|
||||
audit: { record: async () => {}, list: async () => ({ items: [], total: 0 }) } as unknown as AuditRepo,
|
||||
auditActorDirectory: {
|
||||
findUserProfiles: async () => new Map(),
|
||||
findApiKeyNames: async () => new Map(),
|
||||
findDeviceNames: async () => new Map(),
|
||||
listTrustedAgentIssuerOrigins: async () => new Set(),
|
||||
|
||||
@@ -479,6 +479,7 @@ describe('webdav usecase', () => {
|
||||
contentType: 'text/plain',
|
||||
contentLength: 9 as number | null,
|
||||
body: new Uint8Array(9),
|
||||
createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -503,6 +504,9 @@ describe('webdav usecase', () => {
|
||||
dirtype: DirType.FILE,
|
||||
status: 'active',
|
||||
object: storageKey,
|
||||
createdByActorType: 'api_key',
|
||||
createdByActorRef: 'key-1',
|
||||
createdByActorIssuer: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -607,7 +611,13 @@ describe('webdav usecase', () => {
|
||||
it('creates a folder matter under the selected private storage', async () => {
|
||||
const create = vi.fn(async () => folder('Projects'))
|
||||
const deps = makeDeps({ matter: { create } })
|
||||
await createWebDavCollection(deps, { orgId: 'ws-1', userId: 'u1', name: 'Projects', parent: 'Docs' })
|
||||
await createWebDavCollection(deps, {
|
||||
orgId: 'ws-1',
|
||||
userId: 'u1',
|
||||
name: 'Projects',
|
||||
parent: 'Docs',
|
||||
createdBy: { type: 'api_key', ref: 'key-1', issuer: null },
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Projects',
|
||||
@@ -616,6 +626,8 @@ describe('webdav usecase', () => {
|
||||
parent: 'Docs',
|
||||
object: '',
|
||||
status: 'active',
|
||||
createdByActorType: 'api_key',
|
||||
createdByActorRef: 'key-1',
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -720,6 +732,7 @@ describe('webdav usecase', () => {
|
||||
targetResourcePath: 'dst.txt',
|
||||
replacedMatterId: null,
|
||||
replacingTarget: false,
|
||||
createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -737,6 +750,12 @@ describe('webdav usecase', () => {
|
||||
expect(sourceMatter.object).toBe('legacy_/webdav-source-.txt')
|
||||
expect(destinationKey).toMatch(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/)
|
||||
expect(copyObject).toHaveBeenCalledWith(storage, sourceMatter.object, storage, destinationKey)
|
||||
expect(copy).toHaveBeenCalledWith(expect.any(Object), '', destinationKey, {
|
||||
onConflict: 'fail',
|
||||
createdByActorType: 'api_key',
|
||||
createdByActorRef: 'key-1',
|
||||
createdByActorIssuer: null,
|
||||
})
|
||||
expect(copyDeadProperties).toHaveBeenCalledWith('ws1', 'src.txt', 'dst.txt')
|
||||
})
|
||||
|
||||
@@ -794,6 +813,7 @@ describe('webdav usecase', () => {
|
||||
targetMatter: null,
|
||||
replacingTarget: false,
|
||||
depth: 'infinity' as const,
|
||||
createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -823,6 +843,12 @@ describe('webdav usecase', () => {
|
||||
object: expect.stringMatching(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/),
|
||||
},
|
||||
])
|
||||
expect(copy).toHaveBeenCalledWith(expect.any(Object), '', '', {
|
||||
onConflict: 'fail',
|
||||
createdByActorType: 'api_key',
|
||||
createdByActorRef: 'key-1',
|
||||
createdByActorIssuer: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('copies only the root when depth=0', async () => {
|
||||
@@ -881,6 +907,7 @@ describe('webdav usecase', () => {
|
||||
owner: 'tester',
|
||||
depth: '0',
|
||||
timeoutSeconds: 600,
|
||||
createdBy: { type: 'api_key', ref: 'key-1', issuer: null },
|
||||
})
|
||||
expect(out).toEqual({ lock, created: false })
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
@@ -906,6 +933,7 @@ describe('webdav usecase', () => {
|
||||
owner: 'tester',
|
||||
depth: 'infinity',
|
||||
timeoutSeconds: 3600,
|
||||
createdBy: { type: 'api_key', ref: 'key-1', issuer: null },
|
||||
})
|
||||
expect(out).not.toBeNull()
|
||||
if (!out) throw new Error('Expected lock to be created')
|
||||
@@ -920,6 +948,8 @@ describe('webdav usecase', () => {
|
||||
dirtype: DirType.FILE,
|
||||
status: 'active',
|
||||
object: storageKey,
|
||||
createdByActorType: 'api_key',
|
||||
createdByActorRef: 'key-1',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Database } from '../platform/interface'
|
||||
import type { Deps } from './deps'
|
||||
import { assertFolderNotUsedByDownload } from './downloads/download-folders'
|
||||
import {
|
||||
type ActorIdentity,
|
||||
type ApiKeyAuth,
|
||||
ApiKeyRateLimitError,
|
||||
type DavDeadProperty,
|
||||
@@ -308,6 +309,7 @@ export async function putWebDavFile(
|
||||
contentType: string
|
||||
contentLength: number | null
|
||||
body: ReadableStream | Uint8Array
|
||||
createdBy: ActorIdentity
|
||||
onTiming?: (phase: 'storage' | 's3' | 'persist', durationMs: number) => void
|
||||
},
|
||||
): Promise<PutWebDavOutcome> {
|
||||
@@ -351,6 +353,7 @@ export async function putWebDavFile(
|
||||
objectKey,
|
||||
contentType,
|
||||
uploadedSize,
|
||||
createdBy: params.createdBy,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -366,6 +369,7 @@ export async function putWebDavFile(
|
||||
objectKey,
|
||||
contentType,
|
||||
uploadedSize,
|
||||
createdBy: params.createdBy,
|
||||
}),
|
||||
)
|
||||
if (sizeDelta < 0) await deps.storageUsage.reconcile(orgId, [storage.id])
|
||||
@@ -402,6 +406,7 @@ async function persistWebDavUpload(
|
||||
objectKey: string
|
||||
contentType: string
|
||||
uploadedSize: number
|
||||
createdBy: ActorIdentity
|
||||
},
|
||||
): Promise<{ status: 201 | 204; matterId: string; bytes: number }> {
|
||||
const { orgId, target, fileName, parent, storage, objectKey, contentType, uploadedSize } = params
|
||||
@@ -421,6 +426,9 @@ async function persistWebDavUpload(
|
||||
object: objectKey,
|
||||
storageId: storage.id,
|
||||
status: ObjectStatus.ACTIVE,
|
||||
createdByActorType: params.createdBy.type,
|
||||
createdByActorRef: params.createdBy.ref,
|
||||
createdByActorIssuer: params.createdBy.issuer,
|
||||
})
|
||||
return { status: 201, matterId: matter.id, bytes: uploadedSize }
|
||||
}
|
||||
@@ -429,7 +437,7 @@ async function persistWebDavUpload(
|
||||
|
||||
export async function createWebDavCollection(
|
||||
deps: Pick<Deps, 'matter' | 'storages'>,
|
||||
params: { orgId: string; userId: string; name: string; parent: string },
|
||||
params: { orgId: string; userId: string; name: string; parent: string; createdBy: ActorIdentity },
|
||||
): Promise<void> {
|
||||
const storage = await deps.storages.select()
|
||||
await deps.matter.create({
|
||||
@@ -442,6 +450,9 @@ export async function createWebDavCollection(
|
||||
object: '',
|
||||
storageId: storage.id,
|
||||
status: ObjectStatus.ACTIVE,
|
||||
createdByActorType: params.createdBy.type,
|
||||
createdByActorRef: params.createdBy.ref,
|
||||
createdByActorIssuer: params.createdBy.issuer,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -514,6 +525,7 @@ export async function copyWebDavFile(
|
||||
targetResourcePath: string
|
||||
replacedMatterId: string | null
|
||||
replacingTarget: boolean
|
||||
createdBy: ActorIdentity
|
||||
},
|
||||
): Promise<CopyWebDavFileOutcome> {
|
||||
const { orgId, userId, sourceMatter } = params
|
||||
@@ -540,6 +552,9 @@ export async function copyWebDavFile(
|
||||
}
|
||||
const copy = await deps.matter.copy({ ...sourceMatter, name: params.targetName }, params.targetParent, newObject, {
|
||||
onConflict: 'fail',
|
||||
createdByActorType: params.createdBy.type,
|
||||
createdByActorRef: params.createdBy.ref,
|
||||
createdByActorIssuer: params.createdBy.issuer,
|
||||
})
|
||||
const copyPath = joinMatterPath(copy.parent, copy.name)
|
||||
await deps.webdavState.copyDeadProperties(orgId, params.sourceResourcePath, copyPath)
|
||||
@@ -575,6 +590,7 @@ export async function copyWebDavCollection(
|
||||
targetMatter: Matter | null
|
||||
replacingTarget: boolean
|
||||
depth: '0' | 'infinity'
|
||||
createdBy: ActorIdentity
|
||||
},
|
||||
): Promise<CopyWebDavCollectionOutcome> {
|
||||
const { orgId, userId, sourceMatter, sourceRoot, targetMatter } = params
|
||||
@@ -627,6 +643,9 @@ export async function copyWebDavCollection(
|
||||
|
||||
const rootCopy = await deps.matter.copy({ ...sourceMatter, name: params.targetName }, params.targetParent, '', {
|
||||
onConflict: 'fail',
|
||||
createdByActorType: params.createdBy.type,
|
||||
createdByActorRef: params.createdBy.ref,
|
||||
createdByActorIssuer: params.createdBy.issuer,
|
||||
})
|
||||
createdIds.push(rootCopy.id)
|
||||
await deps.webdavState.copyDeadProperties(orgId, sourceRoot, joinMatterPath(rootCopy.parent, rootCopy.name))
|
||||
@@ -634,6 +653,9 @@ export async function copyWebDavCollection(
|
||||
for (const prepared of preparedCopies) {
|
||||
const copy = await deps.matter.copy(prepared.item, prepared.targetParent, prepared.objectKey, {
|
||||
onConflict: 'fail',
|
||||
createdByActorType: params.createdBy.type,
|
||||
createdByActorRef: params.createdBy.ref,
|
||||
createdByActorIssuer: params.createdBy.issuer,
|
||||
})
|
||||
createdIds.push(copy.id)
|
||||
await deps.webdavState.copyDeadProperties(
|
||||
@@ -698,6 +720,7 @@ export async function createWebDavLock(
|
||||
owner: string
|
||||
depth: string
|
||||
timeoutSeconds: number
|
||||
createdBy: ActorIdentity
|
||||
},
|
||||
): Promise<{ lock: DavLock; created: boolean } | null> {
|
||||
const { orgId, userId, target } = params
|
||||
@@ -729,6 +752,9 @@ export async function createWebDavLock(
|
||||
object: objectKey,
|
||||
storageId: storage.id,
|
||||
status: ObjectStatus.ACTIVE,
|
||||
createdByActorType: params.createdBy.type,
|
||||
createdByActorRef: params.createdBy.ref,
|
||||
createdByActorIssuer: params.createdBy.issuer,
|
||||
})
|
||||
const lock = await deps.webdavState.createLock({
|
||||
orgId,
|
||||
|
||||
Reference in New Issue
Block a user