LinkedAt #
An ATProto professional-networking MVP. LinkedAt reuses existing shared lexicons as canonical public records wherever they are good enough:
- Sifa records (
id.sifa.profile.*) for profile, position, education, and skill data. - XPTracker records (
app.xptracker.job.listing) for job listings. - LinkedAt-owned records only for sidecars, starting with
app.linkedat.profile.resume.
LinkedAt does not create a primary app.linkedat.job.* lexicon.
Status #
LinkedAt is a prototype with one flow working end to end. It has never been connected to a PDS, a firehose, or a database. Read this section before believing anything the UI implies.
| Area | State |
|---|---|
| Post a job | Works. Form β validated app.xptracker.job.listing record β RecordWriter β normalization β index β visible on /jobs and its own detail URL. |
| Browse jobs and profiles | Works, against an in-memory index seeded from apps/web/app/data/fixtures/. |
| Profile / experience / resume editing | Not implemented. The forms render disabled and say so. |
| Sign in (ATProto OAuth) | Not implemented. All three /api/auth/* routes return 501. |
| PDS writes | Not implemented. Writes go to an in-process stand-in, never to a repository on the network. |
| Firehose indexing | Not implemented. apps/indexer has the normalization handlers and no Jetstream connection. |
| Postgres | Schema and migration only. pnpm db:migrate creates ten tables nothing reads or writes. |
Everything above is verified by pnpm test and pnpm --filter @linkedat/web test:e2e, including the job-posting flow, which is covered both as an integration test (apps/web/tests/job-publish-flow.test.ts) and in a real browser.
The three stand-ins #
Each fake is confined to one file, so the seams are obvious and deletable:
| File | Stands in for | Replace with |
|---|---|---|
apps/web/app/data/repositories.server.ts |
The index | Postgres implementations of ProfileRepository / JobRepository against packages/db/src/schema.ts. Only this file changes. |
apps/web/app/data/record-writer.server.ts |
A PDS | An @atproto/api Agent bound to the signed-in actor's session. |
apps/web/app/data/dev-actor.server.ts |
The signed-in user | A session lookup on the incoming request. |
Suggested order of work #
- Finish OAuth. Everything else is gated on having an actor. The pieces exist and are tested:
startOAuthLoginbuilds the authorization URL,encryptSessionForStorageseals payloads with AES-256-GCM, and theoauth_sessionstable is where sealed sessions belong. Missing: the code exchange, and state/session stores that survive a restart βcreateAtprotoOAuthClientcurrently uses in-memory stores, which cannot work on serverless at all. Seeapps/web/app/data/auth.server.ts. - Point the writer at a real PDS. Job posting already runs the real
@linkedat/atprotohelpers; only the transport is fake. - Connect the firehose.
apps/indexer/src/jetstream.tsreturns a subscription config and opens no socket. The event handlers it would feed are written and tested. - Move the index to Postgres. Implement the two repository interfaces; the web app does not change.
- Then profile, experience, and resume publishing, which all follow the job pattern.
Known hazards #
LINKEDAT_BASE_URLends up inside published records. When a job has no external canonical URL, LinkedAt derivessourceUrifrom this value and publishes it. Get it wrong in production and every job record permanently carries a wrong URL. It defaults tohttp://localhost:3000.- The in-memory index is per-process. State resets on restart and is not shared between processes. Netlify deploys will behave as though nothing was ever posted.
listPublicProfilesis unpaginated and/peoplefilters in application code. Fine against fixtures, not against a real index.
Local Setup #
pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm --filter @linkedat/web test:e2e # first run: pnpm exec playwright install chromium
Copy .env.example to .env and fill in DATABASE_URL, LINKEDAT_BASE_URL, OAuth settings, and the session secret before running against real services. None of them are required for the app to boot, because nothing is wired to real services yet.
pnpm dev
pnpm indexer:dev
The web app runs on React Router v7 Framework Mode with SSR through Vite.
Layout #
apps/webβ React Router app: route loaders and actions, public pages, the in-memory index and its stand-ins underapp/data/.apps/indexerβ record normalization handlers, and a Jetstream subscription that does not yet subscribe.packages/atprotoβ OAuth client, identity resolution, record read/write adapters, schema.org projection.packages/dbβ Postgres schema, migration script, repository interfaces and their in-memory implementations.packages/lexiconsβ pinned lexicons, TypeScript validators, and the drift check between them.docsβ protocol research, namespace policy, schema mapping decisions.
Protocol Decisions #
Profile data is stored in Sifa-compatible records. Job listings are stored as app.xptracker.job.listing records with the required XPTracker fields: title, company, sourceUri, and createdAt. When a LinkedAt-native job has no external canonical URL, LinkedAt picks the rkey first, derives the public LinkedAt job URL from it, and publishes the record once with sourceUri already pointing at itself. There is no second write.
Resume uploads and JSON Resume import metadata live in the sidecar collection app.linkedat.profile.resume. JSON Resume data maps into Sifa-compatible records where possible. PDF text is not indexed.
Lexicon drift #
packages/lexicons holds each schema twice: the pinned JSON documents, and hand-written TypeScript validators that run on every indexer event. Nothing generates one from the other, so pnpm --filter @linkedat/lexicons validate (also run by pnpm test) compares them β it parses every pinned document with @atproto/lexicon and checks that both validators reach the same verdict on a table of records.
Two kinds of disagreement are distinguished. For LinkedAt-owned schemas the validators must agree exactly. For upstream schemas the TypeScript validator may be stricter, because ATProto knownValues is advisory and does not reject unlisted values, while LinkedAt only renders values it knows. It must never be more permissive. Cases are tagged accordingly in scripts/validate.ts.
Refresh pinned upstream lexicons with pnpm --filter @linkedat/lexicons fetch:external, then run validate to see what moved.
Public Data Warning #
ATProto repository records and blobs are public. LinkedAt UI copy must treat upload and import as publishing, not as private storage. Discoverability controls such as noindex are web-rendering controls only; they are not privacy promises.
Future Job Lexicon Decision #
If LinkedAt later needs structured salary, hiring organization objects, applicant location requirements, a direct apply URL separate from the source URL, or verified employer claims, the first step is to propose compatible extensions upstream to XPTracker. Create app.linkedat.job.* only if upstream coordination is not viable after real usage.
License #
MIT. See LICENSE.
The pinned lexicons under packages/lexicons/external/ are copies of schemas owned by the Sifa and XPTracker projects and are not covered by this license.