feat: add shares schema, service layer, and lifecycle integration (#308)

- Add `shares` and `share_recipients` tables to Drizzle schema with indices
- Add migration 0010_shares.sql for shares/share_recipients tables
- Add `server/lib/password.ts` extracting scrypt hash/verify from auth.ts
  to eliminate duplicate crypto params across services
- Add `server/services/share.ts` implementing full CRUD + atomic counters:
  createShare, getShareByToken, incrementViews, incrementDownloadsAtomic
  (atomic SQL UPDATE), listSharesByCreator, revokeShare, cascadeDeleteByMatter
- Add `shared/schemas/share.ts` Zod validation schemas
- Export Share, ShareKind, ShareRecipient from shared/types
- Extend `purge.ts` to cascade-delete shares on matter hard-delete
- Add 38 integration tests and CF Workers atomic counter race tests

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

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-19 23:23:48 -04:00
committed by GitHub
parent a842acdb77
commit de4938e75c
13 changed files with 1043 additions and 19 deletions
+31
View File
@@ -0,0 +1,31 @@
CREATE TABLE `shares` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`kind` text NOT NULL,
`matter_id` text NOT NULL,
`org_id` text NOT NULL,
`creator_id` text NOT NULL,
`password_hash` text,
`expires_at` integer,
`download_limit` integer,
`views` integer DEFAULT 0 NOT NULL,
`downloads` integer DEFAULT 0 NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shares_token_unique` ON `shares` (`token`);
--> statement-breakpoint
CREATE INDEX `shares_creator_status_created_idx` ON `shares` (`creator_id`,`status`,`created_at`);
--> statement-breakpoint
CREATE TABLE `share_recipients` (
`id` text PRIMARY KEY NOT NULL,
`share_id` text NOT NULL,
`recipient_user_id` text,
`recipient_email` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `share_recipients_share_id_idx` ON `share_recipients` (`share_id`);
--> statement-breakpoint
CREATE INDEX `share_recipients_user_id_idx` ON `share_recipients` (`recipient_user_id`);
+8 -1
View File
@@ -64,6 +64,13 @@
"when": 1776200000000,
"tag": "0009_matters_active_name_uniq",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1745000000000,
"tag": "0010_shares",
"breakpoints": true
}
]
}
}
+10 -17
View File
@@ -1,4 +1,3 @@
import crypto from 'node:crypto'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin, organization, username } from 'better-auth/plugins'
@@ -15,6 +14,7 @@ import {
} from '../shared/oauth-providers'
import * as authSchema from './db/auth-schema'
import { orgQuotas, systemOptions } from './db/schema'
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
import type { Database } from './platform/interface'
import { sendEmail } from './services/email'
import { redeemInviteCode, validateInviteCode } from './services/invite'
@@ -22,23 +22,16 @@ import { findPersonalOrg } from './services/org'
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
// which blows past Cloudflare Workers' CPU budget and triggers error 1102.
// node:crypto.scryptSync is native (OpenSSL) on both Node and Workers
// (via nodejs_compat) and is counted as I/O rather than JS CPU time on CF.
const SCRYPT_PARAMS = { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 }
// We use node:crypto.scryptSync via server/lib/password.ts (native OpenSSL,
// counted as I/O rather than JS CPU time on CF Workers).
async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16)
const key = crypto.scryptSync(password.normalize('NFKC'), salt, 64, SCRYPT_PARAMS)
return `${salt.toString('hex')}:${key.toString('hex')}`
async function authHashPassword(password: string): Promise<string> {
return hashPassword(password)
}
async function verifyPassword({ hash, password }: { hash: string; password: string }): Promise<boolean> {
const [saltHex, keyHex] = hash.split(':')
if (!saltHex || !keyHex) {
throw new Error('stored password hash is malformed: expected "<salt>:<key>"')
}
const key = crypto.scryptSync(password.normalize('NFKC'), Buffer.from(saltHex, 'hex'), 64, SCRYPT_PARAMS)
return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex'))
async function authVerifyPassword({ hash, password }: { hash: string; password: string }): Promise<boolean> {
if (!hash.includes(':')) throw new Error('stored password hash is malformed: expected "<salt>:<key>"')
return verifyPasswordHash(hash, password)
}
async function loadProviderConfig(db: Database, providerId: string) {
@@ -145,8 +138,8 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
emailAndPassword: {
enabled: true,
password: {
hash: hashPassword,
verify: verifyPassword,
hash: authHashPassword,
verify: authVerifyPassword,
},
},
emailVerification: {
+36 -1
View File
@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const matters = sqliteTable('matters', {
id: text('id').primaryKey(),
@@ -79,3 +79,38 @@ export const activityEvents = sqliteTable('activity_events', {
metadata: text('metadata'), // JSON
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
})
export const shares = sqliteTable(
'shares',
{
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
kind: text('kind').notNull(), // 'landing' | 'direct'
matterId: text('matter_id').notNull(),
orgId: text('org_id').notNull(),
creatorId: text('creator_id').notNull(),
passwordHash: text('password_hash'),
expiresAt: integer('expires_at', { mode: 'timestamp' }),
downloadLimit: integer('download_limit'),
views: integer('views').notNull().default(0),
downloads: integer('downloads').notNull().default(0),
status: text('status').notNull().default('active'), // 'active' | 'revoked'
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('shares_creator_status_created_idx').on(t.creatorId, t.status, t.createdAt)],
)
export const shareRecipients = sqliteTable(
'share_recipients',
{
id: text('id').primaryKey(),
shareId: text('share_id').notNull(),
recipientUserId: text('recipient_user_id'),
recipientEmail: text('recipient_email'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
index('share_recipients_share_id_idx').on(t.shareId),
index('share_recipients_user_id_idx').on(t.recipientUserId),
],
)
+18
View File
@@ -0,0 +1,18 @@
import crypto from 'node:crypto'
// node:crypto.scryptSync is native OpenSSL — safe on Cloudflare Workers (nodejs_compat)
// and avoids the JS-CPU budget issue that the pure-JS @noble/hashes scrypt triggers.
const SCRYPT_PARAMS = { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 }
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16)
const key = crypto.scryptSync(password.normalize('NFKC'), salt, 64, SCRYPT_PARAMS)
return `${salt.toString('hex')}:${key.toString('hex')}`
}
export function verifyPassword(hash: string, plaintext: string): boolean {
const [saltHex, keyHex] = hash.split(':')
if (!saltHex || !keyHex) return false
const key = crypto.scryptSync(plaintext.normalize('NFKC'), Buffer.from(saltHex, 'hex'), 64, SCRYPT_PARAMS)
return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex'))
}
+5
View File
@@ -3,6 +3,7 @@ import type { Storage as S3Storage } from '../../shared/types'
import type { Database } from '../platform/interface'
import { decrementUsage, type Matter, purgeMatters } from './matter'
import { S3Service } from './s3'
import { cascadeDeleteByMatter } from './share'
import { getStorage } from './storage'
const s3 = new S3Service()
@@ -32,6 +33,10 @@ export async function purgeRecursively(db: Database, orgId: string, matters: Mat
if (storage && keys.length > 0) await s3.deleteObjects(storage, keys)
}
for (const m of matters) {
await cascadeDeleteByMatter(db, m.id)
}
await purgeMatters(
db,
orgId,
+116
View File
@@ -0,0 +1,116 @@
import { env } from 'cloudflare:workers'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import { createCloudflarePlatform } from '../platform/cloudflare'
import { cascadeDeleteByMatter, createShare, incrementDownloadsAtomic, revokeShare } from './share'
function buildDb() {
return createCloudflarePlatform(env).db
}
async function seedMatter(db: ReturnType<typeof buildDb>, orgId: string, dirtype = DirType.FILE) {
const now = new Date()
const matter = {
id: nanoid(),
orgId,
alias: nanoid(10),
name: `cf-test-${nanoid(6)}`,
type: dirtype !== DirType.FILE ? 'folder' : 'application/pdf',
size: 0,
dirtype,
parent: '',
object: dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`,
storageId: 'storage-1',
status: 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(matter)
return matter
}
// ─── Atomic counter race tests on D1 ─────────────────────────────────────────
describe('[CF] incrementDownloadsAtomic — race conditions on D1', () => {
it('enforces download limit under 50 concurrent calls (limit=10)', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-1',
kind: 'landing',
downloadLimit: 10,
})
const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id)))
const successCount = results.filter((r) => r.ok).length
expect(successCount).toBe(10)
})
it('returns ok=false for all calls when share is revoked', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-2',
kind: 'landing',
})
await revokeShare(db, share.id, 'cf-user-2')
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
expect(results.every((r) => !r.ok)).toBe(true)
})
it('returns ok=false for all calls when share is expired', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const pastDate = new Date(Date.now() - 5000)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-3',
kind: 'landing',
expiresAt: pastDate,
})
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
expect(results.every((r) => !r.ok)).toBe(true)
})
})
// ─── cascadeDeleteByMatter on D1 ─────────────────────────────────────────────
describe('[CF] cascadeDeleteByMatter on D1', () => {
it('removes shares and recipients atomically', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-cascade-user',
kind: 'landing',
recipients: [{ recipientEmail: 'cascade@example.com' }],
})
await cascadeDeleteByMatter(db, matter.id)
// Share should be gone (token lookup returns null due to deletion)
// We verify by checking the share lookup returns null
const { getShareByToken } = await import('./share')
expect(await getShareByToken(db, share.token)).toBeNull()
})
})
+555
View File
@@ -0,0 +1,555 @@
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import { createTestApp } from '../test/setup.js'
import {
cascadeDeleteByMatter,
createShare,
getShareByToken,
incrementDownloadsAtomic,
incrementViews,
isAccessibleByUser,
listShareRecipientUserIds,
listSharesByCreator,
revokeShare,
verifyPassword,
} from './share.js'
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function seedMatter(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
opts: { orgId: string; storageId?: string; dirtype?: number; status?: string },
) {
const now = new Date()
const matter = {
id: nanoid(),
orgId: opts.orgId,
alias: nanoid(10),
name: `test-${nanoid(6)}`,
type: opts.dirtype !== DirType.FILE ? 'folder' : 'application/pdf',
size: 0,
dirtype: opts.dirtype ?? DirType.FILE,
parent: '',
object: opts.dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`,
storageId: opts.storageId ?? 'storage-1',
status: opts.status ?? 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(matter)
return matter
}
// ─── createShare ─────────────────────────────────────────────────────────────
describe('createShare', () => {
it('creates a landing share for a file with password and recipients', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'user-1',
kind: 'landing',
password: 'secret123',
recipients: [{ recipientEmail: 'alice@example.com' }],
})
expect(share.id).toBeTruthy()
expect(share.token).toHaveLength(10)
expect(share.kind).toBe('landing')
expect(share.status).toBe('active')
expect(share.passwordHash).toBeTruthy()
expect(share.passwordHash).not.toBe('secret123')
})
it('creates a direct share for a file with no password and no recipients', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'user-1',
kind: 'direct',
})
expect(share.kind).toBe('direct')
expect(share.passwordHash).toBeNull()
})
it('throws DIRECT_NO_PASSWORD when direct share has a password', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
await expect(
createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'direct', password: 'oops' }),
).rejects.toThrow('DIRECT_NO_PASSWORD')
})
it('throws DIRECT_NO_RECIPIENTS when direct share has recipients', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
await expect(
createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'direct',
recipients: [{ recipientEmail: 'bob@example.com' }],
}),
).rejects.toThrow('DIRECT_NO_RECIPIENTS')
})
it('throws DIRECT_NO_FOLDER when direct share targets a folder', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const folder = await seedMatter(db, { orgId, dirtype: DirType.USER_FOLDER })
await expect(createShare(db, { matterId: folder.id, orgId, creatorId: 'u1', kind: 'direct' })).rejects.toThrow(
'DIRECT_NO_FOLDER',
)
})
it('throws MATTER_NOT_FOUND when matter does not exist', async () => {
const { db } = await createTestApp()
await expect(
createShare(db, { matterId: 'nonexistent', orgId: 'org-1', creatorId: 'u1', kind: 'landing' }),
).rejects.toThrow('MATTER_NOT_FOUND')
})
it('throws MATTER_NOT_FOUND when matter belongs to a different org', async () => {
const { db } = await createTestApp()
const matter = await seedMatter(db, { orgId: 'org-a' })
await expect(
createShare(db, { matterId: matter.id, orgId: 'org-b', creatorId: 'u1', kind: 'landing' }),
).rejects.toThrow('MATTER_NOT_FOUND')
})
it('sets downloadLimit and expiresAt when provided', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const expiresAt = new Date(Date.now() + 86400000)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
downloadLimit: 5,
expiresAt,
})
expect(share.downloadLimit).toBe(5)
expect(share.expiresAt).toEqual(expiresAt)
})
})
// ─── getShareByToken ──────────────────────────────────────────────────────────
describe('getShareByToken', () => {
it('returns full data including matter and recipients', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
recipients: [{ recipientUserId: 'user-42' }],
})
const result = await getShareByToken(db, share.token)
expect(result).not.toBeNull()
expect(result!.share.id).toBe(share.id)
expect(result!.matter.id).toBe(matter.id)
expect(result!.recipients).toHaveLength(1)
expect(result!.recipients[0].recipientUserId).toBe('user-42')
})
it('returns null when token does not exist', async () => {
const { db } = await createTestApp()
expect(await getShareByToken(db, 'nonexistent')).toBeNull()
})
it('returns null when share status is revoked', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
await revokeShare(db, share.id, 'u1')
expect(await getShareByToken(db, share.token)).toBeNull()
})
it('returns null when underlying matter is trashed', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId, status: 'trashed' })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
expect(await getShareByToken(db, share.token)).toBeNull()
})
it('returns data again after matter is restored from trash', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId, status: 'active' })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
// Trash it
await db.update(matters).set({ status: 'trashed' }).where(eq(matters.id, matter.id))
expect(await getShareByToken(db, share.token)).toBeNull()
// Restore it
await db.update(matters).set({ status: 'active' }).where(eq(matters.id, matter.id))
const result = await getShareByToken(db, share.token)
expect(result).not.toBeNull()
expect(result!.share.id).toBe(share.id)
})
})
// ─── verifyPassword ───────────────────────────────────────────────────────────
describe('verifyPassword', () => {
it('returns true for the correct password', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
password: 'correct-password',
})
expect(verifyPassword(share, 'correct-password')).toBe(true)
})
it('returns false for the wrong password', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
password: 'correct-password',
})
expect(verifyPassword(share, 'wrong-password')).toBe(false)
})
it('returns false when share has no password hash', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
expect(verifyPassword(share, 'any-password')).toBe(false)
})
})
// ─── isAccessibleByUser ───────────────────────────────────────────────────────
describe('isAccessibleByUser', () => {
it('returns true when userId is in recipients', () => {
const now = new Date()
const recipients = [{ id: '1', shareId: 's1', recipientUserId: 'user-42', recipientEmail: null, createdAt: now }]
expect(isAccessibleByUser(recipients, 'user-42')).toBe(true)
})
it('returns false when userId is not in recipients', () => {
const now = new Date()
const recipients = [{ id: '1', shareId: 's1', recipientUserId: 'user-99', recipientEmail: null, createdAt: now }]
expect(isAccessibleByUser(recipients, 'user-42')).toBe(false)
})
it('returns false for empty recipients list', () => {
expect(isAccessibleByUser([], 'user-42')).toBe(false)
})
})
// ─── incrementViews ───────────────────────────────────────────────────────────
describe('incrementViews', () => {
it('increments view count correctly', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
await incrementViews(db, share.id)
await incrementViews(db, share.id)
const result = await getShareByToken(db, share.token)
expect(result!.share.views).toBe(2)
})
})
// ─── incrementDownloadsAtomic ─────────────────────────────────────────────────
describe('incrementDownloadsAtomic', () => {
it('returns ok=true and incremented count on success', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
downloadLimit: 10,
})
const result = await incrementDownloadsAtomic(db, share.id)
expect(result.ok).toBe(true)
expect(result.downloads).toBe(1)
})
it('enforces download limit — exactly limit calls succeed with concurrent calls', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
downloadLimit: 10,
})
const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id)))
const successCount = results.filter((r) => r.ok).length
expect(successCount).toBe(10)
})
it('returns ok=false when share is revoked', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
await revokeShare(db, share.id, 'u1')
const result = await incrementDownloadsAtomic(db, share.id)
expect(result.ok).toBe(false)
})
it('returns ok=false when share is expired', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const pastDate = new Date(Date.now() - 1000)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
expiresAt: pastDate,
})
const result = await incrementDownloadsAtomic(db, share.id)
expect(result.ok).toBe(false)
})
it('allows unlimited downloads when downloadLimit is null', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
expect(results.every((r) => r.ok)).toBe(true)
expect(results[4].downloads).toBe(5)
})
})
// ─── listSharesByCreator ──────────────────────────────────────────────────────
describe('listSharesByCreator', () => {
it('returns empty result when no shares exist for creator', async () => {
const { db } = await createTestApp()
const result = await listSharesByCreator(db, 'unknown-creator', { page: 1, pageSize: 20 })
expect(result).toEqual({ items: [], total: 0 })
})
it('returns shares with matterName and matterType joined', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
const result = await listSharesByCreator(db, 'u1', { page: 1, pageSize: 20 })
expect(result.total).toBe(1)
expect(result.items[0].matterName).toBe(matter.name)
expect(result.items[0].matterType).toBe(matter.type)
})
it('paginates correctly', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
for (let i = 0; i < 5; i++) {
const m = await seedMatter(db, { orgId })
await createShare(db, { matterId: m.id, orgId, creatorId: 'paginator', kind: 'landing' })
}
const page1 = await listSharesByCreator(db, 'paginator', { page: 1, pageSize: 3 })
expect(page1.total).toBe(5)
expect(page1.items).toHaveLength(3)
const page2 = await listSharesByCreator(db, 'paginator', { page: 2, pageSize: 3 })
expect(page2.items).toHaveLength(2)
})
it('returns shares across multiple orgs for the same creator', async () => {
const { db } = await createTestApp()
const m1 = await seedMatter(db, { orgId: 'org-x' })
const m2 = await seedMatter(db, { orgId: 'org-y' })
await createShare(db, { matterId: m1.id, orgId: 'org-x', creatorId: 'cross-org-user', kind: 'landing' })
await createShare(db, { matterId: m2.id, orgId: 'org-y', creatorId: 'cross-org-user', kind: 'landing' })
const result = await listSharesByCreator(db, 'cross-org-user', { page: 1, pageSize: 20 })
expect(result.total).toBe(2)
})
it('filters by status when provided', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const m1 = await seedMatter(db, { orgId })
const m2 = await seedMatter(db, { orgId })
await createShare(db, { matterId: m1.id, orgId, creatorId: 'u-filter', kind: 'landing' })
const s2 = await createShare(db, { matterId: m2.id, orgId, creatorId: 'u-filter', kind: 'landing' })
await revokeShare(db, s2.id, 'u-filter')
const active = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'active' })
expect(active.total).toBe(1)
const revoked = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'revoked' })
expect(revoked.total).toBe(1)
})
})
// ─── revokeShare ─────────────────────────────────────────────────────────────
describe('revokeShare', () => {
it('flips share status to revoked', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
await revokeShare(db, share.id, 'u1')
expect(await getShareByToken(db, share.token)).toBeNull()
})
it('throws when non-creator tries to revoke', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
await expect(revokeShare(db, share.id, 'other-user')).rejects.toThrow()
})
it('throws when share does not exist', async () => {
const { db } = await createTestApp()
await expect(revokeShare(db, 'nonexistent', 'u1')).rejects.toThrow()
})
})
// ─── listShareRecipientUserIds ────────────────────────────────────────────────
describe('listShareRecipientUserIds', () => {
it('returns user IDs from recipients', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
recipients: [{ recipientUserId: 'user-a' }, { recipientUserId: 'user-b' }, { recipientEmail: 'c@example.com' }],
})
const ids = await listShareRecipientUserIds(db, share.id)
expect(ids.sort()).toEqual(['user-a', 'user-b'].sort())
})
it('returns empty array when no user recipients', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
recipients: [{ recipientEmail: 'email@example.com' }],
})
const ids = await listShareRecipientUserIds(db, share.id)
expect(ids).toEqual([])
})
})
// ─── cascadeDeleteByMatter ────────────────────────────────────────────────────
describe('cascadeDeleteByMatter', () => {
it('removes all shares and recipients for a matter', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'u1',
kind: 'landing',
recipients: [{ recipientUserId: 'user-x' }],
})
await cascadeDeleteByMatter(db, matter.id)
expect(await getShareByToken(db, share.token)).toBeNull()
const userIds = await listShareRecipientUserIds(db, share.id)
expect(userIds).toEqual([])
})
it('is a no-op when matter has no shares', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
await expect(cascadeDeleteByMatter(db, matter.id)).resolves.toBeUndefined()
})
it('removes multiple shares for the same matter', async () => {
const { db } = await createTestApp()
const orgId = nanoid()
const matter = await seedMatter(db, { orgId })
const s1 = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
const s2 = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u2', kind: 'landing' })
await cascadeDeleteByMatter(db, matter.id)
expect(await getShareByToken(db, s1.token)).toBeNull()
expect(await getShareByToken(db, s2.token)).toBeNull()
})
})
+180
View File
@@ -0,0 +1,180 @@
import { and, count, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { DirType } from '../../shared/constants'
import type { CreateShareInput } from '../../shared/schemas/share'
import { matters, shareRecipients, shares } from '../db/schema'
import { hashPassword, verifyPassword as verifyPasswordHash } from '../lib/password'
import type { Database } from '../platform/interface'
export type Share = typeof shares.$inferSelect
export type ShareRecipient = typeof shareRecipients.$inferSelect
export type ShareWithMatter = Share & { matterName: string; matterType: string }
export function verifyPassword(share: Share, plaintext: string): boolean {
if (!share.passwordHash) return false
return verifyPasswordHash(share.passwordHash, plaintext)
}
export function isAccessibleByUser(recipients: ShareRecipient[], userId: string): boolean {
return recipients.some((r) => r.recipientUserId === userId)
}
export async function createShare(db: Database, input: CreateShareInput): Promise<Share> {
if (input.kind === 'direct' && input.password) throw new Error('DIRECT_NO_PASSWORD')
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
throw new Error('DIRECT_NO_RECIPIENTS')
const matter = await db
.select()
.from(matters)
.where(and(eq(matters.id, input.matterId), eq(matters.orgId, input.orgId)))
.then((rows) => rows[0] ?? null)
if (!matter) throw new Error('MATTER_NOT_FOUND')
if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new Error('DIRECT_NO_FOLDER')
const now = new Date()
const share: Share = {
id: nanoid(),
token: nanoid(10),
kind: input.kind,
matterId: input.matterId,
orgId: input.orgId,
creatorId: input.creatorId,
passwordHash: input.password ? hashPassword(input.password) : null,
expiresAt: input.expiresAt ?? null,
downloadLimit: input.downloadLimit ?? null,
views: 0,
downloads: 0,
status: 'active',
createdAt: now,
}
await db.insert(shares).values(share)
if (input.recipients && input.recipients.length > 0) {
const recipientRows: ShareRecipient[] = input.recipients.map((r) => ({
id: nanoid(),
shareId: share.id,
recipientUserId: r.recipientUserId ?? null,
recipientEmail: r.recipientEmail ?? null,
createdAt: now,
}))
await db.insert(shareRecipients).values(recipientRows)
}
return share
}
export async function getShareByToken(
db: Database,
token: string,
): Promise<{ share: Share; matter: typeof matters.$inferSelect; recipients: ShareRecipient[] } | null> {
const rows = await db
.select({ share: shares, matter: matters })
.from(shares)
.innerJoin(matters, eq(shares.matterId, matters.id))
.where(eq(shares.token, token))
const row = rows[0]
if (!row) return null
if (row.share.status === 'revoked') return null
if (row.matter.status === 'trashed') return null
const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id))
return { share: row.share, matter: row.matter, recipients }
}
export async function incrementViews(db: Database, shareId: string): Promise<void> {
await db
.update(shares)
.set({ views: sql`${shares.views} + 1` })
.where(eq(shares.id, shareId))
}
export async function incrementDownloadsAtomic(
db: Database,
shareId: string,
): Promise<{ ok: boolean; downloads: number }> {
const nowSecs = Math.floor(Date.now() / 1000)
const result = await db
.update(shares)
.set({ downloads: sql`${shares.downloads} + 1` })
.where(
and(
eq(shares.id, shareId),
eq(shares.status, 'active'),
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
),
)
.returning({ downloads: shares.downloads })
if (result.length === 1) {
return { ok: true, downloads: result[0].downloads }
}
const current = await db.select({ downloads: shares.downloads }).from(shares).where(eq(shares.id, shareId))
if (!current[0]) throw new Error('SHARE_NOT_FOUND')
return { ok: false, downloads: current[0].downloads }
}
export async function listSharesByCreator(
db: Database,
creatorId: string,
opts: { page: number; pageSize: number; status?: string },
): Promise<{ items: ShareWithMatter[]; total: number }> {
const conditions = [eq(shares.creatorId, creatorId)]
if (opts.status) conditions.push(eq(shares.status, opts.status))
const where = and(...conditions)
const [countRow] = await db.select({ count: count() }).from(shares).where(where)
const total = countRow?.count ?? 0
const offset = (opts.page - 1) * opts.pageSize
const rows = await db
.select({ share: shares, matterName: matters.name, matterType: matters.type })
.from(shares)
.leftJoin(matters, eq(shares.matterId, matters.id))
.where(where)
.orderBy(desc(shares.createdAt))
.limit(opts.pageSize)
.offset(offset)
const items = rows.map(({ share, matterName, matterType }) => ({
...share,
matterName: matterName ?? '',
matterType: matterType ?? '',
}))
return { items, total }
}
export async function revokeShare(db: Database, shareId: string, creatorId: string): Promise<void> {
const result = await db
.update(shares)
.set({ status: 'revoked' })
.where(and(eq(shares.id, shareId), eq(shares.creatorId, creatorId)))
.returning({ id: shares.id })
if (result.length === 0) throw new Error('SHARE_NOT_FOUND_OR_FORBIDDEN')
}
export async function listShareRecipientUserIds(db: Database, shareId: string): Promise<string[]> {
const rows = await db
.select({ userId: shareRecipients.recipientUserId })
.from(shareRecipients)
.where(and(eq(shareRecipients.shareId, shareId), isNotNull(shareRecipients.recipientUserId)))
return rows.map((r) => r.userId as string)
}
export async function cascadeDeleteByMatter(db: Database, matterId: string): Promise<void> {
const shareRows = await db.select({ id: shares.id }).from(shares).where(eq(shares.matterId, matterId))
if (shareRows.length === 0) return
const shareIds = shareRows.map((r) => r.id)
await db.delete(shareRecipients).where(inArray(shareRecipients.shareId, shareIds))
await db.delete(shares).where(inArray(shares.id, shareIds))
}
+25
View File
@@ -168,6 +168,31 @@ const APP_SCHEMA_SQL = `
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id);
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
matter_id TEXT NOT NULL,
org_id TEXT NOT NULL,
creator_id TEXT NOT NULL,
password_hash TEXT,
expires_at INTEGER,
download_limit INTEGER,
views INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
CREATE TABLE IF NOT EXISTS share_recipients (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
recipient_user_id TEXT,
recipient_email TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id);
CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id);
`
export async function createTestApp() {
+2
View File
@@ -1,5 +1,7 @@
import { z } from 'zod'
export type { CreateShareInput, ShareKind } from './share'
export { createShareSchema, listSharesQuerySchema, shareKindSchema, shareRecipientSchema } from './share'
export type { CreateStorageInput, UpdateStorageInput } from './storage'
export { createStorageSchema, updateStorageSchema } from './storage'
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod'
export const shareKindSchema = z.enum(['landing', 'direct'])
export type ShareKind = z.infer<typeof shareKindSchema>
export const shareRecipientSchema = z.object({
recipientUserId: z.string().optional(),
recipientEmail: z.string().email().optional(),
})
export const createShareSchema = z.object({
matterId: z.string().min(1),
orgId: z.string().min(1),
creatorId: z.string().min(1),
kind: shareKindSchema,
password: z.string().optional(),
expiresAt: z.date().optional(),
downloadLimit: z.number().int().positive().optional(),
recipients: z.array(shareRecipientSchema).optional(),
})
export type CreateShareInput = z.infer<typeof createShareSchema>
export const listSharesQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().default(20),
status: z.enum(['active', 'revoked']).optional(),
})
+28
View File
@@ -90,6 +90,34 @@ export interface PaginatedResponse<T> {
pageSize: number
}
import type { ShareKind as _ShareKind } from '../schemas/share'
export type { ShareKind } from '../schemas/share'
export interface Share {
id: string
token: string
kind: _ShareKind
matterId: string
orgId: string
creatorId: string
passwordHash: string | null
expiresAt: Date | null
downloadLimit: number | null
views: number
downloads: number
status: 'active' | 'revoked'
createdAt: Date
}
export interface ShareRecipient {
id: string
shareId: string
recipientUserId: string | null
recipientEmail: string | null
createdAt: Date
}
export interface ActivityEvent {
id: string
orgId: string