diff --git a/apps/web/.env.example b/apps/web/.env.example index 6d55c54b0..8985b5166 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -98,4 +98,5 @@ LOG_LEVEL=info # Optional tuning. # AUDIT_MAX_ATTEMPTS=12 # SESSION_DAYS=30 +# EPK_VIEW_RETENTION_DAYS=90 # days a press kit view receipt is kept # ALLOW_PRIVATE_NETWORK=false diff --git a/apps/web/README.md b/apps/web/README.md index 5a430171a..63822ba92 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -32,12 +32,13 @@ All data lives on the AT Protocol. Public profiles are ordinary atproto records; - A mix can carry a tracklist: a mix report exported from [locallibrary](../locallibrary) (`locallibrary mix … --export`), shown at `/projects//mixes/` with the waveform and track lanes, tempo, key moves on the Camelot wheel, and every transition. `/mixes/preview` renders an export locally without uploading it. - A mix can also link a set a manager published with locallibrary (`locallibrary sets publish`), picked from a list on the edit form. The profile shows when and where it was played, and says so if the set changed after it was linked. - Sets with a recording play on the site, with the tracklist following along and a track's start to jump to. The server picks what plays when someone presses Play (`/sets///player.json`): the set's `recording` record first (an `fm.plyr.track`, whose file comes from plyr.fm's CDN, or plyr's own player when the track is gated; or a `computer.localnetwork.dj.recording` on the DJ's PDS, streamed through `/media/audio//`), then its links (archive.org downloads play directly, SoundCloud, Mixcloud, YouTube and plyr.fm in their own players). Audio the page plays itself always wins over a platform's player. SoundCloud's and Mixcloud's widget APIs let the tracklist follow those players too. `/sets///chapters.json` serves the tracklist as Podcasting 2.0 JSON chapters. -- Private booking details (email, phone, rates, tech rider, hospitality) visible only to signed-in members. +- Private booking details (email, phone, rates, tech rider, hospitality), shown to the project's managers and staff on its pages, and to everyone else only inside a press kit they've been let into, so each look at them leaves a receipt. - The project form and the booking inquiry keep unsent changes as a draft in the browser (`localStorage`, per account), so a reload or a trip to another page doesn't lose them. Files have to be picked again, and the draft goes once the form is sent, discarded, or back to its saved values, or its account signs out (the sign-out response sets an `ln_signed_out` cookie naming the account, and the next page load drops its drafts, so it works even if Sign out was pressed before the page's scripts ran). - A project page shows how its DJs play: the playing stats and recent sets each manager publishes from locallibrary (`computer.localnetwork.dj.profile` and `dj.set`), read from HappyView when the page loads and streamed in, so a slow answer never holds the page up. The app stores none of it; a few minutes' cache in memory keeps busy pages from asking again. - Press kits as records, which belong to the project, so all its managers share control of them. A project has up to 20 (`/dashboard/projects//press-kits/new`), one per kind of booking. Each is a public `computer.localnetwork.epk` record in the root account's repo, holding the mixes and photos shown to everyone, plus a space of its own under the root holding the rest: a full bio, on-request mixes and photos, press quotes, contact and rider. Managers edit at `/dashboard/projects//press-kits/` through drafts kept in the project's backstage, a space under the root that only its current managers read and write, so each sees the others' work in progress and whether it's behind the published version. A draft saves only over the published version its form was opened on (otherwise the manager is asked to reload). Publishing a draft applies only the fields it changed, shows each field another manager published since next to the version the draft started from and lets the manager pick which of theirs to publish, and copies its photos into the root's repo so they outlive a manager leaving. People ask for access from `/projects//press-kits/`, with the form at `/dashboard/access//`; the rules approve them at once, send them for review, or don't take requests, per group (people they've booked with, people any of the managers follows on Bluesky, promoters, anyone signed in), with seven presets from open to closed. Any manager answers requests and adds or removes people at `/dashboard/projects//press-kits//access`; the root writes each grant record and adds the person to the space. The editor has a press-photo checklist (3 or more photos, every orientation, 3,000 px, credited). - Press kits made before the root held them stay in their maker's repo and space until the maker next opens the dashboard, which moves each to the root with the same key: photos, private part, grants and open requests first, the public record last, then the old record and space go with the maker's session, the only one that can delete them. A move that fails part way finishes on a later visit. -- `/projects//press-kits` lists a project's press kits, then one put together from the profile, mixes, stats, sets and (for members) booking details. Both print or save as a PDF on white. Links from before press kits moved to the project (`/projects//epk`, `/epk//` and its photos) redirect there, since they sit in emails. +- Receipts of press kit views. Each time someone let into a press kit opens it (once per person and press kit every 30 minutes at most), the audit log records a view with the root as its actor and an HMAC of the viewer's DID under a random salt, never the DID. The DID and the salt stay in the app's database, and the project's managers see who viewed which press kit, and when, at `/dashboard/projects//press-kits`, where any of them can delete one. Receipts expire after `EPK_VIEW_RETENTION_DAYS` (90 by default), checked every hour. Deleting one drops the DID and the salt, so the hash chain still verifies but no longer says who viewed. Only views through the site get receipts: the people let in are members of the press kit's space, so they can still read its private part (not the booking details) with another atproto client. +- `/projects//press-kits` lists a project's press kits, then one put together from the profile, mixes, stats, sets and (for its managers) booking details. Both print or save as a PDF on white. Links from before press kits moved to the project (`/projects//epk`, `/epk//` and its photos) redirect there, since they sit in emails. - Every claim and change is reviewed by a moderator, so profiles can't be hijacked. - Managers announce upcoming shows (`/dashboard/projects//shows/new`): name, date and times on the venue's clock, venue, city, and a ticket link. Each is a [`community.lexicon.calendar.event`](https://github.com/lexicon-community/lexicon) in the manager's own repo, so calendar apps like Smoke Signal can read it, plus a `computer.localnetwork.show` with the same record key that ties it to the project and copies what listings need. Times carry the venue's UTC offset, found from its coordinates, so the city has to be picked from the search. A confirmed booking thread offers "Announce this show", filled in from its show details. The announcing manager can edit (re-copying the event, marked rescheduled when the start moves), cancel, or delete a show. A new show's event and show are written together. The edit form carries the versions of the show and event it shows, and the save writes the event, then the show, each only over those versions, so a change made elsewhere since the form opened asks the manager to reload. A delete removes the event, then the show, so one that fails partway leaves the show listed to delete again. The project's dashboard page says when another app changed the event since. @@ -82,7 +83,7 @@ All data lives on the AT Protocol. Public profiles are ordinary atproto records; | Decisions | Root's repo in the moderation space | Root | Staff | | Staff roles (`computer.localnetwork.staff.role`) | Root's repo in the staff space | Root | Staff | | Verifications (`app.bsky.graph.verification`) | Root account's public repo | Root, after admin action | Everyone, via HappyView | -| Private booking details | Root's repo in the industry space | Root | Any signed-in account | +| Private booking details | Root's repo in the industry space | Root | Staff (others via app) | | Audit entries (`computer.localnetwork.audit.entry`) | Both the actor's repo and the root's repo in the audit space | Actor and root | Staff | | Booking threads | One booking space per thread; each party writes in their own repo | Promoter and managers | Thread members only | | Planner | A personal planner space under the promoter's own DID | The promoter | The promoter only | @@ -91,6 +92,7 @@ All data lives on the AT Protocol. Public profiles are ordinary atproto records; | Follows (`computer.localnetwork.follow`) | The follower's public repo | The follower | Everyone, via HappyView | | Press kits' private part, requests and grants | One space per press kit under the root | Root (content, grants), askers | Managers, granted | | Press kit drafts (`computer.localnetwork.epk.draft`) | Each manager's repo in the project's backstage space | Managers | The project's managers | +| Press kit view receipts | The app's database only | The app | The project's managers | | People in acts, parties and venues (`computer.localnetwork.people.*`) | One space per performer, party or venue under the root | Root, for its people or staff | Its people, staff | | Directory listing status (`computer.localnetwork.listing.status`) | The same space | Root, after staff action | Its people, staff | | Bluesky display names (`app.bsky.actor.profile`) | The account's own public repo | The account, in any Bluesky app | Admins, via HappyView | @@ -99,17 +101,17 @@ All data lives on the AT Protocol. Public profiles are ordinary atproto records; Space policies (`com.atproto.simplespace`): -| Space | Read policy | Write policy | -| ----------------------- | ---------------------------------- | ------------------------------------------- | -| audit | member list (staff) | public (everyone records their own entries) | -| moderation | member list (staff) | public (everyone can file requests) | -| staff | member list (staff) | member list (root) | -| industry | public (any authenticated account) | member list (root) | -| booking (per thread) | member list (promoter + managers) | member list | -| planner (personal) | member list (owner) | member list | -| epk (per press kit) | member list (managers, granted) | public (anyone can ask; only root's count) | -| backstage (per project) | member list (managers) | member list (managers) | -| performer, party, venue | member list (its people) | member list (people who write) | +| Space | Read policy | Write policy | +| ----------------------- | --------------------------------- | ------------------------------------------- | +| audit | member list (staff) | public (everyone records their own entries) | +| moderation | member list (staff) | public (everyone can file requests) | +| staff | member list (staff) | member list (root) | +| industry | member list (staff) | member list (root) | +| booking (per thread) | member list (promoter + managers) | member list | +| planner (personal) | member list (owner) | member list | +| epk (per press kit) | member list (managers, granted) | public (anyone can ask; only root's count) | +| backstage (per project) | member list (managers) | member list (managers) | +| performer, party, venue | member list (its people) | member list (people who write) | ```mermaid flowchart LR @@ -142,7 +144,7 @@ Every action (sign-in/out, submissions, revisions, withdrawals, decisions, direc 2. In the actor's own repo inside the root's audit space, written with the actor's OAuth session. 3. In the root account's repo in the same space, with the same record key and the chain fields (`seq`, `prev`, `hash`). -Failed writes retry with exponential backoff. The syncer reads both copies back from the network and marks them _verified_ only if their content matches the chained entry; a copy edited later is flagged _failed_. If the local database is lost, the log is rebuilt from the root's chained copies. Denied attempts (e.g. a non-moderator trying to approve) are recorded with outcome `denied`. Booking message contents are never copied into the audit log, only metadata. +Failed writes retry with exponential backoff. The syncer reads both copies back from the network and marks them _verified_ only if their content matches the chained entry; a copy edited later is flagged _failed_. If the local database is lost, the log is rebuilt from the root's chained copies. Denied attempts (e.g. a non-moderator trying to approve) are recorded with outcome `denied`. Booking message contents are never copied into the audit log, only metadata. A press kit view (`epk.view`) names no viewer: its details hold an HMAC of the viewer's DID under a salt only the app's receipt has, so deleting or expiring the receipt leaves the entry, and the chain, as they were. Accounts whose PDS doesn't run the spaces alpha can still sign in: the root files their requests and messages on their behalf (`onBehalfOf`), and their audit entries are marked "no spaces support". diff --git a/apps/web/src/lib/components/BookingDetails.svelte b/apps/web/src/lib/components/BookingDetails.svelte index 21cd3e8ac..0daac13f1 100644 --- a/apps/web/src/lib/components/BookingDetails.svelte +++ b/apps/web/src/lib/components/BookingDetails.svelte @@ -2,17 +2,17 @@ import type { ProjectDetails } from '#lib/types.ts'; /** - * A project's private booking details, for signed-in members: contacts, rates, the tech rider - * and hospitality. Signed out, a prompt to sign in; with nothing on file, a way to reach the - * managers instead. + * A project's private booking details: contacts, rates, the tech rider and hospitality. `details` is null for anyone + * who isn't a manager, staff, or reading one of the project's press kits with access, since every other look at them + * goes through a press kit, which records it. With nothing on file, a way to reach the managers instead. */ - let { details, signedIn, claimed, loginHref }: { details: ProjectDetails | null; signedIn: boolean; claimed: boolean; loginHref: string } = $props(); + let { details, claimed, kitsHref }: { details: ProjectDetails | null; claimed: boolean; kitsHref?: string } = $props(); const filled = $derived(details && Object.values(details).some(Boolean) ? details : null); -{#if !signedIn} -

Sign in to see booking contacts, rates and the tech rider.

+{#if !details} +

Contacts, rates and the tech rider come with a press kit.{#if kitsHref}{' '}See the press kits{/if}

{:else if filled}
{#if filled.bookingEmail}{/if} diff --git a/apps/web/src/lib/components/epk/EpkViewList.stories.svelte b/apps/web/src/lib/components/epk/EpkViewList.stories.svelte new file mode 100644 index 000000000..1e9257381 --- /dev/null +++ b/apps/web/src/lib/components/epk/EpkViewList.stories.svelte @@ -0,0 +1,58 @@ + + + + + + + + +() }} /> + +() }} /> + + + + + + + + + + + + diff --git a/apps/web/src/lib/components/epk/EpkViewList.svelte b/apps/web/src/lib/components/epk/EpkViewList.svelte new file mode 100644 index 000000000..d45c27809 --- /dev/null +++ b/apps/web/src/lib/components/epk/EpkViewList.svelte @@ -0,0 +1,47 @@ + + +
+
+

Views

+

Who opened the rest of a press kit. Kept for {retentionDays} days.

+
+ {#await views} +
+
+
+
+ {:then list} + {#if list.length} +
    + {#each list as view (view.id)} +
  • +

    + {' '}viewed{' '}{view.epk.title} + +

    +
    + + +
    +
  • + {/each} +
+ {:else} +

No views yet.

+ {/if} + {:catch} + + {/await} +
diff --git a/apps/web/src/lib/components/profile.svelte.test.ts b/apps/web/src/lib/components/profile.svelte.test.ts index 8a78e8f91..740ebc3b1 100644 --- a/apps/web/src/lib/components/profile.svelte.test.ts +++ b/apps/web/src/lib/components/profile.svelte.test.ts @@ -108,16 +108,18 @@ describe('ProjectFacts', () => { }); describe('BookingDetails', () => { - const props = { details: null, signedIn: true, claimed: true, loginHref: '/login' }; + const props = { details: {}, claimed: true }; - it('asks a visitor to sign in', async () => { - await render(BookingDetails, { ...props, signedIn: false, details: { bookingEmail: 'neon@example.com' } }); + it('points everyone else to the press kits, where the details come with access', async () => { + await render(BookingDetails, { ...props, details: null, kitsHref: '/projects/neon/press-kits' }); - await expect.element(page.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/login'); - await expect.element(page.getByText('neon@example.com')).not.toBeInTheDocument(); + await expect.element(page.getByText(/come with a press kit/)).toBeInTheDocument(); + await expect + .element(page.getByRole('link', { name: 'See the press kits' })) + .toHaveAttribute('href', '/projects/neon/press-kits'); }); - it('shows a member what is on file', async () => { + it('shows what is on file to someone who may see it', async () => { await render(BookingDetails, { ...props, details: { bookingEmail: 'neon@example.com', techRider: '2x CDJ-3000' } }); await expect diff --git a/apps/web/src/lib/epk.ts b/apps/web/src/lib/epk.ts index 365657750..616048dce 100644 --- a/apps/web/src/lib/epk.ts +++ b/apps/web/src/lib/epk.ts @@ -238,6 +238,15 @@ export type EpkGrantView = { createdAt: string; }; +/** A receipt of someone with access viewing a press kit's private part, for its project's managers. */ +export type EpkViewReceipt = { + /** The audit entry recording the view. */ + id: string; + viewer: AccountView; + epk: { uri: string; rkey: string; title: string }; + viewedAt: string; +}; + /** Where the viewer stands with a press kit's private part. */ export type EpkAccessState = | { kind: 'manager' } diff --git a/apps/web/src/lib/server/app.ts b/apps/web/src/lib/server/app.ts index e2881e438..d8fe84fe6 100644 --- a/apps/web/src/lib/server/app.ts +++ b/apps/web/src/lib/server/app.ts @@ -35,6 +35,7 @@ import { BookingService } from './services/bookings'; import { BugReportService } from './services/bug-reports'; import { DjService } from './services/dj'; import { EpkService } from './services/epk'; +import { EpkViews } from './services/epk-views'; import { FollowService } from './services/follows'; import { PlannerService } from './services/planner'; import { ProjectAdmin } from './services/project-admin'; @@ -234,6 +235,7 @@ export async function createApp(options: CreateAppOptions) { const epks = new EpkService(core, projects, indexedRepo, (identifier) => identity.resolveIdentifier(identifier)); + const epkViews = new EpkViews(core, epks, config.epkViewRetentionDays); const shows = new ShowService(core, projects, happyView, indexedRepo); const follows = new FollowService(core, happyView); const verifications = new VerificationService(core, indexedRepo, projects, identity); @@ -345,6 +347,7 @@ export async function createApp(options: CreateAppOptions) { recordings, supports, epks, + epkViews, shows, follows, verifications, @@ -486,6 +489,7 @@ export async function createApp(options: CreateAppOptions) { // Rewrites listings that drifted from their spaces: one listRecords of the root's listings, and writes only // where one differs. every(60 * 60_000, () => listings.reconcile(), 'listings'); + every(60 * 60_000, () => epkViews.expire(), 'epk-view-expiry'); every(60 * 60_000, () => Promise.all([notifications.prune(), outbox.prune()]), 'inbox-retention'); every(15_000, () => outbox.processDue(), 'notification-delivery'); void outbox diff --git a/apps/web/src/lib/server/audit/log.ts b/apps/web/src/lib/server/audit/log.ts index 64374475a..27f565491 100644 --- a/apps/web/src/lib/server/audit/log.ts +++ b/apps/web/src/lib/server/audit/log.ts @@ -41,6 +41,8 @@ export const AUDIT_ACTIONS = [ 'epk.grant', 'epk.decline', 'epk.revoke', + 'epk.view', + 'epk.forget', 'show.save', 'show.delete', 'follow.create', diff --git a/apps/web/src/lib/server/bootstrap.test.ts b/apps/web/src/lib/server/bootstrap.test.ts index ee03dc73e..f9bd13d4b 100644 --- a/apps/web/src/lib/server/bootstrap.test.ts +++ b/apps/web/src/lib/server/bootstrap.test.ts @@ -26,6 +26,19 @@ describe('ensureRootSpaces', () => { for (const space of spaces) expect(await t.audit.list({ action: 'system.space', subject: space })).toHaveLength(2); }); + it('closes an industry space that any signed-in account could read to staff only', async () => { + const industry = t.net.spaces.get(t.spaces.industry)!; + industry.readPolicy = { $type: 'com.atproto.simplespace.defs#publicPolicy' }; + + await ensureRootSpaces(t.core); + + expect(industry.readPolicy.$type).toBe('com.atproto.simplespace.defs#memberListPolicy'); + expect(await t.audit.list({ action: 'system.space', subject: t.spaces.industry })).toMatchObject([ + { summary: 'Corrected the industry space policies' }, + { summary: 'Created the industry space' } + ]); + }); + it("watches the root's performer, party and venue spaces again", async () => { const actor = await t.viewer('did:plc:ada'); const spaces = [await t.sharedSpaces.create(actor, 'performer'), await t.sharedSpaces.create(actor, 'venue')]; diff --git a/apps/web/src/lib/server/bootstrap.ts b/apps/web/src/lib/server/bootstrap.ts index c42f7a2b5..47bcf217d 100644 --- a/apps/web/src/lib/server/bootstrap.ts +++ b/apps/web/src/lib/server/bootstrap.ts @@ -14,13 +14,14 @@ const open = { $type: 'com.atproto.simplespace.defs#open' } as const; * - audit: anyone may write (their own audit entries are tracked); only staff read. * - moderation: anyone may file requests; only staff read. * - staff: only the root writes; staff read. - * - industry: only the root writes; any signed-in atproto account may read. + * - industry: only the root writes; only staff read, so everyone else sees booking details through the app, in a press + * kit they were let into, which leaves a receipt. */ export const ROOT_SPACE_POLICIES: Record = { audit: { readPolicy: memberList, writePolicy: publicPolicy, appAccess: open }, moderation: { readPolicy: memberList, writePolicy: publicPolicy, appAccess: open }, staff: { readPolicy: memberList, writePolicy: memberList, appAccess: open }, - industry: { readPolicy: publicPolicy, writePolicy: memberList, appAccess: open } + industry: { readPolicy: memberList, writePolicy: memberList, appAccess: open } }; /** Each root space's name and URI, with the policies it should have. */ diff --git a/apps/web/src/lib/server/config.test.ts b/apps/web/src/lib/server/config.test.ts index b12af3a44..849dd353b 100644 --- a/apps/web/src/lib/server/config.test.ts +++ b/apps/web/src/lib/server/config.test.ts @@ -20,6 +20,7 @@ describe('loadConfig', () => { syncNotify: true, logLevel: 'debug', adminDids: [], + epkViewRetentionDays: 90, happyView: { url: 'https://happyview.example', apiKey: undefined } }); }); @@ -78,6 +79,7 @@ describe('loadConfig', () => { ALLOW_PRIVATE_NETWORK: '0', AUDIT_MAX_ATTEMPTS: '5', SESSION_DAYS: '7', + EPK_VIEW_RETENTION_DAYS: '30', HAPPYVIEW_API_KEY: 'hv_key' }); @@ -86,6 +88,7 @@ describe('loadConfig', () => { allowPrivateNetwork: false, auditMaxAttempts: 5, sessionDays: 7, + epkViewRetentionDays: 30, happyView: { apiKey: 'hv_key' } }); }); diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index 81233e8ce..9981a2c81 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -79,6 +79,7 @@ const envSchema = z.object({ ALLOW_PRIVATE_NETWORK: booleanString, AUDIT_MAX_ATTEMPTS: intString(12, 1, 100), SESSION_DAYS: intString(30, 1, 365), + EPK_VIEW_RETENTION_DAYS: intString(90, 1, 3650), EMAIL_PROVIDER: z.enum(['resend', 'log', 'none']).optional(), RESEND_API_KEY: z.string().min(1).optional(), EMAIL_FROM: z.string().min(1).optional(), @@ -111,6 +112,8 @@ export type Config = { allowPrivateNetwork: boolean; auditMaxAttempts: number; sessionDays: number; + /** How long receipts of press kit views are kept before they expire. */ + epkViewRetentionDays: number; /** * How inbox items go out by email: Resend's API, the log (development, so nothing leaves the machine), or not at * all, in which case the settings page says email isn't set up here. @@ -193,6 +196,7 @@ export function loadConfig(env: Record): Config { allowPrivateNetwork: truthy(value.ALLOW_PRIVATE_NETWORK, dev), auditMaxAttempts: value.AUDIT_MAX_ATTEMPTS, sessionDays: value.SESSION_DAYS, + epkViewRetentionDays: value.EPK_VIEW_RETENTION_DAYS, email: emailConfig(value, dev), dm: { provider: value.DM_PROVIDER ?? (dev ? 'log' : 'none'), chatService: value.CHAT_SERVICE_DID }, backup: backupSettings(value) diff --git a/apps/web/src/lib/server/db/migrations.ts b/apps/web/src/lib/server/db/migrations.ts index 1847c46c3..87ff23b3a 100644 --- a/apps/web/src/lib/server/db/migrations.ts +++ b/apps/web/src/lib/server/db/migrations.ts @@ -331,6 +331,22 @@ const inbox = statements( `CREATE INDEX notification_delivery_due ON notification_delivery (status, next_attempt_at)` ); +/** Receipts of press kit views, which their project's managers can delete and which expire. */ +const epkViews = statements( + `CREATE TABLE epk_view ( + id TEXT PRIMARY KEY, + epk_uri TEXT NOT NULL, + epk_title TEXT NOT NULL, + project_uri TEXT NOT NULL, + viewer_did TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX epk_view_project ON epk_view (project_uri, created_at)`, + `CREATE INDEX epk_view_viewer ON epk_view (epk_uri, viewer_did, created_at)`, + `CREATE INDEX epk_view_created ON epk_view (created_at)` +); + const migrations = { '001_init': { async up(db) { @@ -368,6 +384,11 @@ const migrations = { async up(db) { for (const statement of inbox) await sql.raw(statement).execute(db); } + }, + '008_epk_views': { + async up(db) { + for (const statement of epkViews) await sql.raw(statement).execute(db); + } } } satisfies Record; diff --git a/apps/web/src/lib/server/db/schema.ts b/apps/web/src/lib/server/db/schema.ts index 947d02b59..cebcf77d7 100644 --- a/apps/web/src/lib/server/db/schema.ts +++ b/apps/web/src/lib/server/db/schema.ts @@ -3,8 +3,8 @@ import type { SpaceKind } from '#lib/capabilities.ts'; import type { NotificationChannel, PausedReason, RequestKind } from '#lib/types.ts'; /** - * Local index. Everything here except sessions, the audit outbox, and people's - * inboxes is derived from records stored in atproto repos and spaces, and can be + * Local index. Everything here except sessions, the audit outbox, press kit view receipts and + * people's inboxes is derived from records stored in atproto repos and spaces, and can be * rebuilt by re-syncing, so those need backing up. Published projects aren't * here: HappyView indexes them. * Column names are snake_case in SQLite and camelCase in code. @@ -34,6 +34,7 @@ export interface Database { spaceInvite: SpaceInviteTable; spaceInviteLink: SpaceInviteLinkTable; spaceJoinRequest: SpaceJoinRequestTable; + epkView: EpkViewTable; geocodeCache: GeocodeCacheTable; notification: NotificationTable; notificationChannel: NotificationChannelTable; @@ -314,6 +315,22 @@ export interface SpaceJoinRequestTable { createdAt: string; } +/** + * Who viewed a press kit's private part. Only here, never on atproto: the audit chain holds an HMAC of the viewer's DID + * under `salt`, so deleting the row leaves the chain whole but no longer says who it was. + */ +export interface EpkViewTable { + /** The audit entry recording the view. */ + id: string; + epkUri: string; + /** The press kit's title when it was viewed, so the receipt still reads after the press kit is gone. */ + epkTitle: string; + projectUri: string; + viewerDid: string; + salt: string; + createdAt: string; +} + export interface GeocodeCacheTable { query: string; results: string; diff --git a/apps/web/src/lib/server/project-page.ts b/apps/web/src/lib/server/project-page.ts index 89116ddba..60546fb6a 100644 --- a/apps/web/src/lib/server/project-page.ts +++ b/apps/web/src/lib/server/project-page.ts @@ -2,10 +2,11 @@ import { error } from '@sveltejs/kit'; /** * What a project's public pages (its profile and its press kit) both load: the project, 404 when - * it's hidden from this viewer, the booking details for signed-in members, its managers, and the - * managers' own DJ records, streamed so a slow PDS never holds the page up. Records already at hand - * (or no managers to ask) render with the page, so no placeholder comes and goes and moves what's - * below it. + * it's hidden from this viewer, the booking details for its managers and staff, its managers, and + * the managers' own DJ records, streamed so a slow PDS never holds the page up. Everyone else sees + * booking details only in a press kit they have access to, which records the view. Records already + * at hand (or no managers to ask) render with the page, so no placeholder comes and goes and moves + * what's below it. */ export async function loadProjectPage(locals: App.Locals, slug: string) { const { app, viewer } = locals; @@ -15,13 +16,20 @@ export async function loadProjectPage(locals: App.Locals, slug: string) { const isManager = !!viewer && project.managers.includes(viewer.did); if (project.status !== 'active' && !isManager && !viewer?.isStaff) error(404, 'No project with that name'); + const seesDetails = isManager || !!viewer?.isStaff; const [details, accounts] = await Promise.all([ - viewer ? app.projects.details(project.uri) : Promise.resolve(null), + seesDetails ? app.projects.details(project.uri) : Promise.resolve(null), app.accounts.views(project.managers) ]); const managers = project.managers.map((did) => accounts.get(did) ?? { did, handle: null }); - return { project, details, managers, isManager, dj: app.dj.fresh(managers) ?? app.dj.activity(managers) }; + return { + project, + details: seesDetails ? (details ?? {}) : null, + managers, + isManager, + dj: app.dj.fresh(managers) ?? app.dj.activity(managers) + }; } diff --git a/apps/web/src/lib/server/services/epk-views.test.ts b/apps/web/src/lib/server/services/epk-views.test.ts new file mode 100644 index 000000000..7340bdb47 --- /dev/null +++ b/apps/web/src/lib/server/services/epk-views.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EpkView } from '#lib/epk.ts'; +import { ForbiddenError, NotFoundError } from '../core'; +import { ADMIN, CODJ, DJ, PROMOTER, pressKitServices, STRANGER } from '../testing/press-kits'; +import { EpkViews, viewerCommitment } from './epk-views'; + +const MINUTE = 60_000; + +const DAY = 1440 * MINUTE; + +describe('EpkViews', () => { + let f: Awaited>; + let now: number; + let views: EpkViews; + let clubs: EpkView; + + beforeEach(async () => { + f = await pressKitServices(); + now = Date.parse('2026-10-30T12:00:00Z'); + views = new EpkViews(f.t.core, f.t.epks, 90, () => now); + clubs = await f.publish(DJ); + await f.t.epks.invite(await f.t.viewer(DJ), f.kit(clubs), PROMOTER); + await f.t.audit.idle(); + }); + afterEach(async () => { + await f.t.audit.idle(); + await f.t.db.destroy(); + }); + + const view = async (did = PROMOTER, kit = clubs) => views.record(f.kit(kit), await f.t.viewer(did)); + + const entries = (action: string) => f.t.audit.list({ action, limit: 500 }); + + const salts = () => f.t.db.selectFrom('epkView').select(['id', 'salt', 'viewerDid']).execute(); + + it("lists who viewed which press kit for the project's managers, newest first", async () => { + const festivals = await f.publish(CODJ, { title: 'Festivals' }); + await f.t.epks.invite(await f.t.viewer(CODJ), f.kit(festivals), STRANGER); + + await view(); + now += MINUTE; + await view(STRANGER, festivals); + + expect( + (await views.list(f.project)).map((receipt) => [receipt.viewer.did, receipt.epk.title, receipt.epk.rkey]) + ).toEqual([ + [STRANGER, 'Festivals', festivals.rkey], + [PROMOTER, 'Clubs', clubs.rkey] + ]); + }); + + it('writes each view to the audit chain without naming the viewer, under an HMAC only the receipt can open', async () => { + await view(); + await f.t.audit.idle(); + + const [entry] = await entries('epk.view'); + const [{ id, salt }] = await salts(); + + expect(entry).toMatchObject({ id, actorDid: f.t.core.rootDid, actorStatus: 'self', subject: clubs.uri }); + expect(JSON.parse(entry.details!)).toEqual({ viewer: viewerCommitment(salt, PROMOTER) }); + expect(JSON.stringify(entry)).not.toContain(PROMOTER); + + // Nor does the root's copy on the network. + const copies = f.t.net.spaceRecordsIn(f.t.spaces.audit).filter((record) => record.uri.endsWith(`/${id}`)); + expect(copies).toHaveLength(1); + expect(JSON.stringify(copies[0].value)).not.toContain(PROMOTER); + }); + + it('counts reloads within half an hour as one view', async () => { + await view(); + now += 29 * MINUTE; + await view(); + expect(await views.list(f.project)).toHaveLength(1); + + now += 2 * MINUTE; + await view(); + expect(await views.list(f.project)).toHaveLength(2); + expect(await entries('epk.view')).toHaveLength(2); + }); + + it("lets any of the project's managers delete a receipt, and the chain still verifies", async () => { + await view(); + const [receipt] = await views.list(f.project); + + await views.remove(await f.t.viewer(CODJ), f.project, receipt.id); + + expect(await views.list(f.project)).toEqual([]); + expect(await salts()).toEqual([]); + expect(await entries('epk.view')).toHaveLength(1); + expect(await entries('epk.forget')).toMatchObject([ + { actorDid: CODJ, subject: clubs.uri, details: JSON.stringify({ entry: receipt.id }) } + ]); + expect(await f.t.audit.verifyChain()).toMatchObject({ ok: true }); + }); + + it("doesn't let anyone else delete a receipt, or a manager delete another project's", async () => { + await view(); + const [receipt] = await views.list(f.project); + + await expect(views.remove(await f.t.viewer(PROMOTER), f.project, receipt.id)).rejects.toThrow(ForbiddenError); + + const other = await f.t.projectAdmin.create({ + actor: await f.t.viewer(ADMIN), + profile: { name: 'Elsewhere', kind: 'solo', genres: ['house'], location: { city: 'Leeds', country: 'GB' } }, + managers: [DJ] + }); + + await expect(views.remove(await f.t.viewer(DJ), other, receipt.id)).rejects.toThrow(NotFoundError); + + expect(await views.list(f.project)).toHaveLength(1); + }); + + it('expires receipts after the retention period, and the chain still verifies', async () => { + await view(); + now += 10 * DAY; + await view(); + + now += 81 * DAY; + expect((await views.list(f.project)).map((receipt) => receipt.viewedAt)).toEqual([ + new Date(now - 81 * DAY).toISOString() + ]); + expect(await views.expire()).toBe(1); + expect(await salts()).toHaveLength(1); + expect(await views.expire()).toBe(0); + expect(await entries('epk.view')).toHaveLength(2); + expect(await f.t.audit.verifyChain()).toMatchObject({ ok: true }); + }); + + it("never fails a press kit's page over its receipt", async () => { + vi.spyOn(f.t.audit, 'record').mockRejectedValueOnce(new Error('disk full')); + + await expect(view()).resolves.toBeUndefined(); + expect(await views.list(f.project)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/server/services/epk-views.ts b/apps/web/src/lib/server/services/epk-views.ts new file mode 100644 index 000000000..c346f0932 --- /dev/null +++ b/apps/web/src/lib/server/services/epk-views.ts @@ -0,0 +1,143 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import type { EpkViewReceipt } from '#lib/epk.ts'; +import type { ProjectView, Viewer } from '#lib/types.ts'; +import { parsePublicRecordUri } from '../atproto/spaces'; +import { type Core, NotFoundError } from '../core'; +import type { EpkService, PressKit } from './epk'; +import { actorRole } from './requests'; + +/** Views by the same person of the same press kit closer together than this count as one, so a reload isn't a receipt. */ +const VIEW_GAP_MS = 30 * 60_000; + +/** The most receipts a project's dashboard lists. */ +const LIST_LIMIT = 200; + +const DAY_MS = 86_400_000; + +/** The HMAC of `did` under `salt`, which a view's audit entry holds in place of who viewed. */ +export const viewerCommitment = (salt: string, did: string) => createHmac('sha256', salt).update(did).digest('hex'); + +/** + * Receipts of who viewed a press kit's private part, for its project's managers. Each view goes into the audit chain + * with the root as its actor and an HMAC of the viewer's DID under a random salt, never the DID itself; the DID and the + * salt stay in this app's database. Deleting a receipt (a manager can, and every receipt expires after the retention + * period) drops both, so the chain still verifies but no longer says who viewed. While a receipt exists, its salt and + * DID reproduce the HMAC in the chain. + */ +export class EpkViews { + constructor( + private readonly core: Core, + private readonly epks: Pick, + /** How long receipts are kept. */ + readonly retentionDays: number, + private readonly now: () => number = Date.now + ) {} + + /** + * Records that `viewer`, who isn't a manager, saw the private part of `kit`. A failure is logged, never thrown, so a + * press kit never fails to open over its receipt. + */ + async record(kit: PressKit, viewer: Viewer): Promise { + const { epk, project } = kit; + + try { + const since = new Date(this.now() - VIEW_GAP_MS).toISOString(); + + const recent = await this.core.db + .selectFrom('epkView') + .select('id') + .where('epkUri', '=', epk.uri) + .where('viewerDid', '=', viewer.did) + .where('createdAt', '>', since) + .executeTakeFirst(); + + if (recent) return; + const createdAt = new Date(this.now()).toISOString(); + const salt = randomBytes(32).toString('hex'); + + const entry = await this.core.audit.record({ + actor: this.core.rootDid, + actorRole: 'system', + action: 'epk.view', + subject: epk.uri, + subjectKind: 'epk', + summary: `Someone with access viewed the press kit “${epk.record.title}”`, + details: { viewer: viewerCommitment(salt, viewer.did) } + }); + + await this.core.db + .insertInto('epkView') + .values({ + id: entry.id, + epkUri: epk.uri, + epkTitle: epk.record.title, + projectUri: project.uri, + viewerDid: viewer.did, + salt, + createdAt + }) + .execute(); + this.core.logger.debug({ epk: epk.uri, entry: entry.id }, 'recorded a press kit view'); + } catch (error) { + this.core.logger.warn({ err: error, epk: epk.uri }, 'could not record a press kit view'); + } + } + + /** The project's receipts still within the retention period, newest first. */ + async list(project: ProjectView): Promise { + const rows = await this.core.db + .selectFrom('epkView') + .select(['id', 'epkUri', 'epkTitle', 'viewerDid', 'createdAt']) + .where('projectUri', '=', project.uri) + .where('createdAt', '>=', this.cutoff()) + .orderBy('createdAt', 'desc') + .limit(LIST_LIMIT) + .execute(); + + const accounts = await this.core.accounts.views(rows.map((row) => row.viewerDid)); + + return rows.map((row) => ({ + id: row.id, + viewer: accounts.get(row.viewerDid) ?? { did: row.viewerDid, handle: null }, + epk: { uri: row.epkUri, rkey: parsePublicRecordUri(row.epkUri)?.rkey ?? '', title: row.epkTitle }, + viewedAt: row.createdAt + })); + } + + /** One of the project's managers deletes a receipt, which the audit log records without saying whose it was. */ + async remove(actor: Viewer, project: ProjectView, id: string): Promise { + await this.epks.assertManager(actor, project); + + const row = await this.core.db + .deleteFrom('epkView') + .where('id', '=', id) + .where('projectUri', '=', project.uri) + .returning(['epkUri', 'epkTitle']) + .executeTakeFirst(); + + if (!row) throw new NotFoundError('That view is already gone'); + await this.core.audit.record({ + actor: actor.did, + actorRole: actorRole(actor), + action: 'epk.forget', + subject: row.epkUri, + subjectKind: 'epk', + summary: `Deleted a view receipt of the press kit “${row.epkTitle}”`, + details: { entry: id } + }); + } + + /** Deletes the receipts past the retention period. Returns how many went. */ + async expire(): Promise { + const result = await this.core.db.deleteFrom('epkView').where('createdAt', '<', this.cutoff()).executeTakeFirst(); + const expired = Number(result.numDeletedRows); + + if (expired) this.core.logger.info({ expired }, 'expired press kit view receipts'); + + return expired; + } + + private cutoff(): string { + return new Date(this.now() - this.retentionDays * DAY_MS).toISOString(); + } +} diff --git a/apps/web/src/lib/server/testing/core.ts b/apps/web/src/lib/server/testing/core.ts index 4c1d74042..b15b0597b 100644 --- a/apps/web/src/lib/server/testing/core.ts +++ b/apps/web/src/lib/server/testing/core.ts @@ -11,6 +11,7 @@ import { RateLimiter } from '../rate-limit'; import { AccountService } from '../services/accounts'; import { BookingService } from '../services/bookings'; import { EpkService } from '../services/epk'; +import { EpkViews } from '../services/epk-views'; import { FollowService } from '../services/follows'; import { PlannerService } from '../services/planner'; import { ProjectAdmin } from '../services/project-admin'; @@ -114,6 +115,7 @@ export async function createTestServices( throw new Error(`no such handle ${identifier}`); }); + const epkViews = new EpkViews(core, epks, 90); const shows = new ShowService(core, projects, directory, indexedRepo, 0); const follows = new FollowService(core, directory, 0); @@ -162,6 +164,7 @@ export async function createTestServices( tracklists, supports, epks, + epkViews, shows, follows, verifications, diff --git a/apps/web/src/routes/about/+page.svelte b/apps/web/src/routes/about/+page.svelte index ce605e5ae..def6f0b86 100644 --- a/apps/web/src/routes/about/+page.svelte +++ b/apps/web/src/routes/about/+page.svelte @@ -20,7 +20,7 @@
  • Audit log: anyone may write their own audit entries; staff read.
  • Moderation: anyone may file requests; staff read.
  • Staff: role assignments, written by the root account.
  • -
  • Industry: private booking details, readable by any signed-in member.
  • +
  • Industry: private booking details, readable by staff. Everyone else sees them through the site, in a press kit they've been let into.
  • Booking threads: one space per inquiry, shared only by the promoter and the project's managers.
  • Your planner: a personal space under your own DID, like bookmarks. We keep no copy.
  • diff --git a/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.server.ts b/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.server.ts index b1a7e2843..678a60061 100644 --- a/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.server.ts +++ b/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.server.ts @@ -1,5 +1,6 @@ -import { requireViewer } from '#lib/server/http.ts'; -import type { PageServerLoad } from './$types'; +import { requireViewer, act, text } from '#lib/server/http.ts'; +import { requireManager } from '#lib/server/manage.ts'; +import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, url, parent }) => { const { project } = await parent(); @@ -8,6 +9,21 @@ export const load: PageServerLoad = async ({ locals, url, parent }) => { viewerDid: requireViewer(locals, url).did, // Streamed, so the page shows while the press kits and drafts are read. dashboard: locals.app.epks.dashboard(project), + views: locals.app.epkViews.list(project), + retentionDays: locals.app.epkViews.retentionDays, deleted: url.searchParams.has('deleted') }; }; + +export const actions: Actions = { + forgetView: async ({ request, locals, url, params }) => { + const { viewer, project } = await requireManager(locals, url, params.slug); + const form = await request.formData(); + + return act(async () => { + await locals.app.epkViews.remove(viewer, project, text(form, 'view', 64)); + + return { success: 'View deleted.' }; + }); + } +}; diff --git a/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.svelte b/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.svelte index 0f8b5464e..e5f9b0d28 100644 --- a/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.svelte +++ b/apps/web/src/routes/dashboard/projects/[slug]/press-kits/+page.svelte @@ -1,10 +1,12 @@ @@ -16,4 +18,6 @@ {#if data.deleted}

    Press kit deleted.

    {/if} + + diff --git a/apps/web/src/routes/for-djs/+page.svelte b/apps/web/src/routes/for-djs/+page.svelte index 9eb10cf83..b56ab4564 100644 --- a/apps/web/src/routes/for-djs/+page.svelte +++ b/apps/web/src/routes/for-djs/+page.svelte @@ -10,7 +10,7 @@ { icon: Radio, title: 'Matched by sound', body: 'Genres are matched by family, so a promoter looking for house finds your deep house and afro house sets too.' }, { icon: CalendarHeart, title: 'Inquiries with details', body: 'Every inquiry arrives with the date, venue, capacity, budget and slot. Reply and confirm in a private thread.' }, { icon: Users, title: 'Crews and agents', body: 'Up to ten managers per project. Duos, collectives and booking agents can all answer inquiries.' }, - { icon: Lock, title: 'Private details stay private', body: 'Your booking email, rates and tech rider are visible only to signed-in members, stored in an atproto space.' }, + { icon: Lock, title: 'Private details stay private', body: 'Your booking email, rates and tech rider show only to people you let into a press kit, and you see when they look.' }, { icon: ClipboardCheck, title: 'Reviewed changes', body: 'Moderators review every claim and edit, so nobody else can take over or deface your profile.' } ]; diff --git a/apps/web/src/routes/privacy/+page.server.ts b/apps/web/src/routes/privacy/+page.server.ts new file mode 100644 index 000000000..022abab13 --- /dev/null +++ b/apps/web/src/routes/privacy/+page.server.ts @@ -0,0 +1,3 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals }) => ({ retentionDays: locals.app.epkViews.retentionDays }); diff --git a/apps/web/src/routes/privacy/+page.svelte b/apps/web/src/routes/privacy/+page.svelte index d1a437ba0..50fb5056b 100644 --- a/apps/web/src/routes/privacy/+page.svelte +++ b/apps/web/src/routes/privacy/+page.svelte @@ -1,5 +1,8 @@ Privacy · Local Network @@ -10,12 +13,13 @@

    Public

    Published project profiles (name, genres, city, links, mixes, fee tier, managers' DIDs) are public atproto records anyone can read.

    A bug report you send while signed in is a public post on userinput.app, kept in your own repo, with the page you sent it from and your browser.

    -

    Members only

    -

    Private booking details (email, phone, rates, tech rider) are stored in the industry space and shown only to signed-in members.

    +

    Booking details

    +

    Private booking details (email, phone, rates, tech rider) are stored in a space only Local Network and its staff can read. The project's managers see them, and so does anyone they let into one of their press kits, inside that press kit.

    Staff only

    Requests, claim evidence, decisions, staff roles, and the audit log live in spaces only staff can read. Audit entries record what happened (who, what, when, and which fields changed); booking message contents are never copied into the audit log.

    Press kits

    A press kit's public part is a record in Local Network's repo that anyone can read. The rest sits in a space under Local Network's account that only the project's managers, Local Network, and the people they let in can read. Asking to see it shares your handle and any message you write with the project's managers. Each person let in gets a record in that space saying so, which any of the managers can have deleted to take access away. Drafts of a press kit sit in each manager's repo in a space only the project's current managers can read.

    +

    When someone let in opens a press kit on this site, its project's managers see who and when. Local Network keeps that receipt for {data.retentionDays} days, and any of the managers can delete it sooner. The audit log keeps a record that the press kit was viewed, but not by whom.

    Just you and the other party

    Booking threads are readable only by the promoter and the project's managers. Your planner is readable only by you.

    On our server

    diff --git a/apps/web/src/routes/projects/[slug]/+page.svelte b/apps/web/src/routes/projects/[slug]/+page.svelte index b15867ade..f2a5ba25a 100644 --- a/apps/web/src/routes/projects/[slug]/+page.svelte +++ b/apps/web/src/routes/projects/[slug]/+page.svelte @@ -164,7 +164,7 @@

    Booking details

    - +
    diff --git a/apps/web/src/routes/projects/[slug]/press-kits/+page.svelte b/apps/web/src/routes/projects/[slug]/press-kits/+page.svelte index ca1eeefa5..c4620ae27 100644 --- a/apps/web/src/routes/projects/[slug]/press-kits/+page.svelte +++ b/apps/web/src/routes/projects/[slug]/press-kits/+page.svelte @@ -17,7 +17,7 @@ /** * A project's press kits: the ones its managers publish as records, listed first, then one put * together from what's on file (the profile, its mixes, the managers' playing stats and sets, and - * booking details for members). That one reads like a document and prints on white. + * booking details for its managers). That one reads like a document and prints on white. */ let { data }: PageProps = $props(); @@ -160,7 +160,7 @@

    Booking

    - 0} loginHref="/login?next=/projects/{project.slug}/press-kits" /> + 0} />
    {#if data.viewer && project.managers.length && !data.isManager}Send inquiry{/if} Profile: {data.profileUrl} diff --git a/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.server.ts b/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.server.ts index 1df216b7e..ef79bb001 100644 --- a/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.server.ts +++ b/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.server.ts @@ -8,10 +8,15 @@ export const load: PageServerLoad = async ({ locals, params }) => { if (!kit) error(404, 'Press kit not found'); const access = await locals.app.epks.accessState(kit, locals.viewer); - const canSee = access.kind === 'manager' || access.kind === 'granted'; + const granted = access.kind === 'granted'; + const canSee = access.kind === 'manager' || granted; + + // Someone let in sees the rest, with the project's booking details, and the project's managers get a receipt. + if (granted && locals.viewer) await locals.app.epkViews.record(kit, locals.viewer); return { ...page, + details: granted ? ((await locals.app.projects.details(page.project.uri)) ?? {}) : page.details, epk: kit.epk, access, content: canSee ? await locals.app.epks.content(kit.epk) : null, diff --git a/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.svelte b/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.svelte index a42ddabb3..183852063 100644 --- a/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.svelte +++ b/apps/web/src/routes/projects/[slug]/press-kits/[rkey]/+page.svelte @@ -2,6 +2,7 @@ import { ArrowLeft, Check, Copy, Printer, Quote } from '@lucide/svelte'; import { page } from '$app/state'; import Avatar from '#lib/components/Avatar.svelte'; + import BookingDetails from '#lib/components/BookingDetails.svelte'; import DjSets from '#lib/components/DjSets.svelte'; import DjStats from '#lib/components/DjStats.svelte'; import EpkAccessCard from '#lib/components/epk/EpkAccessCard.svelte'; @@ -35,6 +36,8 @@ const isManager = $derived(data.access.kind === 'manager'); + const hasDetails = $derived(!!data.details && Object.values(data.details).some(Boolean)); + let copied = $state(false); async function copyLink() { @@ -151,7 +154,9 @@ {#if content.contact?.note}
    Note
    {content.contact.note}
    {/if} {#if content.techRider}
    Tech rider
    {content.techRider}
    {/if}
    - {:else} + {/if} + {#if hasDetails} 0} />{/if} + {#if !content.contact && !content.techRider && !hasDetails}

    No contact details in this press kit.{#if data.project.managers.length && !isManager}{' '}Send an inquiry instead.{/if}

    {/if} diff --git a/apps/web/tests/e2e/app.e2e.ts b/apps/web/tests/e2e/app.e2e.ts index 798a5aaac..125f237dd 100644 --- a/apps/web/tests/e2e/app.e2e.ts +++ b/apps/web/tests/e2e/app.e2e.ts @@ -83,7 +83,11 @@ test.describe('public pages', () => { await expectAccessible(page); await page.getByRole('link', { name: 'Halflight' }).click(); await expect(page.getByRole('heading', { name: 'Halflight' })).toBeVisible(); - await expect(page.getByText('Sign in to see booking contacts')).toBeVisible(); + await expect(page.getByText('Contacts, rates and the tech rider come with a press kit.')).toBeVisible(); + await expect(page.getByRole('link', { name: 'See the press kits' })).toHaveAttribute( + 'href', + '/projects/halflight/press-kits' + ); await expectAccessible(page); }); @@ -93,7 +97,7 @@ test.describe('public pages', () => { await expect(page.getByText('126–134')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Recent sets' })).toBeVisible(); await expectAccessible(page); - await page.getByRole('link', { name: 'Press kits' }).click(); + await page.getByRole('link', { name: 'Press kits', exact: true }).click(); await expect(page).toHaveURL(/\/projects\/neon-tide\/press-kits$/); await expect(page.getByRole('heading', { level: 1, name: 'Neon Tide' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sound' })).toBeVisible(); diff --git a/apps/web/tests/integration/press-kits.test.ts b/apps/web/tests/integration/press-kits.test.ts index f3d6a7833..7aeb77a58 100644 --- a/apps/web/tests/integration/press-kits.test.ts +++ b/apps/web/tests/integration/press-kits.test.ts @@ -11,6 +11,10 @@ import { actions as editorActions, load as editorLoad } from '../../src/routes/dashboard/projects/[slug]/press-kits/[rkey]/+page.server.ts'; +import { + actions as kitsActions, + load as kitsLoad +} from '../../src/routes/dashboard/projects/[slug]/press-kits/+page.server.ts'; import { actions as accessActions, load as accessLoad @@ -378,8 +382,34 @@ describe('shared press kits on a real atproto spaces network', () => { ); const shown = (viewer: typeof neon | null) => publicKit({ params, locals: locals(viewer) }); - expect(await shown(pat)).toMatchObject({ access: { kind: 'granted' }, content: { updatedAt: expect.any(String) } }); - expect(await shown(null)).toMatchObject({ access: { kind: 'signedOut' }, content: null }); + expect(await shown(pat)).toMatchObject({ + access: { kind: 'granted' }, + content: { updatedAt: expect.any(String) }, + details: {} + }); + expect(await shown(null)).toMatchObject({ access: { kind: 'signedOut' }, content: null, details: null }); + + // Pat's look leaves the managers a receipt; theirs don't. Either manager can delete it, and the chain still holds. + await shown(remy); + const kits = `/dashboard/projects/${slug}/press-kits`; + + const receipts = async (viewer: typeof neon) => + ( + await kitsLoad({ + url: new URL(`http://x${kits}`), + locals: locals(viewer), + parent: async () => ({ project: night }) + }) + ).views; + + const [receipt, ...others] = await receipts(remy); + expect(others).toEqual([]); + expect(receipt).toMatchObject({ viewer: { did: pat.did }, epk: { rkey, title: 'Night Shift' } }); + expect(await kitsActions.forgetView(post(kits, neon, [['view', receipt.id]], { slug }))).toMatchObject({ + success: 'View deleted.' + }); + expect(await receipts(remy)).toEqual([]); + expect(await net.app.audit.verifyChain()).toMatchObject({ ok: true }); // A co-manager opening a press kit that hasn't moved yet is told who has to open it. const { rkey: old } = await legacyKit('remy', night, { diff --git a/docs/adr/0001-changes-happen-in-the-dashboard.md b/docs/adr/0001-changes-happen-in-the-dashboard.md index 5e483f891..37b30a9be 100644 --- a/docs/adr/0001-changes-happen-in-the-dashboard.md +++ b/docs/adr/0001-changes-happen-in-the-dashboard.md @@ -1,6 +1,6 @@ # Changes happen in the dashboard, public pages only read -Every change to what a public page shows (a project's listing, its shows, its press kits and their grants, booking threads, requests) happens under `/dashboard`. Staff work happens under `/admin`, which uses the same shell with its own navigation and role check. Public pages can show more to viewers who are allowed to read more, like booking details for signed-in members or a press kit's private part for people with a grant. They never show controls for changing it, apart from a single "Manage" link for the project's managers and staff. There's one exception: following a project and saving it to your planner stay as inline toggles, because they only change the viewer's own data. +Every change to what a public page shows (a project's listing, its shows, its press kits and their grants, booking threads, requests) happens under `/dashboard`. Staff work happens under `/admin`, which uses the same shell with its own navigation and role check. Public pages can show more to viewers who are allowed to read more, like a press kit's private part and the project's booking details for people with a grant. They never show controls for changing it, apart from a single "Manage" link for the project's managers and staff. There's one exception: following a project and saving it to your planner stay as inline toggles, because they only change the viewer's own data. Before this, owner controls were scattered over the public pages and private pages sat at several top-level paths. We moved the private routes without redirects, since no notification or outside link pointed at them. Public URLs kept their paths, apart from press kits (see [0002](0002-shared-press-kits-in-root-owned-spaces.md)). diff --git a/lexicons/computer/localnetwork/audit/entry.json b/lexicons/computer/localnetwork/audit/entry.json index aa7fe6c2f..244c20d13 100644 --- a/lexicons/computer/localnetwork/audit/entry.json +++ b/lexicons/computer/localnetwork/audit/entry.json @@ -44,6 +44,8 @@ "epk.grant", "epk.decline", "epk.revoke", + "epk.view", + "epk.forget", "show.save", "show.delete", "follow.create", diff --git a/lexicons/computer/localnetwork/space/industry.json b/lexicons/computer/localnetwork/space/industry.json index 218b7201f..9ba3bf6ee 100644 --- a/lexicons/computer/localnetwork/space/industry.json +++ b/lexicons/computer/localnetwork/space/industry.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "space", - "description": "Private booking details for projects, visible to signed-in members.", + "description": "Private booking details for projects. Only staff can read it; the app shows the details to a project's managers and to people granted one of its press kits.", "key": "literal:main", "name": "Local Network industry details", "collections": ["computer.localnetwork.project.details"]