Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 17 additions & 15 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/mixes/<n>` 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/<did>/<rkey>/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/<did>/<cid>`), 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/<did>/<rkey>/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/<slug>/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/<slug>/press-kits/<rkey>` 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/<slug>/press-kits/<rkey>`, with the form at `/dashboard/access/<slug>/<rkey>`; 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/<slug>/press-kits/<rkey>/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/<slug>/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/<slug>/epk`, `/epk/<did>/<rkey>` 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/<slug>/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/<slug>/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/<slug>/epk`, `/epk/<did>/<rkey>` 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/<slug>/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.

Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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".

Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/lib/components/BookingDetails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>

{#if !signedIn}
<p class="m-0 text-small text-fg-muted"><a class="link" href={loginHref}>Sign in</a> to see booking contacts, rates and the tech rider.</p>
{#if !details}
<p class="m-0 text-small text-fg-muted">Contacts, rates and the tech rider come with a press kit.{#if kitsHref}{' '}<a class="link" href={kitsHref}>See the press kits</a>{/if}</p>
{:else if filled}
<dl class="m-0 grid gap-4 text-small sm:grid-cols-2">
{#if filled.bookingEmail}<div><dt class="eyebrow text-fg-subtle">Booking email</dt><dd class="m-0 mt-1"><a class="link" href="mailto:{filled.bookingEmail}">{filled.bookingEmail}</a></dd></div>{/if}
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/lib/components/epk/EpkViewList.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import BookingDetails from '#lib/components/BookingDetails.svelte';
import type { EpkViewReceipt } from '#lib/epk.ts';
import EpkViewList from './EpkViewList.svelte';

const { Story } = defineMeta({ title: 'Press kits/Views', component: EpkViewList, tags: ['autodocs'] });

const ROOT = 'did:plc:root';

const view = (id: string, handle: string | null, rkey: string, title: string, viewedAt: string): EpkViewReceipt => ({
id,
viewer: { did: `did:plc:${id}`, handle },
epk: { uri: `at://${ROOT}/computer.localnetwork.epk/${rkey}`, rkey, title },
viewedAt
});

const full = [
view('3kv1', 'promoter.test', '3kclubs', 'Clubs', '2026-10-29T22:15:00Z'),
view('3kv2', 'festival-booker.bsky.social', '3kfest', 'Festivals', '2026-10-28T09:40:00Z'),
view('3kv3', null, '3kclubs', 'Clubs', '2026-10-21T17:05:00Z')
];

const pending = <T>() => new Promise<T>(() => {});

const failed = <T>() => {
const promise = Promise.reject<T>(new Error('Could not read views'));
promise.catch(() => {});

return promise;
};

const phone = { viewport: { value: 'mobile2', isRotated: false } };

const list = { slug: 'neon-tide', retentionDays: 90 };
</script>

<Story name="Views" args={{ ...list, views: Promise.resolve(full) }} />

<Story name="Views on a phone" args={{ ...list, views: Promise.resolve(full) }} globals={phone} />

<Story name="No views yet" args={{ ...list, views: Promise.resolve([]) }} />

<Story name="Loading" args={{ ...list, views: pending<EpkViewReceipt[]>() }} />

<Story name="Couldn't load" args={{ ...list, views: failed<EpkViewReceipt[]>() }} />

<Story name="Booking details, outside a press kit" asChild>
<BookingDetails details={null} claimed kitsHref="/projects/neon-tide/press-kits" />
</Story>

<Story name="Booking details, with access" asChild>
<BookingDetails details={{ bookingEmail: 'bookings@neon-tide.test', rateNotes: 'Clubs from 400 EUR', techRider: '2x CDJ-3000, DJM-V10' }} claimed />
</Story>

<Story name="Booking details, none on file" asChild>
<BookingDetails details={{}} claimed />
</Story>
Loading
Loading