diff --git a/package.json b/package.json index 33b90b8..7cb300f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@haverstack/adapter-local": "^0.6.0", - "@haverstack/core": "^0.6.0", + "@haverstack/core": "^0.8.0", "@haverstack/wire-types": "^0.6.0", "@hono/node-server": "^1.13.7", "hono": "^4.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e4844..f4c0b0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.6.0 version: 0.6.0 '@haverstack/core': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.8.0 + version: 0.8.0 '@haverstack/wire-types': specifier: ^0.6.0 version: 0.6.0 @@ -410,6 +410,9 @@ packages: '@haverstack/core@0.6.0': resolution: {integrity: sha512-ruv1LrOlCaCgQTfUKbQEi3hYeIxJ7fGHU2ynWPY3LjB5Cia+bbMkAnPtznEparunPNhOfdI/nf51MjMwyhFhkg==} + '@haverstack/core@0.8.0': + resolution: {integrity: sha512-QT4YvvpZRwHILJCUAsIA28Ed043/nTP97+njEFAr3fOb3B2kCGT14WCdobCjCy4y2uX6ziwch7/lzGlmfirI8Q==} + '@haverstack/record-adapter-sqljs@0.6.0': resolution: {integrity: sha512-+BHfNqW+dKg0Yy+io6cvqXjrOWugBXbPtQJWKTI9VNA6zfHngoWPpYIJ9n01dQ8LpsorXaRAG0ROO3Fev9mI6Q==} @@ -1515,6 +1518,8 @@ snapshots: '@haverstack/core@0.6.0': {} + '@haverstack/core@0.8.0': {} + '@haverstack/record-adapter-sqljs@0.6.0': dependencies: '@haverstack/core': 0.6.0 diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 2cd45bc..6460b61 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -1,12 +1,18 @@ import type { ErrorHandler } from 'hono'; import type { Logger } from 'pino'; import type { AppEnv } from '../types.js'; -import { StackPermissionError, StackValidationError, StackNotFoundError } from '@haverstack/core'; +import { + StackPermissionError, + StackValidationError, + StackNotFoundError, + StackConflictError, +} from '@haverstack/core'; export function errorMiddleware(logger: Logger): ErrorHandler { return (err, c) => { if (err instanceof StackNotFoundError) return c.json({ error: 'Not found' }, 404); if (err instanceof StackPermissionError) return c.json({ error: 'Forbidden' }, 403); + if (err instanceof StackConflictError) return c.json({ error: err.message }, 409); if (err instanceof StackValidationError) return c.json({ error: err.message, details: err.errors }, 422); logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error'); diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 160272d..d27684a 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { SYSTEM_TYPES } from '@haverstack/core'; +import { StackPermissionError } from '@haverstack/core'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth, requireOwner } from '../middleware/auth.js'; @@ -33,13 +33,11 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): const fileId = c.req.param('fileId'); const auth = c.get('auth'); - const accessible = await isAttachmentAccessible(fileId, auth?.entityId ?? null, ctx); - if (!accessible) return c.json({ error: 'Unauthorized' }, 401); - let data: Uint8Array; try { - data = await adapter.getAttachment(fileId); - } catch { + data = await stack.asEntity(auth?.entityId ?? null).getAttachment(fileId); + } catch (e) { + if (e instanceof StackPermissionError) return c.json({ error: 'Unauthorized' }, 401); return c.json({ error: 'Attachment not found' }, 404); } @@ -63,32 +61,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): // DELETE /attachments/:fileId app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => { const fileId = c.req.param('fileId'); - - // Find any _attachment@1 metadata record(s) for this file - const metaResult = await stack.query({ - filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, content: { fileId } }, - }); - - // If no metadata record, verify the bytes actually exist before proceeding - if (!metaResult.records.length) { - try { - await adapter.getAttachment(fileId); - } catch { - return c.json({ error: 'Attachment not found' }, 404); - } - } - - // Refuse if any record in the stack still references this file - const refResult = await stack.query({ filter: { attachmentFileId: fileId }, limit: 1 }); - if (refResult.records.length > 0) { - return c.json({ error: 'Attachment is still referenced by one or more records' }, 409); - } - - for (const record of metaResult.records) { - await stack.delete(record.id, { hard: true }); - } - - await adapter.deleteAttachment(fileId); + await stack.deleteAttachment(fileId); return c.body(null, 204); }); @@ -143,40 +116,3 @@ function resolveMimeType(declared: string, filename: string | undefined): string const ext = filename.split('.').pop()?.toLowerCase(); return (ext && EXTENSION_MIME[ext]) || declared; } - -/** - * An attachment is accessible if: - * 1. The requester is the stack owner, OR - * 2. The requester can read at least one Record that references the file, OR - * 3. The requester uploaded the file (owns its _attachment@1 record) and it - * hasn't been associated with any record yet. - */ -async function isAttachmentAccessible( - fileId: string, - requesterEntityId: string | null, - ctx: StackContext, -): Promise { - const { stack } = ctx; - if (requesterEntityId && requesterEntityId === stack.ownerEntityId) return true; - - const result = await stack.asEntity(requesterEntityId).query({ - filter: { attachmentFileId: fileId }, - limit: 1, - }); - if (result.records.length > 0) return true; - - // Unassociated file: check if the requester uploaded it - if (requesterEntityId) { - const uploadResult = await stack.query({ - filter: { - typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, - entityId: requesterEntityId, - content: { fileId }, - }, - limit: 1, - }); - if (uploadResult.records.length > 0) return true; - } - - return false; -}