fix(objects): load creator profiles on demand

Keep object list responses limited to recorded creator identities and resolve full profiles through the creator subresource. Normalize list avatar sizing and avoid retrying terminal creator lookup errors.
This commit is contained in:
jarvis
2026-08-16 21:06:09 -04:00
parent daaeb56bd6
commit 82f5988d56
18 changed files with 778 additions and 154 deletions
+274 -46
View File
@@ -55,6 +55,78 @@ func (e ActorAttributionType) Valid() bool {
}
}
// Defines values for ActorIdentityType.
const (
ActorIdentityTypeAgent ActorIdentityType = "agent"
ActorIdentityTypeAnonymous ActorIdentityType = "anonymous"
ActorIdentityTypeApiKey ActorIdentityType = "api_key"
ActorIdentityTypeDevice ActorIdentityType = "device"
ActorIdentityTypeOauth ActorIdentityType = "oauth"
ActorIdentityTypeSystem ActorIdentityType = "system"
ActorIdentityTypeTaskUpload ActorIdentityType = "task-upload"
ActorIdentityTypeUser ActorIdentityType = "user"
)
// Valid indicates whether the value is a known member of the ActorIdentityType enum.
func (e ActorIdentityType) Valid() bool {
switch e {
case ActorIdentityTypeAgent:
return true
case ActorIdentityTypeAnonymous:
return true
case ActorIdentityTypeApiKey:
return true
case ActorIdentityTypeDevice:
return true
case ActorIdentityTypeOauth:
return true
case ActorIdentityTypeSystem:
return true
case ActorIdentityTypeTaskUpload:
return true
case ActorIdentityTypeUser:
return true
default:
return false
}
}
// Defines values for ActorProfileType.
const (
ActorProfileTypeAgent ActorProfileType = "agent"
ActorProfileTypeAnonymous ActorProfileType = "anonymous"
ActorProfileTypeApiKey ActorProfileType = "api_key"
ActorProfileTypeDevice ActorProfileType = "device"
ActorProfileTypeOauth ActorProfileType = "oauth"
ActorProfileTypeSystem ActorProfileType = "system"
ActorProfileTypeTaskUpload ActorProfileType = "task-upload"
ActorProfileTypeUser ActorProfileType = "user"
)
// Valid indicates whether the value is a known member of the ActorProfileType enum.
func (e ActorProfileType) Valid() bool {
switch e {
case ActorProfileTypeAgent:
return true
case ActorProfileTypeAnonymous:
return true
case ActorProfileTypeApiKey:
return true
case ActorProfileTypeDevice:
return true
case ActorProfileTypeOauth:
return true
case ActorProfileTypeSystem:
return true
case ActorProfileTypeTaskUpload:
return true
case ActorProfileTypeUser:
return true
default:
return false
}
}
// Defines values for AdminAnalyticsGrowthComparisonCoverageQuality.
const (
AdminAnalyticsGrowthComparisonCoverageQualityExact AdminAnalyticsGrowthComparisonCoverageQuality = "exact"
@@ -4121,6 +4193,29 @@ type ActorAttribution struct {
// ActorAttributionType defines model for ActorAttribution.Type.
type ActorAttributionType string
// ActorIdentity defines model for ActorIdentity.
type ActorIdentity struct {
Issuer *string `json:"issuer"`
Ref *string `json:"ref"`
Type ActorIdentityType `json:"type"`
}
// ActorIdentityType defines model for ActorIdentity.Type.
type ActorIdentityType string
// ActorProfile defines model for ActorProfile.
type ActorProfile struct {
Image *string `json:"image"`
Issuer *string `json:"issuer"`
Name string `json:"name"`
ProfileUrl *string `json:"profileUrl,omitempty"`
Ref *string `json:"ref"`
Type ActorProfileType `json:"type"`
}
// ActorProfileType defines model for ActorProfile.Type.
type ActorProfileType string
// AdminAnalyticsGrowth defines model for AdminAnalyticsGrowth.
type AdminAnalyticsGrowth struct {
ActiveUserTrend []struct {
@@ -5815,14 +5910,14 @@ type ManualImageDomainSettingsProvider string
// Matter defines model for Matter.
type Matter struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorAttribution `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorIdentity `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
@@ -5856,10 +5951,10 @@ type NotificationPage struct {
// ObjectListItem defines model for ObjectListItem.
type ObjectListItem struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorAttribution `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorIdentity `json:"createdBy"`
Dirtype *int `json:"dirtype"`
// HasChildren Whether this folder contains at least one child folder.
HasChildren bool `json:"hasChildren"`
@@ -11048,6 +11143,9 @@ type ClientInterface interface {
CopyObject(ctx context.Context, id string, body CopyObjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetObjectCreator request
GetObjectCreator(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error)
// TransferObjectWithBody request with any body
TransferObjectWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -12319,6 +12417,18 @@ func (c *Client) CopyObject(ctx context.Context, id string, body CopyObjectJSONR
return c.Client.Do(req)
}
func (c *Client) GetObjectCreator(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetObjectCreatorRequest(c.Server, id)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) TransferObjectWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewTransferObjectRequestWithBody(c.Server, id, contentType, body)
if err != nil {
@@ -16403,6 +16513,40 @@ func NewCopyObjectRequestWithBody(server string, id string, contentType string,
return req, nil
}
// NewGetObjectCreatorRequest generates requests for GetObjectCreator
func NewGetObjectCreatorRequest(server string, id string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/objects/%s/creator", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewTransferObjectRequest calls the generic TransferObject builder with application/json body
func NewTransferObjectRequest(server string, id string, body TransferObjectJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -21441,6 +21585,9 @@ type ClientWithResponsesInterface interface {
CopyObjectWithResponse(ctx context.Context, id string, body CopyObjectJSONRequestBody, reqEditors ...RequestEditorFn) (*CopyObjectResponse, error)
// GetObjectCreatorWithResponse request
GetObjectCreatorWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetObjectCreatorResponse, error)
// TransferObjectWithBodyWithResponse request with any body
TransferObjectWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TransferObjectResponse, error)
@@ -23440,14 +23587,14 @@ type CreateObjectResponse struct {
Body []byte
HTTPResponse *http.Response
JSON201 *struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorAttribution `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorIdentity `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
@@ -23572,15 +23719,15 @@ type GetObjectResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorAttribution `json:"createdBy"`
Dirtype *int `json:"dirtype"`
DownloadUrl *string `json:"downloadUrl,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorIdentity `json:"createdBy"`
Dirtype *int `json:"dirtype"`
DownloadUrl *string `json:"downloadUrl,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
@@ -23685,6 +23832,38 @@ func (r CopyObjectResponse) ContentType() string {
return ""
}
type GetObjectCreatorResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *ActorProfile
JSON400 *Error
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r GetObjectCreatorResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r GetObjectCreatorResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r GetObjectCreatorResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type TransferObjectResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -27862,6 +28041,15 @@ func (c *ClientWithResponses) CopyObjectWithResponse(ctx context.Context, id str
return ParseCopyObjectResponse(rsp)
}
// GetObjectCreatorWithResponse request returning *GetObjectCreatorResponse
func (c *ClientWithResponses) GetObjectCreatorWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetObjectCreatorResponse, error) {
rsp, err := c.GetObjectCreator(ctx, id, reqEditors...)
if err != nil {
return nil, err
}
return ParseGetObjectCreatorResponse(rsp)
}
// TransferObjectWithBodyWithResponse request with arbitrary body returning *TransferObjectResponse
func (c *ClientWithResponses) TransferObjectWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TransferObjectResponse, error) {
rsp, err := c.TransferObjectWithBody(ctx, id, contentType, body, reqEditors...)
@@ -31091,14 +31279,14 @@ func ParseCreateObjectResponse(rsp *http.Response) (*CreateObjectResponse, error
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201:
var dest struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorAttribution `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorIdentity `json:"createdBy"`
Dirtype *int `json:"dirtype"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
@@ -31257,15 +31445,15 @@ func ParseGetObjectResponse(rsp *http.Response) (*GetObjectResponse, error) {
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorAttribution `json:"createdBy"`
Dirtype *int `json:"dirtype"`
DownloadUrl *string `json:"downloadUrl,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
CreatedBy *ActorIdentity `json:"createdBy"`
Dirtype *int `json:"dirtype"`
DownloadUrl *string `json:"downloadUrl,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
@@ -31394,6 +31582,46 @@ func ParseCopyObjectResponse(rsp *http.Response) (*CopyObjectResponse, error) {
return response, nil
}
// ParseGetObjectCreatorResponse parses an HTTP response from a GetObjectCreatorWithResponse call
func ParseGetObjectCreatorResponse(rsp *http.Response) (*GetObjectCreatorResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &GetObjectCreatorResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest ActorProfile
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
}
return response, nil
}
// ParseTransferObjectResponse parses an HTTP response from a TransferObjectWithResponse call
func ParseTransferObjectResponse(rsp *http.Response) (*TransferObjectResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+30 -13
View File
@@ -117,7 +117,11 @@ test.describe('File table responsive columns', () => {
test('desktop: creator uses an avatar-only column with identity details on hover @desktop', async ({ page }) => {
await signUpAndGoToFiles(page)
const creatorResponsePromise = page.waitForResponse(
(response) => response.url().endsWith('/creator') && response.request().method() === 'GET',
)
await createFolder(page, 'test-folder')
expect((await creatorResponsePromise).ok()).toBe(true)
await expect(page.getByRole('columnheader', { name: /size/i })).toBeVisible()
await expect(page.getByRole('columnheader', { name: /modified/i })).toBeVisible()
@@ -127,21 +131,20 @@ test.describe('File table responsive columns', () => {
const creatorCell = row.getByRole('cell').nth(4)
const creatorTrigger = creatorCell.getByRole('button', { name: /created by:/i })
await expect(creatorTrigger).toBeVisible()
const creatorLabel = await creatorTrigger.getAttribute('aria-label')
const creatorName = creatorLabel?.replace(/^Created by:\s*/i, '')
expect(creatorName).toBeTruthy()
await expect(creatorTrigger).not.toContainText(creatorName!)
const resolvedCreatorLabel = await creatorTrigger.getAttribute('aria-label')
const resolvedCreatorName = resolvedCreatorLabel?.replace(/^Created by:\s*/i, '')
expect(resolvedCreatorName).toBeTruthy()
await expect(creatorTrigger).not.toContainText(resolvedCreatorName!)
await creatorTrigger.hover()
const identityCard = page.locator('[data-slot="hover-card-content"]')
await expect(identityCard).toBeVisible()
await expect(identityCard.getByText(creatorName!, { exact: true })).toBeVisible()
await expect(identityCard.getByText(resolvedCreatorName!, { exact: true })).toBeVisible()
await expect(identityCard.getByText('User', { exact: true })).toBeVisible()
await page.getByLabel('Grid view').click()
const gridCard = page.getByRole('button', { name: /test-folder/ })
await expect(gridCard).toBeVisible()
await expect(gridCard).not.toContainText(creatorName!)
await expect(gridCard).not.toContainText(resolvedCreatorName!)
await page.getByLabel('List view').click()
@@ -149,7 +152,7 @@ test.describe('File table responsive columns', () => {
await page.getByRole('menuitem', { name: /details/i }).click()
const details = page.getByRole('dialog', { name: 'test-folder' })
await expect(details.getByText(/created by/i)).toBeVisible()
await expect(details.getByTitle(creatorName!)).toBeVisible()
await expect(details.getByTitle(resolvedCreatorName!)).toBeVisible()
})
test('desktop: creator hover card truncates long identity metadata @desktop', async ({ page }) => {
@@ -169,18 +172,32 @@ test.describe('File table responsive columns', () => {
type: 'agent',
ref: 'agent-0123456789abcdef0123456789abcdef',
issuer: actorIssuer,
name: actorName,
image: null,
profileUrl: 'https://identity.example.com/agents/agent-0123456789abcdef0123456789abcdef',
resolved: true,
}
}
await route.fulfill({ response, json: body })
})
await page.route('**/api/objects/*/creator', async (route) => {
await route.fulfill({
json: {
type: 'agent',
ref: 'agent-0123456789abcdef0123456789abcdef',
issuer: actorIssuer,
name: actorName,
image: null,
profileUrl: 'https://identity.example.com/agents/agent-0123456789abcdef0123456789abcdef',
},
})
})
const creatorResponsePromise = page.waitForResponse(
(response) => response.url().endsWith('/creator') && response.request().method() === 'GET',
)
await page.reload()
expect((await creatorResponsePromise).ok()).toBe(true)
const row = page.getByRole('row', { name: /long-actor-folder/ })
await row.getByRole('button', { name: `Created by: ${actorName}` }).hover()
const creator = row.locator('[data-slot="hover-card-trigger"]')
await expect(creator).toHaveAttribute('aria-label', `Created by: ${actorName}`)
await creator.hover()
const card = page.locator('[data-slot="hover-card-content"]')
await expect(card).toBeVisible()
await expect(card.getByRole('link')).toHaveAttribute(
+108 -7
View File
@@ -13,12 +13,14 @@ import { currentTrafficPeriod } from '../domain/quota.js'
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js'
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../usecases/object.js'
import type {
ActorIdentity,
CopyMatterOptions,
CreateMatterInput,
Matter,
MatterListFilters,
UpdateMatterInput,
} from '../usecases/ports.js'
import { actorIdentityKey } from '../usecases/ports.js'
import { createCapacityRequestHash } from './objects.js'
type TestDbForMatter = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -150,14 +152,29 @@ async function insertFolder(
async function insertFile(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
opts: { id: string; name: string; parent?: string; status?: string; size?: number; trashedAt?: number },
opts: {
id: string
name: string
parent?: string
status?: string
size?: number
trashedAt?: number
createdBy?: ActorIdentity
},
) {
const now = Date.now()
const status = opts.status ?? 'active'
const size = opts.size ?? 100
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${opts.trashedAt ?? null}, ${now}, ${now})
INSERT INTO matters (
id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at,
created_by_actor_type, created_by_actor_ref, created_by_actor_issuer, created_at, updated_at
)
VALUES (
${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0,
${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${opts.trashedAt ?? null},
${opts.createdBy?.type ?? null}, ${opts.createdBy?.ref ?? null}, ${opts.createdBy?.issuer ?? null}, ${now}, ${now}
)
`)
}
@@ -223,6 +240,83 @@ describe('Objects API', () => {
expect(body).toEqual({ items: [], nextPageToken: null })
})
it('GET /api/objects does not wait for external creator profiles [spec: objects/creator-attribution]', async () => {
const { app, db, deps } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const identity = { type: 'oauth', ref: 'agent-1', issuer: 'https://id.realmroot.dev/api/auth' } as const
await insertFile(db, orgId, { id: 'LazyCreatorFile', name: 'lazy.txt', createdBy: identity })
vi.spyOn(deps.auditActorDirectory, 'listTrustedAgentIssuerOrigins').mockResolvedValue(
new Set(['https://id.realmroot.dev']),
)
vi.spyOn(deps.agentInfo, 'resolve').mockImplementation(() => new Promise(() => {}))
const res = await Promise.race([
app.request('/api/objects?path=&pageSize=100', { headers }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('object_list_waited_for_creator')), 100)),
])
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<{ id: string; createdBy: unknown }> }
expect(body.items).toHaveLength(1)
expect(body.items[0]).toMatchObject({ id: 'LazyCreatorFile' })
expect(body.items[0]?.createdBy).toEqual(identity)
expect(deps.agentInfo.resolve).not.toHaveBeenCalled()
})
it('GET /api/objects/{id}/creator resolves the creator profile on demand', async () => {
const { app, db, deps } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const identity = { type: 'oauth', ref: 'agent-1', issuer: 'https://id.realmroot.dev/api/auth' } as const
await insertFile(db, orgId, { id: 'CreatorProfileFile', name: 'creator.txt', createdBy: identity })
vi.spyOn(deps.auditActorDirectory, 'listTrustedAgentIssuerOrigins').mockResolvedValue(
new Set(['https://id.realmroot.dev']),
)
vi.spyOn(deps.agentInfo, 'resolve').mockResolvedValue(
new Map([
[
actorIdentityKey(identity),
{
name: 'Jarvis',
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
profileUrl: 'https://id.realmroot.dev/agents/agent-1',
resolved: true,
},
],
]),
)
const res = await app.request('/api/objects/CreatorProfileFile/creator', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
...identity,
name: 'Jarvis',
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
profileUrl: 'https://id.realmroot.dev/agents/agent-1',
})
})
it('GET /api/objects/{id}/creator does not return a fallback profile', async () => {
const { app, db, deps } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const identity = { type: 'oauth', ref: 'missing-agent', issuer: 'https://id.realmroot.dev/api/auth' } as const
await insertFile(db, orgId, { id: 'MissingCreatorProfile', name: 'missing.txt', createdBy: identity })
vi.spyOn(deps.auditActorDirectory, 'listTrustedAgentIssuerOrigins').mockResolvedValue(
new Set(['https://id.realmroot.dev']),
)
vi.spyOn(deps.agentInfo, 'resolve').mockResolvedValue(new Map())
const res = await app.request('/api/objects/MissingCreatorProfile/creator', { headers })
expect(res.status).toBe(404)
})
it('GET /api/objects enforces the shared page-size limit', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
@@ -2909,11 +3003,13 @@ describe('Objects API — error branches', () => {
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({
const body = (await res.json()) as { createdBy: unknown; name: string; parent: string }
expect(body).toMatchObject({
name: '藏.mp3',
parent: targetFolder,
createdBy: { type: 'device', resolved: true },
createdBy: { type: 'device' },
})
expect(body.createdBy).not.toHaveProperty('resolved')
})
it('creates a folder with a workspace API key that has objects:create [spec: objects/creator-attribution]', async () => {
@@ -2930,11 +3026,13 @@ 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({
const body = (await res.json()) as { createdBy: unknown; name: string; orgId: string }
expect(body).toMatchObject({
name: 'api-key-folder',
orgId,
createdBy: { type: 'api_key', name: 'API key · test-api-key', resolved: true },
createdBy: { type: 'api_key' },
})
expect(body.createdBy).not.toHaveProperty('resolved')
})
it('returns null instead of attributing a legacy object to the workspace owner [spec: objects/legacy-creator]', async () => {
@@ -2948,6 +3046,9 @@ describe('Objects API — error branches', () => {
expect(res.status).toBe(200)
await expect(res.json()).resolves.toMatchObject({ id: 'legacy-creator-file', createdBy: null })
const creatorRes = await app.request('/api/objects/legacy-creator-file/creator', { headers })
expect(creatorRes.status).toBe(404)
})
it('returns 403 when an object API key is missing the route scope', async () => {
+42 -15
View File
@@ -1,7 +1,8 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
actorAttributionSchema,
actorIdentitySchema,
actorProfileSchema,
completeObjectUploadSchema,
copyObjectBodySchema,
createMatterSchema,
@@ -26,11 +27,12 @@ import {
copyObject,
createObject,
getObject,
getObjectCreator,
listObjects,
matterCreatorIdentity,
type ObjectActor,
ObjectUploadSessionError,
presignUploadSessionParts,
resolveMatterCreators,
transferObject,
trashObject,
updateObject,
@@ -62,7 +64,7 @@ const matterSchema = z
storageId: opaqueIdSchema,
status: z.string(),
trashedAt: z.number().int().nullable(),
createdBy: actorAttributionSchema.nullable(),
createdBy: actorIdentitySchema.nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
@@ -109,9 +111,8 @@ function toObjectListItemDTO(item: MatterListItem, createdBy: MatterDTO['created
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)
function matterDTO(matter: Matter): MatterDTO {
return toMatterDTO(matter, matterCreatorIdentity(matter))
}
const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
@@ -358,6 +359,23 @@ const getObjectRoute = authRoute(
},
)
const getObjectCreatorRoute = authRoute(
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
{
operationId: 'getObjectCreator',
summary: 'Get object creator',
tags: ['Objects'],
method: 'get',
path: '/{id}/creator',
request: { params: idParam },
responses: {
200: jsonContent(actorProfileSchema, 'Object creator profile'),
400: errorResponse('No active organization'),
404: errorResponse('Not found'),
},
},
)
const patchObjectRoute = authRoute(
{ scopes: [AuthorizationScope.OBJECTS_UPDATE], minTeamRole: 'editor' },
{
@@ -472,10 +490,9 @@ 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((item) => toObjectListItemDTO(item, creators.get(item.id) ?? null)),
items: result.result.items.map((item) => toObjectListItemDTO(item, matterCreatorIdentity(item))),
nextPageToken: await encodeNextPageToken(c.get('platform'), result.result.nextBoundary, {
query: fingerprint,
codec: directoryCursorCodec,
@@ -484,6 +501,16 @@ const objects = app
200,
)
})
.openapi(getObjectCreatorRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await getObjectCreator(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
})
if (!result.ok) throw result.error
return c.json(result.creator, 200)
})
.openapi(createObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
@@ -520,7 +547,7 @@ const objects = app
402,
)
}
const matter = await matterDTO(c.get('deps'), result.matter)
const matter = matterDTO(result.matter)
if ('upload' in result) return c.json({ ...matter, upload: result.upload }, 201)
return c.json(matter, 201)
})
@@ -560,7 +587,7 @@ const objects = app
if ('error' in result) throw result.error // quota exceeded
throw new ObjectUploadSessionError('not_found') // draft gone
}
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
return c.json(matterDTO(result.matter), 200)
})
.openapi(abortUploadRoute, async (c) => {
const orgId = c.get('orgId')
@@ -603,9 +630,9 @@ const objects = app
},
result.receipt.trafficEventId,
)
return c.json({ ...(await matterDTO(c.get('deps'), result.matter)), downloadUrl: result.downloadUrl }, 200)
return c.json({ ...matterDTO(result.matter), downloadUrl: result.downloadUrl }, 200)
}
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
return c.json(matterDTO(result.matter), 200)
}
throw result.error
})
@@ -618,7 +645,7 @@ const objects = app
input: c.req.valid('json'),
})
if (!result.ok) throw result.error
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
return c.json(matterDTO(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}.
@@ -644,7 +671,7 @@ const objects = app
input: { copyFrom: c.req.valid('param').id, parent: body.parent, onConflict: body.onConflict },
})
if (!result.ok) throw result.error
return c.json(await matterDTO(c.get('deps'), result.matter), 201)
return c.json(matterDTO(result.matter), 201)
})
.openapi(transferObjectRoute, async (c) => {
const orgId = c.get('orgId')
@@ -661,7 +688,7 @@ const objects = app
if (!result.ok) throw result.error
return c.json(
{
saved: await Promise.all(result.result.saved.map((matter) => matterDTO(c.get('deps'), matter))),
saved: result.result.saved.map(matterDTO),
skipped: result.result.skipped,
sourceDeleted: result.result.sourceDeleted,
},
+33 -30
View File
@@ -12,7 +12,7 @@
import { DirType } from '@shared/constants'
import type {
ActorAttribution,
ActorProfile,
CompleteObjectUploadInput,
ConflictStrategy,
CreateMatterInput,
@@ -22,7 +22,7 @@ import type {
} from '@shared/schemas'
import type { ObjectUploadInstructions } from '@shared/types'
import { buildObjectKey, fileExt } from '../lib/path-template'
import { resolveActorAttributions } from './audit-actors'
import { resolveActorProfiles } from './audit-actors'
import type { Deps } from './deps'
import { assertFolderNotUsedByDownload, ensureDownloadFolderPath } from './downloads/download-folders'
import { assertTaskUploadAllowed } from './downloads/downloads'
@@ -259,34 +259,37 @@ 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] : []
}),
)
export function matterCreatorIdentity(matter: Matter): ActorIdentity | null {
if (!matter.createdByActorType || !matter.createdByActorRef) return null
return {
type: matter.createdByActorType as ActorIdentity['type'],
ref: matter.createdByActorRef,
issuer: matter.createdByActorIssuer ?? null,
}
}
export type GetObjectCreatorOutcome = { ok: true; creator: ActorProfile } | { ok: false; error: AppError }
export async function getObjectCreator(
deps: Pick<Deps, 'matter' | 'auditActorDirectory' | 'agentInfo'>,
params: { orgId: string; objectId: string },
): Promise<GetObjectCreatorOutcome> {
const matter = await deps.matter.get(params.objectId, params.orgId)
if (!matter || matter.trashedAt != null) return { ok: false, error: notFound() }
const identity = matterCreatorIdentity(matter)
if (!identity) return { ok: false, error: notFound() }
const profiles = await resolveActorProfiles(deps, [identity])
const profile = profiles.get(actorIdentityKey(identity))
if (!profile) return { ok: false, error: notFound() }
return {
ok: true,
creator: {
...identity,
name: profile.name,
image: profile.image,
...(profile.profileUrl !== undefined ? { profileUrl: profile.profileUrl } : {}),
},
}
}
// Decides the S3 mechanism, presigns every URL up front, and records the upload
+21
View File
@@ -11,6 +11,25 @@ export const actorTypeSchema = z.enum([
'task-upload',
])
export const actorIdentitySchema = z
.object({
type: actorTypeSchema,
ref: z.string().nullable(),
issuer: z.string().nullable(),
})
.openapi('ActorIdentity')
export const actorProfileSchema = z
.object({
type: actorTypeSchema,
ref: z.string().nullable(),
issuer: z.string().nullable(),
name: z.string(),
image: z.string().nullable(),
profileUrl: z.string().url().nullable().optional(),
})
.openapi('ActorProfile')
export const actorAttributionSchema = z
.object({
type: actorTypeSchema,
@@ -24,4 +43,6 @@ export const actorAttributionSchema = z
.openapi('ActorAttribution')
export type ActorType = z.infer<typeof actorTypeSchema>
export type ActorIdentity = z.infer<typeof actorIdentitySchema>
export type ActorProfile = z.infer<typeof actorProfileSchema>
export type ActorAttribution = z.infer<typeof actorAttributionSchema>
+2 -2
View File
@@ -1,8 +1,8 @@
import { z } from 'zod'
import { opaqueIdSchema } from './id'
export type { ActorAttribution, ActorType } from './actors'
export { actorAttributionSchema, actorTypeSchema } from './actors'
export type { ActorAttribution, ActorIdentity, ActorProfile, ActorType } from './actors'
export { actorAttributionSchema, actorIdentitySchema, actorProfileSchema, actorTypeSchema } from './actors'
export {
adminAnalyticsGrowthSchema,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { CommercePayment, CommerceProduct, ProductPrice } from 'zpan-cloud-sdk'
import type { DirType, ObjectStatus, StorageStatus, StorageStatusReason } from '../constants'
import type { ActorAttribution } from '../schemas/actors'
import type { ActorIdentity } from '../schemas/actors'
import type {
CloudOrder as ZPanCloudOrder,
CloudOrderFulfillmentPayload as ZPanCloudOrderFulfillmentPayload,
@@ -21,7 +21,7 @@ export interface StorageObject {
status: ObjectStatus
// Soft-delete marker: null = live, epoch ms = in trash.
trashedAt: number | null
createdBy?: ActorAttribution | null
createdBy?: ActorIdentity | null
createdAt: string
updatedAt: string
}
+2 -1
View File
@@ -45,7 +45,8 @@ Feature: Objects
Scenario: Object creation records the authenticated actor
Given an authenticated user, API key, agent, or device
When that actor creates an object
Then the object stores the stable actor identity and returns its display profile
Then the object stores and returns the stable actor identity
And its display profile is available from the creator subresource
@objects/legacy-creator @api
Scenario: Historical objects do not impersonate the workspace owner
+37 -1
View File
@@ -7,7 +7,10 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => (key === 'actors.notRecorded' ? '-' : key) }),
}))
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ActorIdentity', () => {
it('renders the resolved name and avatar', () => {
@@ -53,6 +56,39 @@ describe('ActorIdentity', () => {
})
describe('ActorAvatarHoverCard', () => {
it('uses the same fixed avatar box with an image or fallback', async () => {
class LoadedImage {
addEventListener(type: string, listener: EventListener) {
if (type === 'load') queueMicrotask(() => listener(new Event('load')))
}
removeEventListener() {}
}
vi.stubGlobal('Image', LoadedImage)
const actor: ActorAttribution = {
type: 'agent',
ref: 'agent-1',
issuer: 'https://realm.example.com',
name: 'Research Agent',
image: 'https://example.com/agent.png',
resolved: true,
}
const { rerender } = render(<ActorAvatarHoverCard actor={actor} />)
await waitFor(() => expect(document.querySelector('[data-slot="avatar-image"]')).toBeTruthy())
const image = document.querySelector('[data-slot="avatar-image"]')
const imageAvatar = document.querySelector('[data-slot="avatar"]')
expect(imageAvatar?.getAttribute('data-size')).toBe('default')
expect(image?.getAttribute('width')).toBe('32')
expect(image?.getAttribute('height')).toBe('32')
expect(image?.className).toContain('object-cover')
rerender(<ActorAvatarHoverCard actor={{ ...actor, image: null }} />)
const fallbackAvatar = document.querySelector('[data-slot="avatar"]')
expect(fallbackAvatar?.getAttribute('data-size')).toBe('default')
expect(fallbackAvatar?.className).toBe(imageAvatar?.className)
})
it('keeps the file-list cell avatar-only and reveals the detailed identity card on hover', async () => {
const actor: ActorAttribution = {
type: 'oauth',
+46 -26
View File
@@ -1,4 +1,4 @@
import type { ActorAttribution } from '@shared/schemas'
import type { ActorAttribution, ActorIdentity as ActorIdentityData, ActorProfile } from '@shared/schemas'
import type { AuditEvent } from '@shared/types'
import { Bot, CircleHelp, ExternalLink, KeyRound, Monitor, UserRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -9,11 +9,16 @@ import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
interface ActorIdentityProps {
actor?: ActorAttribution | null
actor?: ActorAttribution | ActorProfile | ActorIdentityData | null
compact?: boolean
className?: string
}
interface ActorAvatarHoverCardProps extends Pick<ActorIdentityProps, 'actor' | 'className'> {
onOpenChange?: (open: boolean) => void
size?: 'sm' | 'default' | 'lg'
}
export function auditEventActor(event: AuditEvent): ActorAttribution {
return {
type: event.actorType,
@@ -23,26 +28,39 @@ export function auditEventActor(event: AuditEvent): ActorAttribution {
}
}
function ActorFallback({ actor }: { actor: ActorAttribution }) {
type DisplayActor = ActorAttribution | ActorProfile | ActorIdentityData
const ACTOR_AVATAR_PIXELS = { sm: 24, default: 32, lg: 40 } as const
function hasActorProfile(actor: DisplayActor): actor is ActorAttribution | ActorProfile {
return 'name' in actor
}
function ActorFallback({ actor }: { actor: DisplayActor }) {
if (actor.type === 'api_key') return <KeyRound aria-hidden="true" />
if (actor.type === 'device') return <Monitor aria-hidden="true" />
if (actor.type === 'oauth' || actor.type === 'agent') return <Bot aria-hidden="true" />
if (actor.type === 'user') {
const initials = actor.name
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase()
return initials || <UserRound aria-hidden="true" />
if (hasActorProfile(actor)) {
const initials = actor.name
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase()
if (initials) return initials
}
return <UserRound aria-hidden="true" />
}
return <CircleHelp aria-hidden="true" />
}
function ActorAvatar({ actor, size = 'sm' }: { actor: ActorAttribution; size?: 'sm' | 'default' | 'lg' }) {
function ActorAvatar({ actor, size = 'sm' }: { actor: DisplayActor; size?: 'sm' | 'default' | 'lg' }) {
const pixels = ACTOR_AVATAR_PIXELS[size]
return (
<Avatar size={size}>
{actor.image && <AvatarImage src={actor.image} alt="" />}
{hasActorProfile(actor) && actor.image && (
<AvatarImage src={actor.image} alt="" width={pixels} height={pixels} className="block size-full object-cover" />
)}
<AvatarFallback className="[&_svg]:size-3">
<ActorFallback actor={actor} />
</AvatarFallback>
@@ -56,36 +74,36 @@ export function ActorIdentity({ actor, compact = false, className }: ActorIdenti
return <span className={cn('text-muted-foreground', className)}>{t('actors.notRecorded')}</span>
}
const name = hasActorProfile(actor) ? actor.name : t(`actors.type.${actor.type}`)
return (
<span className={cn('inline-flex min-w-0 items-center gap-2', className)} title={actor.name}>
<span className={cn('inline-flex min-w-0 items-center gap-2', className)} title={name}>
<ActorAvatar actor={actor} />
<span className={cn('min-w-0 truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm')}>
{actor.name}
</span>
<span className={cn('min-w-0 truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm')}>{name}</span>
</span>
)
}
export function ActorAvatarHoverCard({ actor, className }: Pick<ActorIdentityProps, 'actor' | 'className'>) {
export function ActorAvatarHoverCard({ actor, className, onOpenChange, size = 'default' }: ActorAvatarHoverCardProps) {
const { t } = useTranslation()
if (!actor) {
return <span className={cn('text-muted-foreground', className)}>{t('actors.notRecorded')}</span>
}
const name = hasActorProfile(actor) ? actor.name : t(`actors.type.${actor.type}`)
return (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCard openDelay={200} closeDelay={100} onOpenChange={onOpenChange}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn(
'inline-flex cursor-help rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
'flex cursor-help rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
className,
)}
aria-label={`${t('files.createdBy')}: ${actor.name}`}
aria-label={`${t('files.createdBy')}: ${name}`}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ActorAvatar actor={actor} size="default" />
<ActorAvatar actor={actor} size={size} />
</button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-64 max-w-[calc(100vw-2rem)] overflow-hidden">
@@ -114,15 +132,17 @@ export function ActorAvatarHoverCard({ actor, className }: Pick<ActorIdentityPro
)
}
function ActorProfileCardHeader({ actor }: { actor: ActorAttribution }) {
function ActorProfileCardHeader({ actor }: { actor: DisplayActor }) {
const { t } = useTranslation()
const name = hasActorProfile(actor) ? actor.name : t(`actors.type.${actor.type}`)
const profileUrl = hasActorProfile(actor) ? actor.profileUrl : undefined
const content = (
<>
<div className="flex min-w-0 flex-1 items-center gap-3 overflow-hidden">
<ActorAvatar actor={actor} size="lg" />
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 overflow-hidden">
<span className="block w-full min-w-0 whitespace-normal break-words text-sm font-semibold" data-actor-field>
{actor.name}
{name}
</span>
<Badge variant="secondary" className="max-w-full min-w-0">
<span className="min-w-0 truncate" data-actor-field>
@@ -131,15 +151,15 @@ function ActorProfileCardHeader({ actor }: { actor: ActorAttribution }) {
</Badge>
</div>
</div>
{actor.profileUrl && <ExternalLink aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />}
{profileUrl && <ExternalLink aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />}
</>
)
if (!actor.profileUrl) return <div className="flex w-full min-w-0 items-center gap-3 overflow-hidden">{content}</div>
if (!profileUrl) return <div className="flex w-full min-w-0 items-center gap-3 overflow-hidden">{content}</div>
return (
<a
href={actor.profileUrl}
href={profileUrl}
target="_blank"
rel="noreferrer"
className="flex w-full min-w-0 items-center gap-3 overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
+2 -2
View File
@@ -1,11 +1,11 @@
import { DirType } from '@shared/constants'
import type { StorageObject } from '@shared/types'
import type { ColumnDef, Row } from '@tanstack/react-table'
import { ActorAvatarHoverCard } from '@/components/actor-identity'
import { Checkbox } from '@/components/ui/checkbox'
import { formatDate, formatSize } from '@/lib/format'
import { FileIcon } from './file-icon'
import { FileRowActions } from './file-row-actions'
import { ObjectCreatorAvatar } from './object-creator'
import type { FileActionHandlers } from './types'
function foldersFirstSort(rowA: Row<StorageObject>, rowB: Row<StorageObject>): number {
@@ -95,7 +95,7 @@ export function getColumns(
{
id: 'createdBy',
header: t('files.colCreatedBy'),
cell: ({ row }) => <ActorAvatarHoverCard actor={row.original.createdBy} />,
cell: ({ row }) => <ObjectCreatorAvatar item={row.original} />,
size: 64,
meta: { className: 'hidden text-center lg:table-cell' },
enableSorting: false,
@@ -1,7 +1,9 @@
import { DirType } from '@shared/constants'
import type { StorageObject } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getObjectCreator } from '@/lib/api'
import { FileDetailsSheet } from './file-details-sheet'
vi.mock('react-i18next', () => ({
@@ -9,6 +11,7 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('./file-icon', () => ({ FileIcon: () => <span data-testid="file-icon" /> }))
vi.mock('@/lib/api', () => ({ getObjectCreator: vi.fn() }))
const item: StorageObject = {
id: 'file-1',
@@ -27,22 +30,32 @@ const item: StorageObject = {
type: 'agent',
ref: 'agent-1',
issuer: 'https://realm.example.com',
name: 'Report Agent',
image: null,
resolved: true,
},
createdAt: '2026-08-01T12:00:00.000Z',
updatedAt: '2026-08-02T12:00:00.000Z',
}
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('FileDetailsSheet', () => {
it('shows creator and file metadata', () => {
render(<FileDetailsSheet item={item} onOpenChange={vi.fn()} />)
it('loads the creator profile and shows file metadata', async () => {
vi.mocked(getObjectCreator).mockResolvedValue({
...item.createdBy!,
name: 'Report Agent',
image: null,
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<FileDetailsSheet item={item} onOpenChange={vi.fn()} />
</QueryClientProvider>,
)
expect(screen.getByText('report.pdf')).toBeTruthy()
expect(screen.getByText('Report Agent')).toBeTruthy()
expect(await screen.findByText('Report Agent')).toBeTruthy()
expect(screen.getByText('/Reports')).toBeTruthy()
expect(screen.getByText('1.0 KB')).toBeTruthy()
})
+2 -2
View File
@@ -1,11 +1,11 @@
import { DirType } from '@shared/constants'
import type { StorageObject } from '@shared/types'
import { useTranslation } from 'react-i18next'
import { ActorIdentity } from '@/components/actor-identity'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { formatDate, formatSize } from '@/lib/format'
import { FileIcon } from './file-icon'
import { ObjectCreatorIdentity } from './object-creator'
interface FileDetailsSheetProps {
item: StorageObject | null
@@ -33,7 +33,7 @@ export function FileDetailsSheet({ item, onOpenChange }: FileDetailsSheetProps)
<dl className="grid grid-cols-[7rem_minmax(0,1fr)] gap-x-4 gap-y-4 px-4 text-sm">
<dt className="text-muted-foreground">{t('files.createdBy')}</dt>
<dd className="min-w-0">
<ActorIdentity actor={item.createdBy} />
<ObjectCreatorIdentity item={item} />
</dd>
<dt className="text-muted-foreground">{t('files.createdAt')}</dt>
<dd>{formatDate(item.createdAt)}</dd>
@@ -0,0 +1,95 @@
import { DirType } from '@shared/constants'
import type { StorageObject } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, getObjectCreator } from '@/lib/api'
import { ObjectCreatorAvatar } from './object-creator'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/lib/api', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/api')>()),
getObjectCreator: vi.fn(),
}))
const item: StorageObject = {
id: 'file-1',
orgId: 'org-1',
alias: 'alias-1',
name: 'report.pdf',
type: 'application/pdf',
size: 1024,
dirtype: DirType.FILE,
parent: '',
object: 'object-key',
storageId: 'storage-1',
status: 'active',
trashedAt: null,
createdBy: {
type: 'agent',
ref: 'agent-1',
issuer: 'https://id.realmroot.dev/api/auth',
},
createdAt: '2026-08-01T12:00:00.000Z',
updatedAt: '2026-08-02T12:00:00.000Z',
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('ObjectCreatorAvatar', () => {
it('loads the creator when the avatar is rendered', async () => {
vi.mocked(getObjectCreator).mockResolvedValue({
...item.createdBy!,
name: 'Jarvis',
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
profileUrl: 'https://id.realmroot.dev/agents/agent-1',
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ObjectCreatorAvatar item={item} />
</QueryClientProvider>,
)
await waitFor(() => expect(getObjectCreator).toHaveBeenCalledWith('file-1'))
const trigger = await screen.findByLabelText('files.createdBy: Jarvis')
expect(trigger.classList.contains('flex')).toBe(true)
expect(trigger.classList.contains('inline-flex')).toBe(false)
expect(trigger.querySelector('[data-slot="avatar"]')?.getAttribute('data-size')).toBe('sm')
})
it('does not request a creator profile when attribution was not recorded', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ObjectCreatorAvatar item={{ ...item, createdBy: null }} />
</QueryClientProvider>,
)
expect(screen.getByText('actors.notRecorded')).toBeTruthy()
expect(getObjectCreator).not.toHaveBeenCalled()
})
it('does not retry when the creator profile returns 404', async () => {
vi.mocked(getObjectCreator).mockRejectedValue(
new ApiError(404, {
error: { code: 404, message: 'Creator not found', status: 'NOT_FOUND' },
}),
)
const queryClient = new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } })
render(
<QueryClientProvider client={queryClient}>
<ObjectCreatorAvatar item={item} />
</QueryClientProvider>,
)
await waitFor(() => expect(queryClient.getQueryState(['objects', 'file-1', 'creator'])?.status).toBe('error'))
expect(getObjectCreator).toHaveBeenCalledTimes(1)
})
})
+31
View File
@@ -0,0 +1,31 @@
import type { ActorIdentity as ActorIdentityData, ActorProfile } from '@shared/schemas'
import type { StorageObject } from '@shared/types'
import { useQuery } from '@tanstack/react-query'
import { ActorAvatarHoverCard, ActorIdentity } from '@/components/actor-identity'
import { ApiError, getObjectCreator } from '@/lib/api'
function retryCreatorQuery(failureCount: number, error: Error): boolean {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false
return failureCount < 3
}
function useObjectCreator(item: StorageObject, enabled: boolean): ActorProfile | ActorIdentityData | null | undefined {
const query = useQuery({
queryKey: ['objects', item.id, 'creator'],
queryFn: () => getObjectCreator(item.id),
enabled: enabled && Boolean(item.createdBy),
staleTime: 5 * 60 * 1000,
retry: retryCreatorQuery,
})
return query.data ?? item.createdBy
}
export function ObjectCreatorAvatar({ item }: { item: StorageObject }) {
const creator = useObjectCreator(item, true)
return <ActorAvatarHoverCard actor={creator} size="sm" />
}
export function ObjectCreatorIdentity({ item }: { item: StorageObject }) {
const creator = useObjectCreator(item, true)
return <ActorIdentity actor={creator} />
}
+26
View File
@@ -60,6 +60,7 @@ import {
getLicensingStatus,
getOAuthConsentContext,
getObject,
getObjectCreator,
getProfile,
getSession,
getShare,
@@ -276,6 +277,31 @@ describe('api', () => {
})
})
describe('getObjectCreator', () => {
it('fetches the object creator subresource', async () => {
const creator = {
type: 'agent' as const,
ref: 'agent-1',
issuer: 'https://id.realmroot.dev/api/auth',
name: 'Jarvis',
image: null,
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(creator))
await expect(getObjectCreator('id1')).resolves.toEqual(creator)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/id1/creator')
expect(init.method).toBe('GET')
})
it('throws ApiError when the creator subresource fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(getObjectCreator('missing')).rejects.toThrow('not found')
})
})
describe('site invitations api', () => {
it('lists site invitations with pagination', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
+5
View File
@@ -1,6 +1,7 @@
import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type {
ActorProfile,
AllowedImageMime,
AnnouncementInput,
CloudCreditBalanceResponse,
@@ -309,6 +310,10 @@ export function getObject(id: string) {
return unwrap<StorageObject & { downloadUrl?: string }>(objects[':id'].$get({ param: { id } }))
}
export function getObjectCreator(id: string) {
return unwrap<ActorProfile>(objects[':id'].creator.$get({ param: { id } }))
}
// POST /api/objects returns a folder, or a file draft with `upload` instructions:
// the server-decided part size and one presigned PUT URL per slice.
export interface CreateObjectResult extends StorageObject {