Contributing to Get Heard
Thanks for looking. This file is for someone who wants to change the code - how to run it, how the project actually works, and which conventions are enforced by something that will fail rather than by someone remembering.
The license, in the two sentences that affect you
Get Heard is AGPL-3.0-only. Run it for yourself, for your own music, at any scale, and nothing is asked of you - there is no obligation attached to using it. But if you modify it and let other people use your modified version over a network, you have to offer them its source (§13, "Remote Network Interaction"), and any derivative you distribute is AGPL too.
That is deliberate. This codebase is a hosted product as well as a program you run on your laptop, and the point of the copyleft is that a modified hosted fork stays open rather than becoming somebody else's closed SaaS. It does not stop anyone hosting Get Heard unmodified - that is a permitted use of an open-source license, and the trade was accepted knowingly.
Two consequences for a contributor:
- Your contribution is AGPL too, and the owner retains copyright in their own work. There is no CLA to sign.
- ⚠ A new dependency must be AGPL-compatible, and that is a real constraint rather than a formality. MIT, ISC, BSD and Apache-2.0 all flow into AGPL-3.0 one-way and are fine. GPL-2.0-only is not - it cannot combine with AGPL-3.0 in either direction, and it is the trap to watch for. If you add a dependency, check its license field before its bundle size.
What this is
A music promotion tool for one working artist: it finds outlets, writes and sends the pitches, tracks what came back, and produces the artifacts a release needs - a press kit, a smart link, a link-in-bio page, a fan kit, and 9:16 shorts cut from the song itself.
Everything in it is judged against one question, and the question has teeth in code review: does this get the song heard? A surface where a stranger lands and cannot press play is a bug, whatever else it does well. Reach without listening is vanity. When a metric and a listen point in different directions, the listen wins. If a change cannot be argued for on those grounds, it probably does not belong - and if it can, say so in the PR, because that is the argument that settles disagreements here.
It builds as one of two editions, forked in exactly one named place (config.edition, set by
APP_EDITION): selfhost, the single-owner app above, which is the default and what every
existing install is; and saas, a hosted multi-tenant product on top of it. The iron rule is that
anything reading config.edition keeps selfhost byte-identical to before.
Why half of it says getheard
The product is Get Heard. The repository, the systemd units in deploy/, the service user and -
load-bearing - the default database filename data/getheard.db are still getheard,
and that is a decision rather than an unfinished rename. Every existing install has its database at
that path, and renaming it would strand them for no gain, because no stranger ever reads any of
those. The npm package is getheard, because publishing is precisely a stranger reading and
typing a name.
⚠ Two traps live here. First: never mass-rename "out now" in this repo. OUT NOW is the
release-status chip baked into every reel and it is press-kit and link-page copy - it is domain
language about the song, nothing to do with the product's name, and a find/replace across it breaks
the renderer and the published pages. Second, the general form, which is worth more than the specific
case: a name's justification can expire without the name changing. "No stranger ever reads it"
was true of the package name right up until the day it was published. When a surface stops being
internal, re-derive the decision instead of inheriting it.
Getting it running
git clone <this repo> && cd getheard
npm install
npm run dev # server watch + frontend bundle watch → http://localhost:3000
Node 24 or newer - not a preference. The app is native ESM and runs its TypeScript through
Node's own type stripping, and package.json pins engines.node >= 24.
⚠ If you change Node versions, run npm rebuild better-sqlite3. It is compiled against
whichever major last installed it, and the mismatch does not present as an error: some test files
simply hang, which reads as "the suite is pathologically slow". That misdiagnosis cost two rounds
once.
No configuration is needed to start. The app boots with nothing set and says what is missing, and
most credentials (Netlify, Spotify, the Gmail OAuth app, a mailbox) are settable in Settings, where a
saved value beats the matching env var. ⚠ The AI credentials are the exception and are read from
the environment only - Settings picks the provider and the models. .env.example lists every
variable, commented out; DOCS.md §10 is the reference it chains onto.
The commands
| Command | What it does |
|---|---|
npm run dev |
The normal loop: tsx watch on the server + esbuild --watch on the frontend bundle |
npm run dev:saas |
The hosted edition locally - its own database, its own port, publishing and mail disarmed. It refuses to start against the selfhost database |
npm run dev:landing |
The hosted edition's signed-out front door, on its own database so your dev:saas session does not reach it. Seeds a synthetic showcase so the play button exists |
npm run dev:crew |
Crew, the internal back-office - a separate process on its own port, against the same dev database as dev:saas, so the two run side by side |
npm test |
The whole suite (node --test, native type stripping, no tsx). It must stay green. Runs the two below in order, because one concurrency number cannot serve both |
npm run test:unit |
Everything except the browser tests, at high concurrency - they are cheap |
npm run test:one tests/foo.test.ts |
One file, ~0.3s - the tight loop. ⚠ Not a bare node --test: the guard that stops the suite reaching the internet rides on --import ./tests/noNetwork.ts |
npm run test:ui |
The real-browser tests only. Needs Chrome installed; without it they skip rather than fail. ⚠ Deliberately low concurrency: each file is a test runner, a real server child AND a Chrome, so running many at once makes the suite slower and flaky |
npx tsc --noEmit -p tsconfig.json |
Backend typecheck |
npx tsc --noEmit -p tsconfig.frontend.json |
Frontend typecheck - esbuild does not typecheck, so this is the only thing that will tell you |
npm run typecheck:tests |
Typechecks tests/. ⚠ This and npm run lint are two gates and you need both - an eslint autofix once removed a load-bearing defaulted parameter that only tsc caught |
npm run lint |
eslint in --fix mode over shared/, src/ and tests/ |
npm run prettier |
Formatting, scoped to code globs on purpose (see below) |
npm run i18n:audit |
Every untranslated frontend string; --strict exits 1 |
npm run build then npm start |
The compiled build - the path an installed copy takes, and the only one an npx getheard user ever runs |
Before opening a PR: npm test, both typechecks, and npm run lint.
How this project works
Work happens in rounds. Each one leaves a plans/PLAN-round<N>.md saying what was
built, why, what was proved, and what is still on the list. They are the project's memory, they are
frozen once written, and reading the newest one is the fastest way to find out where things stand.
BACKLOG.md is the durable cross-round backlog - the index, the current truth, and a pointer to the
PLAN that holds each item's full spec. A round's "still on the list" is a short delta against it, not
a fresh re-summary; that rule exists because the outstanding-work list used to live only in PLAN
prose and a whole backlog quietly evaporated once.
The architecture document is CLAUDE.md, and it is addressed to an AI agent
That is deliberate and it is not going to change. Most of this codebase was written in sessions with
an AI agent, and CLAUDE.md is the file each session boots from - so it is the one document with a
structural reason to stay true, and it is where every hard-won reason ends up. Extracting an
ARCHITECTURE.md from it would produce two documents, and the copy that stays true is the one a
session boots from; the other would be stale within a month and would look authoritative while
being wrong.
So: read CLAUDE.md as the architecture document. The voice is directed at an agent and it is
full of ⚠ warnings written the day something bit; that is the good part, not an artefact. It keeps
what binds work anywhere in the tree; the long reasoning for each individual lane sits beside the
code it binds, and CLAUDE.md opens with a table pointing at every one:
src/frontend/CLAUDE.md- the frontend's own notes: Lit islands, shadow DOM, state, the edit modals.src/crew/CLAUDE.md- Crew, the internal back-office: a separate app on a separate port.docs/notes/- a lane note per subject: the published pages a stranger lands on, the hub and its domains, editions and the AI seam, the mail and social connections, background jobs and error tracking, the database and tenancy, the dashboard and everything the app counts, every text seam, the color/motion/icon tokens, the legal documents and the deletion machinery, the per-workspace clock, the gates and the license, testing and verifying, and the list of gotchas.STYLEGUIDE.md- the law for frontend work. Read it before touching a page.DOCS.md- the long-form feature and configuration reference, written for a user.
Every one of those is registered in tests/docs.test.ts, so a note that names a file which no
longer exists fails the suite rather than quietly going stale - and so does a note nobody
registered, which would otherwise be checked by nothing at all.
The split is not a style choice; it is a budget. CLAUDE.md is loaded into every session before
anyone knows what that session is about, so length in it is charged to every piece of work, including
all the work that never opens the lane a given page describes. It has twice grown past what can
actually be loaded, and both times nobody noticed until it was already over - so the sizes are gated
now (see the table below). If a gate stops you, the fix is never to delete the reasoning: move the
lane into docs/notes/<lane>.md, register it, and leave a pointer behind.
Conventions that are actually enforced
The useful list is not "what we prefer" but "what will fail". Almost everything here has a gate, because this codebase has repeatedly learned that a convention nothing checks is a convention that decays one round at a time.
| Rule | What enforces it |
|---|---|
Relative imports carry an explicit .ts; type-only imports are import type; no enums, namespaces or constructor parameter properties; import.meta.dirname, never __dirname |
tsc (verbatimModuleSyntax, erasableSyntaxOnly) + an eslint autofix rule |
TypeScript strict everywhere; strictTypeChecked on src/**, shared/** and tests/** |
npm run lint |
No truthiness test on a non-boolean, in shared/** or a migrated frontend island (src/** is a per-file burn-down) |
npm run lint (strict-boolean-expressions) |
Tabs, width 150, double quotes, arrowParens: "avoid", no trailing commas |
npm run prettier |
| No untranslated user-facing string in the frontend, in services, or in a shared catalog constant | npm run i18n:audit, gated by tests/i18nAudit.test.ts |
Every err.* code exists in both locale files, and every literal code the AppError family emits is a dictionary key or a declared diagnostic |
tests/errors.test.ts |
| No duplicate key in a locale file | tests/i18n.test.ts |
| Icons are SVG the app owns, never emoji | tests/icons.test.ts |
| Color is a token; a derived color is computed, not pasted | tests/theme.test.ts |
A component's box spacing goes inside its shadow root, never on :host |
tests/theme.test.ts |
Every animation duration is a token, so prefers-reduced-motion can reach it |
tests/motion.test.ts |
Every background timer goes through startSweep and is registered and started |
tests/sweeps.test.ts |
| Every query touching a tenant-scoped table filters by workspace | tests/tenancyScope.test.ts |
Every settings key has a declared disposition |
tests/settingsScope.test.ts |
| A daemon that reaches AI is wrapped in a workspace | tests/daemonAiScope.test.ts |
| The test suite never touches the internet, and every test that can reach the un-publish path has the provider credential dead or the vendor stubbed | tests/networkGuard.test.ts (+ the tests/noNetwork.ts guard every script that runs the test runner loads) |
Every Crew route is audited or dispositioned; Crew touches only staff_* tables and never the process-local diagnostics |
tests/crewScope.test.ts |
Every "what day/hour is it now" says whose clock it is; SQLite's 'localtime' is gone from src/ |
tests/workspaceClock.test.ts |
Every audio_files query says which recording it means - a release can hold a master and an instrumental |
tests/audioKind.test.ts |
| Everything a workspace purge destroys is either carried away by the export and put back by the import, or has a written reason why not | tests/dataPortability.test.ts |
| English here means American English - color, catalog, canceled, center, license - in the dictionary, on the published pages and in the legal documents | tests/americanEnglish.test.ts |
Every font-size in the app is a rem and nothing else is, so the text-size control can reach it; every element with static styles carries focusStyles |
tests/a11y.test.ts |
Nothing in src/ or shared/ reads process.cwd() to find the app's own tree, and the published tarball contains what it should |
tests/packaging.test.ts |
| Only the modules that are allowed to know about a hosting vendor import one, in both directions | tests/pagesProvider.test.ts |
| A hosted publish never runs unscoped - it would deploy one tenant's tree over another's | tests/hubPublishScope.test.ts |
| An outlet's contact address never leaves the server in a hosted payload | tests/outletAddress.test.ts |
| The wordmark renders as "Get Heard" with the space, even split across markup | tests/brand.test.ts |
| A doc's claims about the app - env vars, file paths, npm scripts, unbuilt-feature claims | tests/docs.test.ts |
| The notes an agent session loads stay inside their size budget, and every notes file is registered | tests/docs.test.ts |
Every way of building the frontend bundle pins the same esbuild --target, and what npm run dev writes parses as JavaScript |
tests/frontendBuildTarget.test.ts |
The canary pattern, and how to answer one
Half the tests above are canaries: they guard a failure that has no symptom. An unscoped query returns the right rows in selfhost. A hardcoded color is exactly right in the theme it was written in. A scheduler that is never called does not log anything. A clock reading is the same "today" for every timezone the test suite happens to run in. A stale line in a document describes a feature that degrades gracefully, so nothing goes wrong - it just never gets used.
They all have the same shape, and the same failure message: a closed list, an entry per case, and a
reason. When one fails, the answer is not to add your case to the exemption list and move on -
though keep, global by design and not ours are all perfectly good answers. The rule is:
unconsidered is not an answer. Write down which one it is and why, in the entry. The next person
to read it is trusting that you looked.
If you add a thing the canary counts - a new sweep, a new table, a new env var, a new settings key
- add it to the list in the same change. The failure message tells you where.
Things that will surprise you
Every one of these cost someone a day. CLAUDE.md's "Gotchas that have bitten" is the long list.
- Never run prettier on the root markdown files or on
locales/*.json. The npm script's globs exclude them deliberately, and.prettierignoreguards the markdown. Prettier's markdown parser destroysBACKLOG.md's hand-nested outline and is not idempotent, so a second pass mangles more; on the locale files it silently re-indents ~3,500 keys and breaks a raw-text test with a baffling message. - Migrations are append-only.
src/db.tsholds the schema as an array; add an entry and never edit a shipped one. The current version is whatever the last entry says - do not write that number down anywhere else, including in a comment. (tests/docs.test.tswill stop you writing it in a doc.) - Shadow DOM changes what
textContentcan see, silently. Every island renders into a shadow root, soancestor.textContentno longer contains its text,this.querySelectorno longer finds its own content (usethis.renderRoot), adocument-level listener sees the shadow HOST asevent.target(useevent.composedPath()[0]), and@keyframesare tree-scoped. - A backtick inside a
css`…`template closes it - even inside a CSS comment. And*/inside a CSS comment closes it early and silently swallows the next rule. - Never concatenate a user-facing sentence out of fragments, and never pluralize by hand. Word
order is not universal. Values go in as named parameters; counted strings ship CLDR plural forms.
Dates, numbers, lists, language names and country names all belong to
Intl- and a hardcoded locale argument is as much a hardcoded-English bug as a hardcoded string. - Text a fan, creator or outlet reads carries no em-dashes. A standing owner rule; the AI drafters strip any the model slips in.
- Every knob is a promise. A control the data cannot honor is shown disabled with the reason, never silently ignored, and never as a button that throws on click.
- ⚠ Do not trust a dead-code tool on the frontend. A Lit custom element is reached by its tag name in a template string, never by importing the class, so every static "is this export used?" analysis reports the entire component and page layer as dead. Measured in round 154: of 33 unreferenced exports, 20 were custom-element classes that the app cannot run without. The real question for an export here is not "who imports it" but "who names it".
What a good PR looks like
- One thing, and the reason for it. The reason is the valuable half - this codebase is unusually heavily commented on purpose, and the comments carry why, never a description of the line below. Do not add comments that restate code, and do not delete the existing rationale.
- Tests that assert the decision, not the prose.
assert.equal(detail.key, "preflight.hook.noMaster")rather than an English sentence, so a translation does not break a test that never meant to check wording. - A gate, if you found a class rather than an instance. If the bug you fixed could recur silently, the fix is not finished until something fails when it does. That is the pattern the whole test suite is built on.
- Say what you did not do. A partial fix, honestly scoped, is welcome. A partial fix described as complete is the one thing this project cannot absorb.
- Green:
npm test, both typechecks,npm run lint.
If you are unsure whether something is wanted, open an issue before building it. BACKLOG.md is the
real roadmap and most of its entries say why they are still open.
Reporting a security problem
Do not open a public issue. Write to security@getheard.now with enough detail to reproduce, and give it a reasonable window before disclosing. This app holds an artist's outreach history, the email addresses of editors who never signed up for anything, and OAuth tokens for their mail and social accounts - the stakes are somebody's real inbox, not a demo.