diff --git a/.env.example b/.env.example index 1e12e34..a5e4f43 100644 --- a/.env.example +++ b/.env.example @@ -66,13 +66,14 @@ LEX_VALIDATION_FAIL_ON_ERROR=true # OAuth is a public client (DPoP, token_endpoint_auth_method="none") — there is # NO OAUTH_PRIVATE_KEY to configure; nothing to set. # ===================================================================== -# PUBLIC_URL=https://askimut.example.com # drives OAuth client_id + Secure cookie +# PUBLIC_URL=https://askimut.at # drives OAuth client_id + Secure cookie # TURSO_DATABASE_URL=libsql://askimut-.turso.io # TURSO_AUTH_TOKEN= # AT_PROTOCOL_ENABLED=true # HOST=0.0.0.0 # PORT=3000 -# +# LEXICON_AUTHORITY_IDENTIFIER=your-handle.bsky.social # must match _lexicon.askimut.at DNS +# LEXICON_AUTHORITY_PASSWORD= # Ingester container only: # JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe # PLC_URL=https://plc.directory # optional; backfill DID resolution (default shown) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9044dba..5d13f7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ RUN corepack enable COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . -RUN pnpm build && pnpm build:ingester && pnpm build:migrate +RUN pnpm build && pnpm build:ingester && pnpm build:migrate && pnpm build:publish-lexicons # --- runtime stage --- # Debian/glibc mandatory: @libsql/linux-x64-gnu native binding is in .output. Not Alpine. @@ -14,6 +14,8 @@ ENV NODE_ENV=production WORKDIR /app COPY --from=build /app/.output ./.output COPY --from=build /app/drizzle ./drizzle +COPY --from=build /app/lexicons ./lexicons +COPY --from=build /app/lexicons.json ./lexicons.json COPY scripts/docker-entrypoint.sh /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh \ && mkdir -p /app/logs \ diff --git a/docker-compose.yaml b/docker-compose.yaml index d208041..f21d809 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,25 +5,10 @@ x-logging: &default-logging max-file: "5" services: - migrate: - build: - context: . - dockerfile: Dockerfile - environment: - - ASKIMUT_SERVICE=migrate - - TURSO_DATABASE_URL=${TURSO_DATABASE_URL:?} - - TURSO_AUTH_TOKEN=${TURSO_AUTH_TOKEN:?} - logging: *default-logging - exclude_from_hc: true - restart: "no" - web: build: context: . dockerfile: Dockerfile - depends_on: - migrate: - condition: service_completed_successfully environment: - ASKIMUT_SERVICE=web - HOST=0.0.0.0 @@ -50,7 +35,7 @@ services: interval: 30s timeout: 5s retries: 3 - start_period: 10s + start_period: 60s restart: unless-stopped ingester: diff --git a/docs/adr/0002-shared-answer-records.md b/docs/adr/0002-shared-answer-records.md index 88d4c4b..910b56e 100644 --- a/docs/adr/0002-shared-answer-records.md +++ b/docs/adr/0002-shared-answer-records.md @@ -6,9 +6,9 @@ Many askers ask the same thing. Rather than copying answer content per question This requires three separate lexicon records on PDSs: -- `com.askimut.question` — the question record (unchanged from current). -- `com.askimut.answer_content` — the answer content record: the recipient's actual reply text. Askimut-native only; can exist before any question is linked to it. Fields: `content`, `authorDid`, `createdAt`. -- `com.askimut.answer` — the answer (link) record: connects one question to one answer content. One per question. Fields: `questionUri`, `answerContentUri`, `authorDid`, `createdAt`. The `answerContentUri` may point to a `com.askimut.answer_content` record or to an external record (bsky post, standard.site post, etc.). +- `at.askimut.question` — the question record (unchanged from current). +- `at.askimut.answer_content` — the answer content record: the recipient's actual reply text. Askimut-native only; can exist before any question is linked to it. Fields: `content`, `authorDid`, `createdAt`. +- `at.askimut.answer` — the answer (link) record: connects one question to one answer content. One per question. Fields: `questionUri`, `answerContentUri`, `authorDid`, `createdAt`. The `answerContentUri` may point to a `at.askimut.answer_content` record or to an external record (bsky post, standard.site post, etc.). The database uses three normalized tables (`questions`, `answer_contents`, `answers`) mirroring the three lexicons. The grouping query joins `answers` by `answerContentUri` to find all questions sharing the same answer content. CIDs are stored on all three tables for change detection. @@ -23,28 +23,28 @@ Each question gets its own answer record with duplicated content. No new lexicon ### Option B — Shared answer record with array of question URIs (rejected) -One `com.askimut.answer` record contains the content plus an array of `questionUri` values it answers. +One `at.askimut.answer` record contains the content plus an array of `questionUri` values it answers. - **Pro**: single record per answer content; no separate link lexicon. - **Con**: editing the array (adding/removing a question) rewrites the entire record. Less flexible for future evolution. Does not align with PDS immutability semantics (data at a given DID is immutable; appending to an array is a full record replacement). ### Option C — Shared record with separate link lexicon (original ADR-0002, superseded) -Three lexicons: `com.askimut.question`, `com.askimut.answer` (the content), `com.askimut.questionAnswerLink` (the link). +Three lexicons: `at.askimut.question`, `at.askimut.answer` (the content), `at.askimut.questionAnswerLink` (the link). - **Pro**: clean separation of content and link; content is shared; link records are lightweight. -- **Con**: the naming is confusing — `com.askimut.answer` is the content, not the answer (link), which contradicts the domain model in CONTEXT.md where "Answer" is defined as the link. The `questionAnswerLink` name is redundant with the domain term "Answer." Three records to create when answering a question for the first time (content + link + the question already exists). +- **Con**: the naming is confusing — `at.askimut.answer` is the content, not the answer (link), which contradicts the domain model in CONTEXT.md where "Answer" is defined as the link. The `questionAnswerLink` name is redundant with the domain term "Answer." Three records to create when answering a question for the first time (content + link + the question already exists). ### Option D — Three lexicons matching the domain model (chosen) -Three lexicons: `com.askimut.question`, `com.askimut.answer_content` (the content), `com.askimut.answer` (the link). +Three lexicons: `at.askimut.question`, `at.askimut.answer_content` (the content), `at.askimut.answer` (the link). -- **Pro**: naming matches CONTEXT.md exactly — "Answer" is the link, "Answer Content" is the content. `answer_content` can exist before any question (anterior). `answer` is a thin link record (4 fields). External content is supported by pointing `answerContentUri` at any at-uri, not just `com.askimut.answer_content`. The grouping is a pure DB-side join — the protocol never "groups," it just has link records that happen to point to the same content. +- **Pro**: naming matches CONTEXT.md exactly — "Answer" is the link, "Answer Content" is the content. `answer_content` can exist before any question (anterior). `answer` is a thin link record (4 fields). External content is supported by pointing `answerContentUri` at any at-uri, not just `at.askimut.answer_content`. The grouping is a pure DB-side join — the protocol never "groups," it just has link records that happen to point to the same content. - **Con**: three lexicons instead of two (more ingester paths, more PDS writes when creating new content). The `answer` record name is counterintuitive without reading CONTEXT.md (one might expect `answer` to contain the answer text). Breaking change from the current single-`answer`-record model. ## External answer content -When `answerContentUri` points to an external record (bsky post, standard.site post), no `com.askimut.answer_content` PDS record is created. The app fetches the external content at link-creation time and stores it in the `answer_contents` table as a cache. A stale-while-revalidate (SWR) pattern keeps the cache fresh: the page displays cached content immediately, then triggers a background re-fetch if `lastFetchedAt` is older than a threshold. The re-fetch compares the external record's CID to the stored CID; if unchanged, only `lastFetchedAt` is updated. If changed, the content and CID are updated and the query is revalidated. +When `answerContentUri` points to an external record (bsky post, standard.site post), no `at.askimut.answer_content` PDS record is created. The app fetches the external content at link-creation time and stores it in the `answer_contents` table as a cache. A stale-while-revalidate (SWR) pattern keeps the cache fresh: the page displays cached content immediately, then triggers a background re-fetch if `lastFetchedAt` is older than a threshold. The re-fetch compares the external record's CID to the stored CID; if unchanged, only `lastFetchedAt` is updated. If changed, the content and CID are updated and the query is revalidated. This means the `answer_contents` table is a hybrid: a pure PDS index for askimut-native rows (ingested from the firehose, rebuildable from the Atmosphere) and a cache for external rows (fetched on demand, rebuildable by re-fetching). This is consistent with ADR-0001: external rows are recoverable from the network (the external platform), just not from the askimut firehose. @@ -59,6 +59,6 @@ This means the `answer_contents` table is a hybrid: a pure PDS index for askimut ## History -This ADR was originally written with Option C (three lexicons: `question`, `answer`=content, `questionAnswerLink`=link). That model was never implemented — the shipped code used a simpler denormalized model with inline `answer_*` columns on the `questions` table and a single `com.askimut.answer` lexicon carrying both `content` and `questionUri`. +This ADR was originally written with Option C (three lexicons: `question`, `answer`=content, `questionAnswerLink`=link). That model was never implemented — the shipped code used a simpler denormalized model with inline `answer_*` columns on the `questions` table and a single `at.askimut.answer` lexicon carrying both `content` and `questionUri`. The original Option C was superseded by Option D after a grilling session clarified that the domain model in CONTEXT.md defines "Answer" as the link and "Answer Content" as the content. Option D aligns the lexicon names with the domain vocabulary, eliminating the naming confusion of Option C. diff --git a/docs/adr/0004-jetstream-as-sync-boundary.md b/docs/adr/0004-jetstream-as-sync-boundary.md index 57eed7a..67ca619 100644 --- a/docs/adr/0004-jetstream-as-sync-boundary.md +++ b/docs/adr/0004-jetstream-as-sync-boundary.md @@ -2,11 +2,11 @@ The Ingester consumes AT Protocol repository events from Jetstream, Bluesky's JSON fan-out over the firehose, and nothing else. There is one ingestion substrate, one code path, and one thing that changes between local development and production: `JETSTREAM_URL`. -Jetstream is a thin substrate. It delivers commit, identity, and account events as plain JSON over a WebSocket, filtered server-side by `wantedCollections=com.askimut.*` (and by `wantedDids` on a second connection for Bluesky AMA post discovery, see [ADR-0008](./0008-bsky-ama-intake.md)). It offers no cryptographic verification, no repo discovery, and no backfill. Everything above that line is the app's: +Jetstream is a thin substrate. It delivers commit, identity, and account events as plain JSON over a WebSocket, filtered server-side by `wantedCollections=at.askimut.*` (and by `wantedDids` on a second connection for Bluesky AMA post discovery, see [ADR-0008](./0008-bsky-ama-intake.md)). It offers no cryptographic verification, no repo discovery, and no backfill. Everything above that line is the app's: - **Cursor.** The Ingester persists `time_us` into `ingester_state` and reconnects with the cursor rewound five seconds, so the reconnect gap is covered by replay rather than lost. See [ADR-0005](./0005-at-least-once-idempotent-ingestion.md) for why replaying is safe. -- **Backfill.** `src/lib/backfill.ts` resolves a DID to its PDS and pages `com.atproto.repo.listRecords` for every `com.askimut.*` collection, feeding each record through the same handlers with `live: false`. Every commit enqueues its repo once per process lifetime, so a repo whose records predate the cursor is hydrated the first time it is seen. -- **Scope.** Jetstream's collection filter is global, so `com.askimut.*` records arrive from the entire network, not from a known set of repos. Scope is enforced in the handlers instead: a Question is indexed only if its Recipient already has a `users` row with `questionsOpen` set, and identity and account events are applied only for DIDs already in `users`. +- **Backfill.** `src/lib/backfill.ts` resolves a DID to its PDS and pages `com.atproto.repo.listRecords` for every `at.askimut.*` collection, feeding each record through the same handlers with `live: false`. Every commit enqueues its repo once per process lifetime, so a repo whose records predate the cursor is hydrated the first time it is seen. +- **Scope.** Jetstream's collection filter is global, so `at.askimut.*` records arrive from the entire network, not from a known set of repos. Scope is enforced in the handlers instead: a Question is indexed only if its Recipient already has a `users` row with `questionsOpen` set, and identity and account events are applied only for DIDs already in `users`. - **Trust.** Records are validated against the generated lexicons before they touch the database. Nothing verifies that the repo they came from actually signed them. The Ingester is a full indexer, not a confirmation stamp. Questions are inserted if unknown or confirmed if they exist as unconfirmed writes; answer content records are upserted; answer link records connect a Question to its content, and are stashed in `pending_answers` when either side has not arrived yet (see [ADR-0002](./0002-shared-answer-records.md)); delete events soft-delete; identity and account events update handle and status. @@ -15,7 +15,7 @@ Only OAuth login creates a `users` row. Neither a profile record nor an identity ## Considered Options -- **Tap (`@atproto/tap`)** — a single-tenant Go service that subscribes to a Relay and emits filtered, cryptographically verified events, with MST integrity checks, identity signature verification, automatic backfill, desync recovery, and repo discovery via `TAP_SIGNAL_COLLECTION=com.askimut.profile`. It was adopted for a period and then removed, and the reasoning is worth keeping because Tap is a good tool and the temptation will recur. +- **Tap (`@atproto/tap`)** — a single-tenant Go service that subscribes to a Relay and emits filtered, cryptographically verified events, with MST integrity checks, identity signature verification, automatic backfill, desync recovery, and repo discovery via `TAP_SIGNAL_COLLECTION=at.askimut.profile`. It was adopted for a period and then removed, and the reasoning is worth keeping because Tap is a good tool and the temptation will recur. What it genuinely offered: verified events, and backfill without writing any. What it cost: a second Go service with its own relational database to deploy and operate, and — because the local PDS requires authentication for `com.atproto.sync.listReposByCollection` and Tap cannot send relay credentials — a divergent repo-discovery path in local development. It never displaced Jetstream in production, so the app carried two substrates and two ingester code paths behind an `INGESTER_SOURCE` switch, with the dev-only path dynamically imported so the production bundle would not resolve it. @@ -25,6 +25,6 @@ Only OAuth login creates a `users` row. Neither a profile record nor an identity ## Consequences -- The database contains records written by any client to `com.askimut.*` in any repo on the network, subject to the handler-side gates above. These are indexed the same as app-written records. +- The database contains records written by any client to `at.askimut.*` in any repo on the network, subject to the handler-side gates above. These are indexed the same as app-written records. - `INGESTER_SOURCE` does not exist. There is one source, so a variable naming it would read as a choice that is not on offer. - Losing more than the five-second cursor rewind (a long Ingester outage, a Jetstream backlog eviction) leaves a gap that only backfill closes, and backfill for a given repo runs once per process lifetime on the first commit seen. A restart re-arms it; without a restart, a repo already marked `done` in `backfilled_repos` is not revisited. diff --git a/docs/adr/0008-bsky-ama-intake.md b/docs/adr/0008-bsky-ama-intake.md index 72adb82..172dc83 100644 --- a/docs/adr/0008-bsky-ama-intake.md +++ b/docs/adr/0008-bsky-ama-intake.md @@ -1,6 +1,6 @@ # Bluesky AMA intake: SWR on render, records only from user-triggered actions -Askimut imports Bluesky AMA threads: an AMA Root (a Recipient's first-level bsky post, discovered via #AMA tag facet, a link facet to the Recipient's own askimut profile, or an explicit `com.askimut.amaRoot` record) whose first-level replies become Questions. This ADR records the intake architecture and the principles that shape it. +Askimut imports Bluesky AMA threads: an AMA Root (a Recipient's first-level bsky post, discovered via #AMA tag facet, a link facet to the Recipient's own askimut profile, or an explicit `at.askimut.amaRoot` record) whose first-level replies become Questions. This ADR records the intake architecture and the principles that shape it. ## Decision 1 — Intake is stale-while-revalidate on render, not a background poller @@ -22,9 +22,9 @@ Root _discovery_ still needs a stream — you cannot revalidate a root you don't The app writes a record to a user's PDS only when that user performed the action in the app with a live OAuth session. Everything the ingester merely _observes_ on the Atmosphere becomes a DB row, never a record: -- **Manual AMA marks** → `com.askimut.amaRoot` records on the Recipient's PDS (user intent, session available; un-marking deletes the record but already-imported Questions stay — AMA Root status is sticky). -- **Discovery toggles** → fields on `com.askimut.profile` (same pattern as `questionsOpen`; the ingester sees toggle changes and updates `wantedDids`). -- **Tag/URL-discovered roots, bsky Questions, Derived Answers** → DB rows only. A bsky Question's source of truth is the asker's own `app.bsky.feed.post`; `answer.questionUri` points directly at that foreign at-uri. No mirror `com.askimut.question` record is written — it would claim an authorship the Recipient doesn't have and drift from the original. +- **Manual AMA marks** → `at.askimut.amaRoot` records on the Recipient's PDS (user intent, session available; un-marking deletes the record but already-imported Questions stay — AMA Root status is sticky). +- **Discovery toggles** → fields on `at.askimut.profile` (same pattern as `questionsOpen`; the ingester sees toggle changes and updates `wantedDids`). +- **Tag/URL-discovered roots, bsky Questions, Derived Answers** → DB rows only. A bsky Question's source of truth is the asker's own `app.bsky.feed.post`; `answer.questionUri` points directly at that foreign at-uri. No mirror `at.askimut.question` record is written — it would claim an authorship the Recipient doesn't have and drift from the original. The forcing constraint is also the design signal: the background ingester has no OAuth session, and an interaction the user didn't trigger has no business producing a record in their repo. Rebuild story (ADR-0001) holds: observed rows are re-derivable from the Atmosphere; discovered roots rebuild by re-scanning enabled users' `app.bsky.feed.post` via `listRecords`. @@ -42,7 +42,7 @@ An Unavailable Asker (blocked/deleted/suspended on bsky) gets one unified treatm Three things the implementation settled that this ADR did not anticipate. -**Discovery needs its own Jetstream connection.** Decision 1 says the consumer "adds `app.bsky.feed.post` with `wantedDids`". `wantedDids` is per-connection, not per-collection, so applying it to the existing `com.askimut.*` stream would silently drop records from every DID not in the discovery set — including Askers, whose DIDs are unknowable in advance. Discovery therefore runs on a second connection and the `com.askimut.*` stream stays unfiltered. +**Discovery needs its own Jetstream connection.** Decision 1 says the consumer "adds `app.bsky.feed.post` with `wantedDids`". `wantedDids` is per-connection, not per-collection, so applying it to the existing `at.askimut.*` stream would silently drop records from every DID not in the discovery set — including Askers, whose DIDs are unknowable in advance. Discovery therefore runs on a second connection and the `at.askimut.*` stream stays unfiltered. **Pausing gates asking, not answering.** `questionsOpen` closed suppresses new bsky Question intake and content updates only. Derived Answers and Unavailable Asker transitions keep flowing: a Recipient who closes their inbox and then works through the backlog on Bluesky should still have those Answers recorded, and freezing moderation state while closed would leave a newly blocked Asker's content visible — the exact read-around this ADR forbids. diff --git a/docs/adr/0009-no-anonymous-questions.md b/docs/adr/0009-no-anonymous-questions.md index 16a8603..8088a2a 100644 --- a/docs/adr/0009-no-anonymous-questions.md +++ b/docs/adr/0009-no-anonymous-questions.md @@ -1,14 +1,14 @@ # Askimut does not support anonymous questions -Every Question is attributed to its Asker's DID. The product once carried an `anonymous` boolean on `com.askimut.question` and a matching checkbox in the ask form, but that flag was display-only fiction: `authorDid` is required on the record, and the record sits publicly on the Asker's own PDS, so anyone reading the Atmosphere could see who asked. Keeping a toggle that promised privacy the architecture never delivered was worse than having no feature at all — a user could rely on it. This ADR removes the flag end to end and records why the alternatives were not carried forward. +Every Question is attributed to its Asker's DID. The product once carried an `anonymous` boolean on `at.askimut.question` and a matching checkbox in the ask form, but that flag was display-only fiction: `authorDid` is required on the record, and the record sits publicly on the Asker's own PDS, so anyone reading the Atmosphere could see who asked. Keeping a toggle that promised privacy the architecture never delivered was worse than having no feature at all — a user could rely on it. This ADR removes the flag end to end and records why the alternatives were not carried forward. ## Decision -Askimut does not support anonymous questions. A Question is always attributed to its Asker's DID, on the lexicon, in the index, and in the UI. The `anonymous` property is removed from `com.askimut.question`; the ask form no longer offers a checkbox; the database column is dropped. +Askimut does not support anonymous questions. A Question is always attributed to its Asker's DID, on the lexicon, in the index, and in the UI. The `anonymous` property is removed from `at.askimut.question`; the ask form no longer offers a checkbox; the database column is dropped. ### Considered Options -- **DB-only, off-Atmosphere questions** — store the question in the index without publishing a `com.askimut.question` record, and hide the Asker in the UI. Pros: genuine display anonymity on Askimut without touching the Asker's PDS. Cons: breaks ADR-0001's invariant that the PDS is the source of truth and the DB is a rebuildable index — this creates a class of Question that cannot be rebuilt from the Atmosphere after data loss. Rejected: a non-portable, non-rebuildable Question contradicts the product thesis. +- **DB-only, off-Atmosphere questions** — store the question in the index without publishing a `at.askimut.question` record, and hide the Asker in the UI. Pros: genuine display anonymity on Askimut without touching the Asker's PDS. Cons: breaks ADR-0001's invariant that the PDS is the source of truth and the DB is a rebuildable index — this creates a class of Question that cannot be rebuilt from the Atmosphere after data loss. Rejected: a non-portable, non-rebuildable Question contradicts the product thesis. - **Display-only anonymity with honest framing** — keep publishing the full record but label the UI "hidden on Askimut, visible on the Atmosphere." Pros: technically honest, no new storage model. Cons: costs permanent UI surface to explain a guarantee that isn't one; users who miss the disclaimer still believe they are anonymous. Rejected: honest framing does not fix the underlying false promise, and the UX tax buys little. diff --git a/docs/adr/0010-bsky-ama-dev-testing.md b/docs/adr/0010-bsky-ama-dev-testing.md index eb0a916..d341181 100644 --- a/docs/adr/0010-bsky-ama-dev-testing.md +++ b/docs/adr/0010-bsky-ama-dev-testing.md @@ -15,7 +15,7 @@ The smoke test uses a dedicated public Recipient account, a distinct public Aske - the public Jetstream accepting our post-discovery subscription (`app.bsky.feed.post` filtered by `wantedDids`) and the subscriber-sourced options update that follows a discovery-toggle change; - OAuth against a real PDS through the loopback client, which is the configuration path that matches neither production nor local development; - the public AppView's real `getPostThread` response shape, which is the only check that our thread fixtures still resemble reality; and -- a Recipient's `com.askimut.profile` toggle round-tripping through the public network into the Ingester's wanted-DID set. +- a Recipient's `at.askimut.profile` toggle round-tripping through the public network into the Ingester's wanted-DID set. It is guided rather than automated because account login, posting, and public indexing are the behavior under test. Both Askers and Recipients are ordinary public accounts: Askimut never contacts an Asker's PDS, so hosting the Asker elsewhere adds setup cost without adding a code path. Verifying that a self-hosted repository is publicly indexed is a worthwhile separate exercise, not a prerequisite for this one. diff --git a/docs/adr/0012-standard-site-verification-and-identity.md b/docs/adr/0012-standard-site-verification-and-identity.md index baa91f3..0051647 100644 --- a/docs/adr/0012-standard-site-verification-and-identity.md +++ b/docs/adr/0012-standard-site-verification-and-identity.md @@ -4,7 +4,7 @@ Status: accepted Standard.site documents become Answer Content through a narrow compatibility profile built on the registry in [ADR-0011](./0011-typed-answer-content-origin-registry.md). A Recipient links either a `site.standard.document` AT-URI or an HTTPS article page whose server-rendered HTML `` advertises exactly one such document backlink. Both inputs converge on the same normalized identity and pass the same verification chain. -**Identity.** The canonical Answer Content identifier is a DID-authority AT-URI whose collection is exactly `site.standard.document`. Handle-authority AT-URIs are accepted as input only after bidirectional handle resolution and normalization to a DID. The fetched CID is stored separately as the currently accepted record revision; it can change without changing identity. The derived canonical HTTPS article URL is cached for attribution and re-verification; it is not identity. No `com.askimut.answerContent` record is created — the Answer link points directly at the Standard.site document AT-URI ([ADR-0002](./0002-shared-answer-records.md)). +**Identity.** The canonical Answer Content identifier is a DID-authority AT-URI whose collection is exactly `site.standard.document`. Handle-authority AT-URIs are accepted as input only after bidirectional handle resolution and normalization to a DID. The fetched CID is stored separately as the currently accepted record revision; it can change without changing identity. The derived canonical HTTPS article URL is cached for attribution and re-verification; it is not identity. No `at.askimut.answerContent` record is created — the Answer link points directly at the Standard.site document AT-URI ([ADR-0002](./0002-shared-answer-records.md)). **Ownership.** The document AT-URI authority DID must equal the Recipient DID. Publication owners and `contributors` metadata do not grant ownership. diff --git a/docs/adr/0015-granular-oauth-permissions.md b/docs/adr/0015-granular-oauth-permissions.md index ec18d2e..ff0e610 100644 --- a/docs/adr/0015-granular-oauth-permissions.md +++ b/docs/adr/0015-granular-oauth-permissions.md @@ -1,16 +1,16 @@ # Granular OAuth permissions via a published permission set -Askimut OAuth currently requests `atproto transition:generic`, which grants broad write access to the user's PDS. Replace it with granular scopes: a published `com.askimut.appPermissions` permission set for all five `com.askimut.*` record collections, plus an inline `repo:app.bsky.feed.post?action=create` grant for Cross-post replies. One unified scope at login — no split between Asker and Recipient flows. +Askimut OAuth currently requests `atproto transition:generic`, which grants broad write access to the user's PDS. Replace it with granular scopes: a published `at.askimut.appPermissions` permission set for all five `at.askimut.*` record collections, plus an inline `repo:app.bsky.feed.post?action=create` grant for Cross-post replies. One unified scope at login — no split between Asker and Recipient flows. ## Decision 1 — Unified scope, permission set for own collections, inline for Bluesky The OAuth scope string is: ``` -atproto include:com.askimut.appPermissions repo:app.bsky.feed.post?action=create +atproto include:at.askimut.appPermissions repo:app.bsky.feed.post?action=create ``` -`com.askimut.appPermissions` bundles full CRUD on `com.askimut.question`, `com.askimut.answerContent`, `com.askimut.answer`, `com.askimut.profile`, and `com.askimut.amaRoot`. Bluesky cross-post stays outside the set because permission sets may only grant collections at or below their own NSID group — `app.bsky.feed.post` cannot live inside `com.askimut.*`. +`at.askimut.appPermissions` bundles full CRUD on `at.askimut.question`, `at.askimut.answerContent`, `at.askimut.answer`, `at.askimut.profile`, and `at.askimut.amaRoot`. Bluesky cross-post stays outside the set because permission sets may only grant collections at or below their own NSID group — `app.bsky.feed.post` cannot live inside `at.askimut.*`. Splitting asker vs recipient scopes was rejected: the app has a single login form; role-aware OAuth would require separate buttons and re-auth when a user switches from asking to answering. @@ -19,14 +19,14 @@ No `blob:*/*` scope. Askimut does not upload images or video; Answer Content is ### Considered Options - **On-demand Bluesky scope at first Cross-post.** Pros: minimal initial consent; follows the rule of thumb to request other apps' permissions as needed. Cons: every Recipient's first Cross-post hits a re-auth interrupt; scope-failure handling already exists but would become a common path rather than an edge case. Rejected for now — Cross-post is core Recipient workflow, create-only on `app.bsky.feed.post` is low risk, and two readable permission groups at login ("Askimut" + Bluesky post create) is acceptable. Revisit if users push back on granting Bluesky write at sign-in. -- **Inline `repo:com.askimut.*` scopes without a permission set.** Works mechanically but shows a confusing "Repository" label per collection in the consent dialog. Rejected in favour of a permission set with human-readable `title` and `detail`. +- **Inline `repo:at.askimut.*` scopes without a permission set.** Works mechanically but shows a confusing "Repository" label per collection in the consent dialog. Rejected in favour of a permission set with human-readable `title` and `detail`. - **Keep `transition:generic`.** Rejected — over-broad, erodes trust, and contradicts AT Protocol OAuth direction. ## Decision 2 — Full CRUD on own collections; evolve via the same permission set -Grant create, update, and delete on all five `com.askimut.*` collections even though today's code only creates most of them and deletes `amaRoot`. Future features (retract Answer, delete Question) should not require a scope widening. Granular per-action filters inside the set do not improve the consent dialog — the user sees the set title, not per-action rows. +Grant create, update, and delete on all five `at.askimut.*` collections even though today's code only creates most of them and deletes `amaRoot`. Future features (retract Answer, delete Question) should not require a scope widening. Granular per-action filters inside the set do not improve the consent dialog — the user sees the set title, not per-action rows. -When a new `com.askimut.*` collection is added, update `com.askimut.appPermissions` in place. Do not mint sibling permission sets. The scope string stays unchanged; existing approvers pick up new grants after the authorization server's permission-set cache refreshes (~30 minutes per observed behaviour). Graceful re-auth on write failure remains the fallback if a user acts before propagation completes. +When a new `at.askimut.*` collection is added, update `at.askimut.appPermissions` in place. Do not mint sibling permission sets. The scope string stays unchanged; existing approvers pick up new grants after the authorization server's permission-set cache refreshes (~30 minutes per observed behaviour). Graceful re-auth on write failure remains the fallback if a user acts before propagation completes. ## Decision 3 — No production migration path needed diff --git a/docs/deploy.md b/docs/deploy.md index 44208fe..4365823 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,13 +1,14 @@ # Deploying askimut (Coolify VPS) -Askimut runs as three Compose services built from one image (`Dockerfile`): +Askimut runs as two Compose services built from one image (`Dockerfile`): -- **migrate** — one-shot job that applies pending Drizzle SQL migrations to Turso - before web starts (`node .output/server/migrate.mjs`). - **web** — SolidStart server (`node .output/server/index.mjs`), port 3000. + Applies pending Drizzle SQL migrations to Turso on startup before listening + (`node .output/server/migrate.mjs` in the entrypoint), then publishes lexicons + when authority credentials are configured (`node .output/server/publish-lexicons.mjs`). - **ingester** — long-lived firehose consumer (`node .output/server/ingester.mjs`). Consumes Bluesky **Jetstream** filtered to - `com.askimut.*` and backfills each repo's historical records via + `at.askimut.*` and backfills each repo's historical records via `com.atproto.repo.listRecords`. Jetstream delivers identity/account events for the whole network, so the ingester applies them only for DIDs already in the `users` table (users are created by the web app at OAuth signup). @@ -19,9 +20,14 @@ Coolify's built-in Traefik proxy — no Caddy/nginx in the compose file. 1. Create a Turso database and an auth token. 2. Set `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` in Coolify — migrations run - automatically on each deploy via the one-shot **migrate** service (before web - starts). Migration files live in `drizzle/` and are committed (including - `drizzle/meta/`). + automatically on each deploy when the **web** container starts (entrypoint + runs `migrate.mjs` before the server). Migration files live in `drizzle/` + and are committed (including `drizzle/meta/`). + + Do **not** add a separate one-shot `migrate` Compose service on Coolify: an + exited migrate container can mark the whole stack unhealthy and return **502 + Bad Gateway** for your domain even when migration succeeded + ([coolify#6591](https://github.com/coollabsio/coolify/issues/6591)). When you change the schema locally: @@ -49,12 +55,14 @@ pnpm db:migrate | Var | Value | Scope | Notes | | --------------------- | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | -| `PUBLIC_URL` | `https://askimut.example.com` | web | **must be `https://`** — drives OAuth `client_id` + Secure cookie. `http://` only works on localhost. | +| `PUBLIC_URL` | `https://askimut.at` | web | **must be `https://`** — drives OAuth `client_id` + Secure cookie. `http://` only works on localhost. | | `TURSO_DATABASE_URL` | `libsql://askimut-.turso.io` | both | | | `TURSO_AUTH_TOKEN` | (secret) | both | mark secret | | `AT_PROTOCOL_ENABLED` | `true` | web | | | `JETSTREAM_URL` | `wss://jetstream2.us-east.bsky.network/subscribe` | ingester | default in compose | | `HOST` / `PORT` | `0.0.0.0` / `3000` | web | set in compose | +| `LEXICON_AUTHORITY_IDENTIFIER` | Bluesky handle of the `_lexicon.askimut.at` publisher | web | optional — skip publish if unset; OAuth needs it once DNS is live | +| `LEXICON_AUTHORITY_PASSWORD` | app password for that handle | web | mark secret | **Do NOT set** `APP_URL`, `DEV_PDS_URL`, `DEV_PLC_URL`, `JETSTREAM_URL` (dev-only; `DEV_PDS_URL` would enable `allowHttp` and a wrong handleResolver). There is no @@ -87,20 +95,33 @@ After deploy: - [ ] `/` redirect works (pre-existing `middleware.ts` sets `Location` without a 3xx status — confirm behavior in prod) -## 6. Lexicon publication (permission set) +## 6. Lexicon publication + +OAuth requests `include:at.askimut.appPermissions`. All lexicons listed in +`lexicons.json` must be published as `com.atproto.lexicon.schema` records on the +authority DID before login and record writes succeed on the open network. + +**Authority DNS (one-time):** `_lexicon.askimut.at` TXT → `did=` +(the DID of the account below). -OAuth requests `include:com.askimut.appPermissions`. The permission-set lexicon -must be published as a `com.atproto.lexicon.schema` record on the authority DID -before login succeeds — otherwise the authorization server returns `invalid_scope`. +**On each deploy:** when `LEXICON_AUTHORITY_IDENTIFIER` and +`LEXICON_AUTHORITY_PASSWORD` are set on the **web** service, the entrypoint runs +`publish-lexicons.mjs` after migrations (same code path as `pnpm publish:lexicons`). +`putRecord` is idempotent — safe on every startup. -**Authority:** `_lexicon.askimut.app` DNS TXT → publishing DID (must match the -account used below). +If credentials are unset, publish is skipped with a log line; granular OAuth will +fail until DNS + credentials are configured. -**When the permission set changes** (new collection in -`lexicons/com.askimut.appPermissions.json`): +**When lexicon JSON changes:** + +1. Commit updated files under `lexicons/` and `lexicons.json` if needed. +2. Redeploy — publication runs automatically when credentials are set. +3. Allow ~30 minutes for authorization-server permission-set cache refresh after + permission-set changes. +4. Ask existing users to sign out and sign in again if writes fail with + insufficient scope. -1. Commit the updated lexicon and redeploy the app (scope string stays the same). -2. Publish the revised lexicon to the network: +Manual fallback (debugging): ```bash LEXICON_AUTHORITY_IDENTIFIER= \ @@ -108,9 +129,12 @@ LEXICON_AUTHORITY_PASSWORD= \ pnpm publish:lexicons ``` -3. Allow ~30 minutes for authorization-server permission-set cache refresh. -4. Ask existing users to sign out and sign in again if writes fail with - insufficient scope. +Or inside the image: + +```bash +docker compose run --rm -e ASKIMUT_SERVICE=publish-lexicons \ + -e LEXICON_AUTHORITY_IDENTIFIER=... -e LEXICON_AUTHORITY_PASSWORD=... web +``` **Local dev network:** `pnpm dev:network` bootstraps a `lex-authority.test` account, publishes the permission set, and configures the PDS diff --git a/lexicons.json b/lexicons.json index b144788..e037162 100644 --- a/lexicons.json +++ b/lexicons.json @@ -1,11 +1,11 @@ { "version": 1, "lexicons": [ - "com.askimut.question", - "com.askimut.answer", - "com.askimut.answerContent", - "com.askimut.profile", - "com.askimut.amaRoot", - "com.askimut.appPermissions" + "at.askimut.question", + "at.askimut.answer", + "at.askimut.answerContent", + "at.askimut.profile", + "at.askimut.amaRoot", + "at.askimut.appPermissions" ] } diff --git a/lexicons/com.askimut.amaRoot.json b/lexicons/at.askimut.amaRoot.json similarity index 94% rename from lexicons/com.askimut.amaRoot.json rename to lexicons/at.askimut.amaRoot.json index 3799318..d25703b 100644 --- a/lexicons/com.askimut.amaRoot.json +++ b/lexicons/at.askimut.amaRoot.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.askimut.amaRoot", + "id": "at.askimut.amaRoot", "defs": { "main": { "type": "record", diff --git a/lexicons/com.askimut.answer.json b/lexicons/at.askimut.answer.json similarity index 89% rename from lexicons/com.askimut.answer.json rename to lexicons/at.askimut.answer.json index ad595fc..1420177 100644 --- a/lexicons/com.askimut.answer.json +++ b/lexicons/at.askimut.answer.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.askimut.answer", + "id": "at.askimut.answer", "defs": { "main": { "type": "record", @@ -17,7 +17,7 @@ "answerContentUri": { "type": "string", "format": "at-uri", - "description": "AT-URI of the answer content (com.askimut.answer_content or external record)" + "description": "AT-URI of the answer content (at.askimut.answer_content or external record)" }, "authorDid": { "type": "string", diff --git a/lexicons/com.askimut.answerContent.json b/lexicons/at.askimut.answerContent.json similarity index 95% rename from lexicons/com.askimut.answerContent.json rename to lexicons/at.askimut.answerContent.json index bb49f32..bbc3ed8 100644 --- a/lexicons/com.askimut.answerContent.json +++ b/lexicons/at.askimut.answerContent.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.askimut.answerContent", + "id": "at.askimut.answerContent", "defs": { "main": { "type": "record", diff --git a/lexicons/com.askimut.appPermissions.json b/lexicons/at.askimut.appPermissions.json similarity index 61% rename from lexicons/com.askimut.appPermissions.json rename to lexicons/at.askimut.appPermissions.json index 40515e4..d315ee9 100644 --- a/lexicons/com.askimut.appPermissions.json +++ b/lexicons/at.askimut.appPermissions.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.askimut.appPermissions", + "id": "at.askimut.appPermissions", "defs": { "main": { "type": "permission-set", @@ -11,11 +11,11 @@ "type": "permission", "resource": "repo", "collection": [ - "com.askimut.question", - "com.askimut.answerContent", - "com.askimut.answer", - "com.askimut.profile", - "com.askimut.amaRoot" + "at.askimut.question", + "at.askimut.answerContent", + "at.askimut.answer", + "at.askimut.profile", + "at.askimut.amaRoot" ] } ] diff --git a/lexicons/com.askimut.profile.json b/lexicons/at.askimut.profile.json similarity index 97% rename from lexicons/com.askimut.profile.json rename to lexicons/at.askimut.profile.json index 6db829f..61b30e7 100644 --- a/lexicons/com.askimut.profile.json +++ b/lexicons/at.askimut.profile.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.askimut.profile", + "id": "at.askimut.profile", "defs": { "main": { "type": "record", diff --git a/lexicons/com.askimut.question.json b/lexicons/at.askimut.question.json similarity index 98% rename from lexicons/com.askimut.question.json rename to lexicons/at.askimut.question.json index 40071bc..1a2e50a 100644 --- a/lexicons/com.askimut.question.json +++ b/lexicons/at.askimut.question.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.askimut.question", + "id": "at.askimut.question", "defs": { "main": { "type": "record", diff --git a/package.json b/package.json index becfaa3..4dc7480 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "build:ingester": "esbuild src/ingester.ts --bundle --format=esm --platform=node --target=node22 --packages=external --alias:~=./src --outfile=.output/server/ingester.mjs", "build:migrate": "esbuild src/migrate.ts --bundle --format=esm --platform=node --target=node22 --external:@libsql/client --outfile=.output/server/migrate.mjs", + "build:publish-lexicons": "esbuild src/publish-lexicons.ts --bundle --format=esm --platform=node --target=node22 --packages=external --alias:~=./src --outfile=.output/server/publish-lexicons.mjs", "start": "node .output/server/index.mjs", "start:ingester": "node .output/server/ingester.mjs", "test": "vitest run", @@ -28,7 +29,7 @@ "dev:network:auto": "node --import tsx/esm scripts/dev-network.ts | node --import tsx/esm scripts/update-env.ts", "dev:network:reset": "tsx scripts/reset-dev-network.ts", "publish:test-qa": "tsx scripts/publish-test-qa.ts", - "publish:lexicons": "tsx scripts/publish-lexicons.ts", + "publish:lexicons": "tsx src/publish-lexicons.ts", "dev:standard-site": "dotenv -e .env -- tsx scripts/dev-standard-site.ts", "db:seed-shared": "dotenv -e .env -- tsx scripts/seed-shared-answer.ts" }, diff --git a/scripts/create-3615etienne-app-password.sh b/scripts/create-3615etienne-app-password.sh new file mode 100755 index 0000000..6a9f2fa --- /dev/null +++ b/scripts/create-3615etienne-app-password.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env sh +# Create an AT Protocol app password for 3615etienne.com. +# +# Usage: +# ./scripts/create-3615etienne-app-password.sh [app-name] +# +# Example: +# ./scripts/create-3615etienne-app-password.sh 3615etienne.com 'your-login-password' askimut +# +# Override PDS (default https://3615etienne.com): +# PDS_URL=https://3615etienne.com ./scripts/create-3615etienne-app-password.sh user pass name + +set -eu + +PDS_URL="${PDS_URL:-https://3615etienne.com}" +APP_PASSWORD_NAME="${3:-my-app}" + +usage() { + echo "Usage: $0 [app-name]" >&2 + echo " user: handle or email (e.g. 3615etienne.com)" >&2 + echo " default app-name: my-app" >&2 + echo " env: PDS_URL (default $PDS_URL)" >&2 + exit 1 +} + +[ "$#" -ge 2 ] || usage + +IDENTIFIER="$1" +ACCOUNT_PASSWORD="$2" + +json_field() { + field="$1" + node -e " + const field = process.argv[1]; + let data = ''; + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => { + let parsed; + try { + parsed = JSON.parse(data); + } catch { + console.error('Invalid JSON response:', data); + process.exit(1); + } + if (parsed.error) { + console.error(parsed.message || parsed.error); + process.exit(1); + } + const value = parsed[field]; + if (value == null || value === '') { + console.error('Missing ' + field + ' in response:', data); + process.exit(1); + } + process.stdout.write(String(value)); + }); + " "$field" +} + +SESSION_BODY=$(node -e " + process.stdout.write(JSON.stringify({ + identifier: process.argv[1], + password: process.argv[2], + })); +" "$IDENTIFIER" "$ACCOUNT_PASSWORD") + +APP_PASSWORD_BODY=$(node -e " + process.stdout.write(JSON.stringify({ name: process.argv[1] })); +" "$APP_PASSWORD_NAME") + +echo "→ createSession ($IDENTIFIER on $PDS_URL)" >&2 +SESSION_JSON=$(curl -sS -X POST "$PDS_URL/xrpc/com.atproto.server.createSession" \ + -H "Content-Type: application/json" \ + -d "$SESSION_BODY") + +ACCESS_JWT=$(printf '%s' "$SESSION_JSON" | json_field accessJwt) || exit 1 + +echo "→ createAppPassword (name=$APP_PASSWORD_NAME)" >&2 +APP_PASSWORD_JSON=$(curl -sS -X POST "$PDS_URL/xrpc/com.atproto.server.createAppPassword" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_JWT" \ + -d "$APP_PASSWORD_BODY") + +APP_PASSWORD=$(printf '%s' "$APP_PASSWORD_JSON" | json_field password) || exit 1 + +echo "$APP_PASSWORD" diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 556b356..f141ee7 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -55,7 +55,12 @@ if [ "$SERVICE" = "ingester" ]; then log "JETSTREAM_URL=${JETSTREAM_URL:-}" fi -if [ "$SERVICE" = "migrate" ]; then +if [ "$missing" -ne 0 ]; then + log "FATAL: missing required env — container will exit" + exit 1 +fi + +run_migrations() { log "Running database migrations..." if [ "$(id -u)" = "0" ]; then runuser -u node -- node .output/server/migrate.mjs @@ -69,12 +74,52 @@ if [ "$SERVICE" = "migrate" ]; then else log "Migrations complete" fi - exit "$code" + return "$code" +} + +run_publish_lexicons() { + require_creds=${1:-0} + if [ -z "${LEXICON_AUTHORITY_IDENTIFIER:-}" ] || [ -z "${LEXICON_AUTHORITY_PASSWORD:-}" ]; then + if [ "$require_creds" = "1" ]; then + log "FATAL: LEXICON_AUTHORITY_IDENTIFIER and LEXICON_AUTHORITY_PASSWORD required" + return 1 + fi + log "SKIP: lexicon publish (set LEXICON_AUTHORITY_IDENTIFIER + LEXICON_AUTHORITY_PASSWORD; also needs _lexicon.askimut.at DNS)" + return 0 + fi + log "Publishing lexicons to the network..." + if [ "$(id -u)" = "0" ]; then + runuser -u node -- node .output/server/publish-lexicons.mjs + code=$? + else + node .output/server/publish-lexicons.mjs + code=$? + fi + if [ "$code" -ne 0 ]; then + log "FATAL: lexicon publish failed with code $code" + else + log "Lexicon publication complete" + fi + return "$code" +} + +# Manual one-shot: docker compose run --rm -e ASKIMUT_SERVICE=migrate web +if [ "$SERVICE" = "migrate" ]; then + run_migrations + exit $? fi -if [ "$missing" -ne 0 ]; then - log "FATAL: missing required env — container will exit" - exit 1 +if [ "$SERVICE" = "publish-lexicons" ]; then + run_publish_lexicons 1 + exit $? +fi + +# Web runs migrations before serving. Avoid a separate one-shot Compose service: +# Coolify treats exited migrate containers as an unhealthy stack and returns 502 +# for the proxied domain even with exclude_from_hc (coollabsio/coolify#6591). +if [ "$SERVICE" = "web" ]; then + run_migrations || exit $? + run_publish_lexicons 0 || exit $? fi log "exec: $*" diff --git a/scripts/lib/lexicon-authority.ts b/scripts/lib/lexicon-authority.ts index fede190..3c59dc0 100644 --- a/scripts/lib/lexicon-authority.ts +++ b/scripts/lib/lexicon-authority.ts @@ -1,17 +1,19 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { TestPds } from "@atproto/dev-env"; import { ServiceProfile } from "@atproto/dev-env/dist/service-profile.js"; -type LexiconDoc = { - lexicon: number; - id: string; - defs: Record; -}; +import { + getPublishedLexiconNsids, + publishAskimutLexicons, + verifyPublishedLexicon, +} from "../../src/lib/lexicon-publish"; -const LEXICON_COLLECTION = "com.atproto.lexicon.schema"; -const REPO_ROOT = join(import.meta.dirname, "../.."); +export { + getPublishedLexiconNsids, + lexiconSchemaRecord, + publishAskimutLexicons, + readLexiconDoc, + verifyPublishedLexicon, +} from "../../src/lib/lexicon-publish"; export const LEXICON_AUTHORITY_HANDLE = "lex-authority.test"; @@ -21,22 +23,6 @@ const LEXICON_AUTHORITY_DETAILS = { password: "lex-authority-pass", } as const; -/** NSIDs published as lexicon.schema records for OAuth permission-set resolution. */ -export const PUBLISHED_LEXICON_NSIDS = ["com.askimut.appPermissions"] as const; - -export function readLexiconDoc(nsid: string): LexiconDoc { - const filePath = join(REPO_ROOT, "lexicons", `${nsid}.json`); - const raw = readFileSync(filePath, "utf8"); - return JSON.parse(raw) as LexiconDoc; -} - -export function lexiconSchemaRecord(doc: LexiconDoc) { - return { - $type: LEXICON_COLLECTION, - ...doc, - }; -} - /** Create or reuse the dev-network lexicon authority account. */ export async function ensureLexiconAuthorityAccount(pds: TestPds) { const agent = pds.getAgent(); @@ -57,71 +43,6 @@ export async function ensureLexiconAuthorityAccount(pds: TestPds) { } } -/** Publish Askimut lexicon.schema records on the lexicon authority repo. */ -export async function publishAskimutLexicons(agent: { - assertDid: string; - com: { - atproto: { - repo: { - putRecord: (args: { - repo: string; - collection: string; - rkey: string; - record: Record; - }) => Promise; - }; - }; - }; -}) { - const repo = agent.assertDid; - - for (const nsid of PUBLISHED_LEXICON_NSIDS) { - const doc = readLexiconDoc(nsid); - const record = lexiconSchemaRecord(doc); - - await agent.com.atproto.repo.putRecord({ - repo, - collection: LEXICON_COLLECTION, - rkey: nsid, - record, - }); - - console.log(`Published lexicon: ${nsid}`); - } -} - -/** Verify a published lexicon resolves via getRecord. */ -export async function verifyPublishedLexicon( - agent: { - com: { - atproto: { - repo: { - getRecord: (args: { - repo: string; - collection: string; - rkey: string; - }) => Promise<{ data: { value: unknown } }>; - }; - }; - }; - }, - nsid: string, - authorityDid: string, -): Promise { - const { data } = await agent.com.atproto.repo.getRecord({ - repo: authorityDid, - collection: LEXICON_COLLECTION, - rkey: nsid, - }); - - const value = data.value as { id?: string }; - if (value.id !== nsid) { - throw new Error(`Lexicon ${nsid} record id mismatch: ${value.id ?? "(missing)"}`); - } - - console.log(`Verified lexicon record: at://${authorityDid}/${LEXICON_COLLECTION}/${nsid}`); -} - export class DevLexiconAuthority extends ServiceProfile { static async create(pds: TestPds): Promise { const agent = await ensureLexiconAuthorityAccount(pds); diff --git a/scripts/lib/persistent-dev-network.ts b/scripts/lib/persistent-dev-network.ts index 3f94554..2da5aca 100644 --- a/scripts/lib/persistent-dev-network.ts +++ b/scripts/lib/persistent-dev-network.ts @@ -15,9 +15,9 @@ import { import { DevLexiconAuthority, ensureLexiconAuthorityAccount, + getPublishedLexiconNsids, publishAskimutLexicons, verifyPublishedLexicon, - PUBLISHED_LEXICON_NSIDS, } from "./lexicon-authority"; const PLC_PORT = 2583; @@ -75,7 +75,7 @@ export async function createPersistentDevNetwork(): Promise { +publishLexiconsFromEnv().catch((err) => { console.error("Fatal error:", err); process.exit(1); }); diff --git a/scripts/seed-shared-answer.ts b/scripts/seed-shared-answer.ts index ecf1369..92a2d59 100644 --- a/scripts/seed-shared-answer.ts +++ b/scripts/seed-shared-answer.ts @@ -145,7 +145,7 @@ async function seedSharedAnswer() { const answerContentUri = constructAtUri( target.did, - "com.askimut.answerContent", + "at.askimut.answerContent", "shared-answer-test", ); @@ -167,7 +167,7 @@ async function seedSharedAnswer() { for (let i = 0; i < numQuestions; i++) { const author = authors[i]; const content = QUESTION_TEMPLATES[i]; - const questionUri = constructAtUri(author.did, "com.askimut.question", `shared-q-${i + 1}`); + const questionUri = constructAtUri(author.did, "at.askimut.question", `shared-q-${i + 1}`); const [questionRow] = await db .insert(questions) diff --git a/scripts/set-3615etienne-atproto-dns.sh b/scripts/set-3615etienne-atproto-dns.sh new file mode 100755 index 0000000..d0e4bea --- /dev/null +++ b/scripts/set-3615etienne-atproto-dns.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +# Set AT Protocol DNS TXT records on 3615etienne.com via iwantmyname DDNS API. +# +# Usage: +# ./scripts/set-3615etienne-atproto-dns.sh +# +# Example: +# ./scripts/set-3615etienne-atproto-dns.sh you@example.com 'app-pass' did:plc:abc123 +# +# Sets: +# _atproto.3615etienne.com TXT did= +# _lexicon.3615etienne.com TXT did= +# +# API docs (legacy): https://iwantmyname.com/developer/domain-dns-api + +set -eu + +DOMAIN="3615etienne.com" +API="https://iwantmyname.com/basicauth/ddns" + +usage() { + echo "Usage: $0 " >&2 + echo " did may be written with or without the did: prefix" >&2 + exit 1 +} + +[ "$#" -eq 3 ] || usage + +USER="$1" +PASS="$2" +RAW_DID="$3" + +case "$RAW_DID" in + did:*) + DID="$RAW_DID" + ;; + *) + DID="did:$RAW_DID" + ;; +esac + +VALUE="did=$DID" + +set_txt() { + hostname="$1" + echo "→ $hostname TXT $VALUE" + curl -fsS -u "$USER:$PASS" \ + "$API?hostname=$hostname&type=TXT&value=$VALUE" + echo +} + +set_txt "_atproto.$DOMAIN" +set_txt "_lexicon.$DOMAIN" + +echo "Done. Verify (may take a few minutes to propagate):" +echo " dig TXT _atproto.$DOMAIN +short" +echo " dig TXT _lexicon.$DOMAIN +short" diff --git a/scripts/show-alice-records.ts b/scripts/show-alice-records.ts index d5a93b7..ecc5b38 100755 --- a/scripts/show-alice-records.ts +++ b/scripts/show-alice-records.ts @@ -5,8 +5,8 @@ * * Usage: * pnpm tsx scripts/show-alice-records.ts - * pnpm tsx scripts/show-alice-records.ts com.askimut.question - * pnpm tsx scripts/show-alice-records.ts com.askimut.question com.askimut.answer + * pnpm tsx scripts/show-alice-records.ts at.askimut.question + * pnpm tsx scripts/show-alice-records.ts at.askimut.question at.askimut.answer */ import { Agent } from "@atproto/api"; diff --git a/scripts/show-bob-records.ts b/scripts/show-bob-records.ts index 43930d1..3daf087 100755 --- a/scripts/show-bob-records.ts +++ b/scripts/show-bob-records.ts @@ -5,8 +5,8 @@ * * Usage: * pnpm tsx scripts/show-bob-records.ts - * pnpm tsx scripts/show-bob-records.ts com.askimut.question - * pnpm tsx scripts/show-bob-records.ts com.askimut.question com.askimut.answer + * pnpm tsx scripts/show-bob-records.ts at.askimut.question + * pnpm tsx scripts/show-bob-records.ts at.askimut.question at.askimut.answer */ import { Agent } from "@atproto/api"; diff --git a/src/__tests__/StackCards.test.tsx b/src/__tests__/StackCards.test.tsx index cbf7888..3ef5624 100644 --- a/src/__tests__/StackCards.test.tsx +++ b/src/__tests__/StackCards.test.tsx @@ -19,7 +19,7 @@ function renderCard(q: FakeQuestion, style?: Record) { describe("StackCards", () => { it("renders a single card without stack positioning when similar is empty", () => { render(() => ( - + )); const cards = screen.getAllByTestId(/^card-/); @@ -32,6 +32,7 @@ describe("StackCards", () => { render(() => ( { render(() => ( { render(() => ( { render(() => ( = {}): AnswerContentDeps { diff --git a/src/__tests__/at-protocol.test.ts b/src/__tests__/at-protocol.test.ts index c5ebf87..277ea09 100644 --- a/src/__tests__/at-protocol.test.ts +++ b/src/__tests__/at-protocol.test.ts @@ -47,10 +47,10 @@ vi.mock("~/lib/oauth", () => { }); const DID = "did:plc:test"; -const COLLECTION_QUESTION = "com.askimut.question"; -const COLLECTION_ANSWER = "com.askimut.answer"; -const COLLECTION_PROFILE = "com.askimut.profile"; -const COLLECTION_AMA_ROOT = "com.askimut.amaRoot"; +const COLLECTION_QUESTION = "at.askimut.question"; +const COLLECTION_ANSWER = "at.askimut.answer"; +const COLLECTION_PROFILE = "at.askimut.profile"; +const COLLECTION_AMA_ROOT = "at.askimut.amaRoot"; describe("at-protocol publisher", () => { beforeEach(() => { diff --git a/src/__tests__/backfill.test.ts b/src/__tests__/backfill.test.ts index 1c89924..2605ac5 100644 --- a/src/__tests__/backfill.test.ts +++ b/src/__tests__/backfill.test.ts @@ -37,7 +37,7 @@ describe("pdsEndpointFromDidDoc", () => { describe("rkeyFromUri", () => { it("returns the final path segment", () => { - expect(rkeyFromUri("at://did:plc:abc/com.askimut.question/rkey123")).toBe("rkey123"); + expect(rkeyFromUri("at://did:plc:abc/at.askimut.question/rkey123")).toBe("rkey123"); }); }); @@ -90,7 +90,7 @@ describe("listRecords", () => { for await (const batch of listRecords( "https://pds.example", "did:plc:abc", - "com.askimut.question", + "at.askimut.question", { fetchImpl: fetchImpl as unknown as typeof fetch, sleep: noSleep, @@ -111,7 +111,7 @@ describe("listRecords", () => { for await (const batch of listRecords( "https://pds.example", "did:plc:abc", - "com.askimut.question", + "at.askimut.question", { fetchImpl: fetchImpl as unknown as typeof fetch, sleep: noSleep, @@ -126,10 +126,10 @@ describe("listRecords", () => { describe("BACKFILL_COLLECTIONS", () => { it("orders profile before question/answer content", () => { expect(BACKFILL_COLLECTIONS).toEqual([ - "com.askimut.profile", - "com.askimut.question", - "com.askimut.answerContent", - "com.askimut.answer", + "at.askimut.profile", + "at.askimut.question", + "at.askimut.answerContent", + "at.askimut.answer", ]); }); }); diff --git a/src/__tests__/bsky-crosspost.test.ts b/src/__tests__/bsky-crosspost.test.ts index ebf64bb..bc698c5 100644 --- a/src/__tests__/bsky-crosspost.test.ts +++ b/src/__tests__/bsky-crosspost.test.ts @@ -211,9 +211,9 @@ describe("executeCrossPost at publisher boundary", () => { questionId, answerContentId: contentRow.id, questionUri: QUESTION_URI, - answerContentUri: "at://did:plc:recipient/com.askimut.answerContent/ac1", + answerContentUri: "at://did:plc:recipient/at.askimut.answerContent/ac1", authorDid: RECIPIENT_DID, - atUri: "at://did:plc:recipient/com.askimut.answer/ans1", + atUri: "at://did:plc:recipient/at.askimut.answer/ans1", crossPostUri: opts?.crossPostUri ?? null, crossPostPending: opts?.crossPostPending ?? false, createdAt: now, diff --git a/src/__tests__/bsky-derived-precedence.test.ts b/src/__tests__/bsky-derived-precedence.test.ts index 1207d44..7d769a6 100644 --- a/src/__tests__/bsky-derived-precedence.test.ts +++ b/src/__tests__/bsky-derived-precedence.test.ts @@ -76,7 +76,7 @@ async function applyDerivedFromFixture(db: DB, questionId: string) { } async function insertExplicitAnswer(db: DB, questionId: string, content: string) { - const acUri = `at://${RECIPIENT_DID}/com.askimut.answerContent/explicit1`; + const acUri = `at://${RECIPIENT_DID}/at.askimut.answerContent/explicit1`; await db.insert(answerContents).values({ id: "ac-explicit", content, @@ -93,7 +93,7 @@ async function insertExplicitAnswer(db: DB, questionId: string, content: string) questionUri: QUESTION_URI, answerContentUri: acUri, authorDid: RECIPIENT_DID, - atUri: `at://${RECIPIENT_DID}/com.askimut.answer/a-explicit`, + atUri: `at://${RECIPIENT_DID}/at.askimut.answer/a-explicit`, derived: false, reindexed: true, deleted: false, diff --git a/src/__tests__/bsky-revalidate.test.ts b/src/__tests__/bsky-revalidate.test.ts index b0bd045..5b43563 100644 --- a/src/__tests__/bsky-revalidate.test.ts +++ b/src/__tests__/bsky-revalidate.test.ts @@ -34,7 +34,7 @@ async function seedRoot(db: DB, overrides: Partial subjectUri: ROOT_URI, origin: "manual", active: true, - recordUri: `at://${RECIPIENT_DID}/com.askimut.amaRoot/rkey1`, + recordUri: `at://${RECIPIENT_DID}/at.askimut.amaRoot/rkey1`, ...overrides, }) .returning(); diff --git a/src/__tests__/dev-jetstream.test.ts b/src/__tests__/dev-jetstream.test.ts index 89b7b3c..d6c8ea5 100644 --- a/src/__tests__/dev-jetstream.test.ts +++ b/src/__tests__/dev-jetstream.test.ts @@ -25,8 +25,8 @@ function frame( describe("matchesCollection", () => { it("matches exact NSIDs and prefix wildcards", () => { - expect(matchesCollection("com.askimut.question", ["com.askimut.*"])).toBe(true); - expect(matchesCollection("app.bsky.feed.post", ["com.askimut.*"])).toBe(false); + expect(matchesCollection("at.askimut.question", ["at.askimut.*"])).toBe(true); + expect(matchesCollection("app.bsky.feed.post", ["at.askimut.*"])).toBe(false); expect(matchesCollection("app.bsky.feed.post", ["app.bsky.feed.post"])).toBe(true); }); }); @@ -38,7 +38,7 @@ describe("matchesJetstreamFilter", () => { kind: "commit", commit: { operation: "create", - collection: "com.askimut.question", + collection: "at.askimut.question", rkey: "q1", }, }); @@ -46,7 +46,7 @@ describe("matchesJetstreamFilter", () => { it("filters by wantedCollections prefix", () => { expect( matchesJetstreamFilter(askimutCommit, { - wantedCollections: ["com.askimut.*"], + wantedCollections: ["at.askimut.*"], wantedDids: [], }), ).toBe(true); @@ -61,13 +61,13 @@ describe("matchesJetstreamFilter", () => { it("filters by wantedDids", () => { expect( matchesJetstreamFilter(askimutCommit, { - wantedCollections: ["com.askimut.*"], + wantedCollections: ["at.askimut.*"], wantedDids: ["did:plc:alice"], }), ).toBe(true); expect( matchesJetstreamFilter(askimutCommit, { - wantedCollections: ["com.askimut.*"], + wantedCollections: ["at.askimut.*"], wantedDids: ["did:plc:bob"], }), ).toBe(false); @@ -82,7 +82,7 @@ describe("matchesJetstreamFilter", () => { }); expect( matchesJetstreamFilter(identity, { - wantedCollections: ["com.askimut.*"], + wantedCollections: ["at.askimut.*"], wantedDids: [], }), ).toBe(true); @@ -92,10 +92,10 @@ describe("matchesJetstreamFilter", () => { describe("parseSubscriptionFilter", () => { it("reads repeatable query params", () => { const url = new URL( - "ws://localhost/subscribe?wantedCollections=com.askimut.*&wantedCollections=app.bsky.feed.post&wantedDids=did:plc:a", + "ws://localhost/subscribe?wantedCollections=at.askimut.*&wantedCollections=app.bsky.feed.post&wantedDids=did:plc:a", ); expect(parseSubscriptionFilter(url)).toEqual({ - wantedCollections: ["com.askimut.*", "app.bsky.feed.post"], + wantedCollections: ["at.askimut.*", "app.bsky.feed.post"], wantedDids: ["did:plc:a"], }); }); @@ -112,7 +112,7 @@ describe("options_update", () => { }); expect(update).not.toBeNull(); const next = applyOptionsUpdate( - { wantedCollections: ["com.askimut.*"], wantedDids: [] }, + { wantedCollections: ["at.askimut.*"], wantedDids: [] }, update!, ); expect(next).toEqual({ diff --git a/src/__tests__/grouping.test.ts b/src/__tests__/grouping.test.ts index f375ac7..02ab015 100644 --- a/src/__tests__/grouping.test.ts +++ b/src/__tests__/grouping.test.ts @@ -69,10 +69,10 @@ async function seedAnswerLink( id, questionId, answerContentId, - questionUri: `at://${ASKER_DID}/com.askimut.question/${questionId}`, + questionUri: `at://${ASKER_DID}/at.askimut.question/${questionId}`, answerContentUri, authorDid: RECIPIENT_DID, - atUri: `at://${RECIPIENT_DID}/com.askimut.answer/${id}`, + atUri: `at://${RECIPIENT_DID}/at.askimut.answer/${id}`, reindexed: true, deleted: false, createdAt: new Date(), @@ -87,7 +87,7 @@ describe("fetchGroupedQuestions", () => { }); it("groups two questions sharing the same Answer Content into one group", async () => { - const acUri = `at://${RECIPIENT_DID}/com.askimut.answerContent/ac1`; + const acUri = `at://${RECIPIENT_DID}/at.askimut.answerContent/ac1`; await seedQuestion(db, "q1", "How are you?", 0); await seedQuestion(db, "q2", "How are you doing?", 1000); await seedAnswerContent(db, "ac1", "I am fine!", acUri); @@ -105,7 +105,7 @@ describe("fetchGroupedQuestions", () => { }); it("caps the stack at 3 cards and reports hiddenCount for extras", async () => { - const acUri = `at://${RECIPIENT_DID}/com.askimut.answerContent/ac1`; + const acUri = `at://${RECIPIENT_DID}/at.askimut.answerContent/ac1`; await seedQuestion(db, "q1", "Q1", 0); await seedQuestion(db, "q2", "Q2", 1000); await seedQuestion(db, "q3", "Q3", 2000); @@ -142,8 +142,8 @@ describe("fetchGroupedQuestions", () => { }); it("separates questions with different Answer Content into different groups", async () => { - const acUri1 = `at://${RECIPIENT_DID}/com.askimut.answerContent/ac1`; - const acUri2 = `at://${RECIPIENT_DID}/com.askimut.answerContent/ac2`; + const acUri1 = `at://${RECIPIENT_DID}/at.askimut.answerContent/ac1`; + const acUri2 = `at://${RECIPIENT_DID}/at.askimut.answerContent/ac2`; await seedQuestion(db, "q1", "Q1", 0); await seedQuestion(db, "q2", "Q2", 1000); await seedAnswerContent(db, "ac1", "Answer A", acUri1); @@ -164,7 +164,7 @@ describe("fetchGroupedQuestions", () => { it("includes answered bsky Questions in the unified list with source metadata", async () => { const bskySourceUri = "at://did:plc:asker/app.bsky.feed.post/qreply1"; - const acUri = `at://${RECIPIENT_DID}/com.askimut.answerContent/ac-bsky`; + const acUri = `at://${RECIPIENT_DID}/at.askimut.answerContent/ac-bsky`; await db.insert(questions).values({ id: "q-bsky", authorDid: ASKER_DID, @@ -186,7 +186,7 @@ describe("fetchGroupedQuestions", () => { questionUri: bskySourceUri, answerContentUri: acUri, authorDid: RECIPIENT_DID, - atUri: `at://${RECIPIENT_DID}/com.askimut.answer/a-bsky`, + atUri: `at://${RECIPIENT_DID}/at.askimut.answer/a-bsky`, reindexed: true, deleted: false, createdAt: new Date(), @@ -213,7 +213,7 @@ describe("fetchGroupedQuestions", () => { }); it("preserves createdAt desc ordering when mixing answered and unanswered questions", async () => { - const acUri = `at://${RECIPIENT_DID}/com.askimut.answerContent/ac1`; + const acUri = `at://${RECIPIENT_DID}/at.askimut.answerContent/ac1`; await seedQuestion(db, "q1", "Old unanswered", 0); await seedQuestion(db, "q2", "New answered", 1000); await seedQuestion(db, "q3", "Newer unanswered", 2000); diff --git a/src/__tests__/ingester.test.ts b/src/__tests__/ingester.test.ts index 1714636..7fc3c9c 100644 --- a/src/__tests__/ingester.test.ts +++ b/src/__tests__/ingester.test.ts @@ -28,11 +28,11 @@ const RECIPIENT_DID = "did:plc:recipient"; const QUESTION_RKEY = "rkey123"; const ANSWER_CONTENT_RKEY = "rkeyAc"; const ANSWER_RKEY = "rkey456"; -const QUESTION_COLLECTION = "com.askimut.question"; -const ANSWER_CONTENT_COLLECTION = "com.askimut.answerContent"; -const ANSWER_COLLECTION = "com.askimut.answer"; -const PROFILE_COLLECTION = "com.askimut.profile"; -const AMA_ROOT_COLLECTION = "com.askimut.amaRoot"; +const QUESTION_COLLECTION = "at.askimut.question"; +const ANSWER_CONTENT_COLLECTION = "at.askimut.answerContent"; +const ANSWER_COLLECTION = "at.askimut.answer"; +const PROFILE_COLLECTION = "at.askimut.profile"; +const AMA_ROOT_COLLECTION = "at.askimut.amaRoot"; const SUBJECT_URI = "at://did:plc:recipient/app.bsky.feed.post/root1"; async function seedUsers(db: DB) { diff --git a/src/__tests__/jetstream.test.ts b/src/__tests__/jetstream.test.ts index 7008324..1637b19 100644 --- a/src/__tests__/jetstream.test.ts +++ b/src/__tests__/jetstream.test.ts @@ -22,7 +22,7 @@ import * as lexicons from "~/lexicons/index.js"; const APP_ORIGIN = "https://askimut.test"; const RECIPIENT_DID = "did:plc:recipient"; const OTHER_DID = "did:plc:other"; -const PROFILE_COLLECTION = "com.askimut.profile"; +const PROFILE_COLLECTION = "at.askimut.profile"; const POST_COLLECTION = "app.bsky.feed.post"; async function seedRecipient( @@ -66,7 +66,7 @@ describe("rewindCursor", () => { describe("buildSubscribeUrl", () => { it("adds the wildcard collection filter and no cursor when null", () => { const url = buildSubscribeUrl("wss://jetstream2.us-east.bsky.network/subscribe", null); - expect(url).toContain("wantedCollections=com.askimut.*"); + expect(url).toContain("wantedCollections=at.askimut.*"); expect(url).not.toContain("cursor="); }); @@ -84,7 +84,7 @@ describe("mapJetstreamEvent", () => { kind: "commit", commit: { operation: "create", - collection: "com.askimut.question", + collection: "at.askimut.question", rkey: "rk1", record: { text: "hi" }, cid: "bafycid", @@ -96,7 +96,7 @@ describe("mapJetstreamEvent", () => { event: { action: "create", did: "did:plc:abc", - collection: "com.askimut.question", + collection: "at.askimut.question", rkey: "rk1", record: { text: "hi" }, cid: "bafycid", diff --git a/src/__tests__/lexicon-publish.test.ts b/src/__tests__/lexicon-publish.test.ts new file mode 100644 index 0000000..73305d4 --- /dev/null +++ b/src/__tests__/lexicon-publish.test.ts @@ -0,0 +1,19 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; + +import { getPublishedLexiconNsids, readLexiconDoc } from "~/lib/lexicon-publish"; + +describe("lexicon publish catalog", () => { + it("loads all committed lexicons from lexicons.json", () => { + const nsids = getPublishedLexiconNsids(); + expect(nsids).toContain("at.askimut.appPermissions"); + expect(nsids).toContain("at.askimut.question"); + expect(nsids.length).toBeGreaterThanOrEqual(6); + }); + + it("reads lexicon JSON whose id matches the NSID", () => { + const doc = readLexiconDoc("at.askimut.appPermissions"); + expect(doc.id).toBe("at.askimut.appPermissions"); + expect(doc.defs.main).toMatchObject({ type: "permission-set" }); + }); +}); diff --git a/src/__tests__/link-input.test.ts b/src/__tests__/link-input.test.ts index 155bd1f..925180a 100644 --- a/src/__tests__/link-input.test.ts +++ b/src/__tests__/link-input.test.ts @@ -8,7 +8,7 @@ import { describe("link-input validation", () => { it("accepts at:// URIs", () => { - const uri = "at://did:plc:abc/com.askimut.answerContent/xyz"; + const uri = "at://did:plc:abc/at.askimut.answerContent/xyz"; expect(isValidLinkInput(uri)).toBe(true); expect(linkInputValidationMessage(uri)).toBeNull(); }); @@ -20,7 +20,7 @@ describe("link-input validation", () => { }); it("accepts askimut permalink URLs", () => { - const url = "https://askimut.app/alice/abc-123"; + const url = "https://askimut.at/alice/abc-123"; expect(isValidLinkInput(url)).toBe(true); expect(linkInputValidationMessage(url)).toBeNull(); expect(parseAskimutPermalinkQuestionId(url)).toBe("abc-123"); diff --git a/src/__tests__/oauth-scope.test.ts b/src/__tests__/oauth-scope.test.ts index 7a531d1..16bc181 100644 --- a/src/__tests__/oauth-scope.test.ts +++ b/src/__tests__/oauth-scope.test.ts @@ -11,7 +11,7 @@ import { OAUTH_SCOPE } from "~/lib/oauth-scope"; describe("OAUTH_SCOPE", () => { it("matches the granular permission bundle from ADR-0015", () => { expect(OAUTH_SCOPE).toBe( - "atproto include:com.askimut.appPermissions repo:app.bsky.feed.post?action=create", + "atproto include:at.askimut.appPermissions repo:app.bsky.feed.post?action=create", ); }); diff --git a/src/__tests__/question-uri.test.ts b/src/__tests__/question-uri.test.ts index afb48e5..1e55ea8 100644 --- a/src/__tests__/question-uri.test.ts +++ b/src/__tests__/question-uri.test.ts @@ -11,7 +11,7 @@ type DB = ReturnType; const ASKER_DID = "did:plc:asker"; const RECIPIENT_DID = "did:plc:recipient"; const BSKY_QUESTION_URI = "at://did:plc:asker/app.bsky.feed.post/qreply1"; -const NATIVE_AT_URI = `at://${ASKER_DID}/com.askimut.question/rkey1`; +const NATIVE_AT_URI = `at://${ASKER_DID}/at.askimut.question/rkey1`; async function seedUsers(db: DB) { const now = Date.now(); diff --git a/src/components/StackCards.module.css b/src/components/StackCards.module.css index aa7c3dd..bcd8715 100644 --- a/src/components/StackCards.module.css +++ b/src/components/StackCards.module.css @@ -1,5 +1,6 @@ :root { --stack-offset: 8px; + --stack-slide: calc(2 * var(--stack-offset)); } .stackContainer { @@ -13,3 +14,76 @@ margin-top: 0.5rem; text-align: right; } + +.primaryEnter { + animation: stackEnter 220ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.primaryEnterReverse { + animation: stackEnterReverse 220ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.primaryExit { + animation: stackExit 180ms cubic-bezier(0.4, 0, 1, 1) both; +} + +.primaryExitReverse { + animation: stackExitReverse 180ms cubic-bezier(0.4, 0, 1, 1) both; +} + +@keyframes stackEnter { + from { + opacity: 0; + transform: translate(calc(-1 * var(--stack-slide)), var(--stack-slide)); + } + + to { + opacity: 1; + transform: translate(0, 0); + } +} + +@keyframes stackEnterReverse { + from { + opacity: 0; + transform: translate(var(--stack-slide), calc(-1 * var(--stack-slide))); + } + + to { + opacity: 1; + transform: translate(0, 0); + } +} + +@keyframes stackExit { + from { + opacity: 1; + transform: translate(0, 0); + } + + to { + opacity: 0; + transform: translate(var(--stack-slide), calc(-1 * var(--stack-slide))); + } +} + +@keyframes stackExitReverse { + from { + opacity: 1; + transform: translate(0, 0); + } + + to { + opacity: 0; + transform: translate(calc(-1 * var(--stack-slide)), var(--stack-slide)); + } +} + +@media (prefers-reduced-motion: reduce) { + .primaryEnter, + .primaryEnterReverse, + .primaryExit, + .primaryExitReverse { + animation: none; + } +} diff --git a/src/components/StackCards.tsx b/src/components/StackCards.tsx index 60f2485..5ced943 100644 --- a/src/components/StackCards.tsx +++ b/src/components/StackCards.tsx @@ -1,22 +1,105 @@ -import { For, Show, type JSX } from "solid-js"; +import { For, Show, createEffect, createSignal, onCleanup, type JSX } from "solid-js"; import { MAX_CARDS } from "~/lib/grouping"; import styles from "./StackCards.module.css"; type CardStyle = Record; +export type StackDirection = 1 | -1 | 0; interface StackCardsProps { primary: T; + primaryKey: string; + direction?: StackDirection; similar: T[]; hiddenCount: number; + itemKey?: (item: T) => string; renderCard: (item: T, style: CardStyle) => JSX.Element; } +const TRANSITION_MS = 220; + +const primaryStyle: CardStyle = { "z-index": "3", position: "relative" }; + +const overlayStyle: CardStyle = { + "z-index": "4", + position: "absolute", + top: "0", + left: "0", + width: "100%", + "pointer-events": "none", +}; + +function AnimatedPrimary(props: { + primary: T; + primaryKey: string; + direction: StackDirection; + onTransition: (keys: string[] | null) => void; + renderCard: (item: T, style: CardStyle) => JSX.Element; +}): JSX.Element { + const [leaving, setLeaving] = createSignal<{ item: T; key: string; direction: StackDirection } | null>( + null, + ); + const [enterDirection, setEnterDirection] = createSignal(0); + + let currentKey = props.primaryKey; + let previousPrimary = props.primary; + + createEffect(() => { + const key = props.primaryKey; + const dir = props.direction ?? 0; + if (key === currentKey) return; + + setLeaving({ item: previousPrimary, key: currentKey, direction: dir }); + setEnterDirection(dir); + props.onTransition([currentKey, key]); + previousPrimary = props.primary; + currentKey = key; + + const timer = setTimeout(() => { + setLeaving(null); + props.onTransition(null); + }, TRANSITION_MS); + onCleanup(() => clearTimeout(timer)); + }); + + const enterClass = () => { + const dir = enterDirection(); + if (!leaving() || dir === 0) return undefined; + return dir === 1 ? styles.primaryEnter : styles.primaryEnterReverse; + }; + + const exitClass = (dir: StackDirection) => + dir === 1 ? styles.primaryExit : styles.primaryExitReverse; + + return ( + <> + + {(state) => ( +
+ {props.renderCard(state().item, primaryStyle)} +
+ )} +
+
{props.renderCard(props.primary, primaryStyle)}
+ + ); +} + export function StackCards(props: StackCardsProps): JSX.Element { - const visibleSimilar = () => props.similar.slice(0, MAX_CARDS - 1); + const [transitionKeys, setTransitionKeys] = createSignal([]); + + const visibleSimilar = () => { + const blocked = new Set(transitionKeys()); + return props.similar + .slice(0, MAX_CARDS - 1) + .filter((item) => { + if (!props.itemKey || blocked.size === 0) return true; + return !blocked.has(props.itemKey(item)); + }); + }; const cardStyle = (index: number): CardStyle => { - if (index === 0) return { "z-index": "3", position: "relative" }; + if (index === 0) return primaryStyle; const multiplier = index; return { "z-index": String(MAX_CARDS - index), @@ -28,9 +111,22 @@ export function StackCards(props: StackCardsProps): JSX.Element { }; }; + const animate = () => props.similar.length > 0; + return (
- {props.renderCard(props.primary, cardStyle(0))} + + setTransitionKeys(keys ?? [])} + renderCard={props.renderCard} + /> + {(item, i) => props.renderCard(item, cardStyle(i() + 1))} 0}>
+{props.hiddenCount} more
diff --git a/src/ingester.ts b/src/ingester.ts index 40766f5..e9de784 100644 --- a/src/ingester.ts +++ b/src/ingester.ts @@ -1,5 +1,5 @@ /** - * Ingester process — consumes com.askimut.* records from Jetstream, + * Ingester process — consumes at.askimut.* records from Jetstream, * marks confirmed records, and inserts notifications for SSE fan-out. * * Run as a separate long-lived process: `tsx src/ingester.ts` (dev) or diff --git a/src/lexicons/com.ts b/src/lexicons/at.ts similarity index 60% rename from src/lexicons/com.ts rename to src/lexicons/at.ts index de24385..e4bd641 100644 --- a/src/lexicons/com.ts +++ b/src/lexicons/at.ts @@ -2,4 +2,4 @@ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. */ -export * as askimut from './com/askimut.js' +export * as askimut from './at/askimut.js' diff --git a/src/lexicons/com/askimut.ts b/src/lexicons/at/askimut.ts similarity index 100% rename from src/lexicons/com/askimut.ts rename to src/lexicons/at/askimut.ts diff --git a/src/lexicons/com/askimut/amaRoot.defs.ts b/src/lexicons/at/askimut/amaRoot.defs.ts similarity index 94% rename from src/lexicons/com/askimut/amaRoot.defs.ts rename to src/lexicons/at/askimut/amaRoot.defs.ts index fe026f3..e71c6f6 100644 --- a/src/lexicons/com/askimut/amaRoot.defs.ts +++ b/src/lexicons/at/askimut/amaRoot.defs.ts @@ -4,12 +4,12 @@ import { l } from '@atproto/lex' -const $nsid = 'com.askimut.amaRoot' +const $nsid = 'at.askimut.amaRoot' export { $nsid } type Main = { - $type: 'com.askimut.amaRoot' + $type: 'at.askimut.amaRoot' /** * AT-URI of the Recipient's first-level Bluesky post diff --git a/src/lexicons/com/askimut/amaRoot.ts b/src/lexicons/at/askimut/amaRoot.ts similarity index 100% rename from src/lexicons/com/askimut/amaRoot.ts rename to src/lexicons/at/askimut/amaRoot.ts diff --git a/src/lexicons/com/askimut/answer.defs.ts b/src/lexicons/at/askimut/answer.defs.ts similarity index 91% rename from src/lexicons/com/askimut/answer.defs.ts rename to src/lexicons/at/askimut/answer.defs.ts index 1718102..7e444f8 100644 --- a/src/lexicons/com/askimut/answer.defs.ts +++ b/src/lexicons/at/askimut/answer.defs.ts @@ -4,12 +4,12 @@ import { l } from '@atproto/lex' -const $nsid = 'com.askimut.answer' +const $nsid = 'at.askimut.answer' export { $nsid } type Main = { - $type: 'com.askimut.answer' + $type: 'at.askimut.answer' /** * AT-URI reference to the question being answered @@ -17,7 +17,7 @@ type Main = { questionUri: l.AtUriString /** - * AT-URI of the answer content (com.askimut.answer_content or external record) + * AT-URI of the answer content (at.askimut.answer_content or external record) */ answerContentUri: l.AtUriString diff --git a/src/lexicons/com/askimut/answer.ts b/src/lexicons/at/askimut/answer.ts similarity index 100% rename from src/lexicons/com/askimut/answer.ts rename to src/lexicons/at/askimut/answer.ts diff --git a/src/lexicons/com/askimut/question.defs.ts b/src/lexicons/at/askimut/answerContent.defs.ts similarity index 50% rename from src/lexicons/com/askimut/question.defs.ts rename to src/lexicons/at/askimut/answerContent.defs.ts index 2b3d9cd..c50f5d6 100644 --- a/src/lexicons/com/askimut/question.defs.ts +++ b/src/lexicons/at/askimut/answerContent.defs.ts @@ -4,52 +4,27 @@ import { l } from '@atproto/lex' -const $nsid = 'com.askimut.question' +const $nsid = 'at.askimut.answerContent' export { $nsid } type Main = { - $type: 'com.askimut.question' + $type: 'at.askimut.answerContent' /** - * The question content + * The answer content text */ content: string /** - * DID of the user being asked - */ - targetDid: l.DidString - - /** - * DID of the user asking the question + * DID of the user who wrote this answer content */ authorDid: l.DidString /** - * The platform/source where this question originated - */ - sourceType: 'askimut' | 'bsky' | 'standard.site' | 'mastodon' | 'nostr' - - /** - * When the question was created + * When the answer content was created */ createdAt: l.DatetimeString - - /** - * Optional tags for categorizing the question - */ - tags?: string[] - - /** - * Original URI from the source platform (e.g., Bluesky post URI) - */ - sourceUri?: l.UriString - - /** - * Platform-specific metadata and additional data - */ - sourceData?: l.LexMap } export type { Main } @@ -58,20 +33,9 @@ const main = l.record<'tid', Main>( 'tid', $nsid, l.object({ - content: l.string({ minLength: 1, maxLength: 1000 }), - targetDid: l.string({ format: 'did' }), + content: l.string({ minLength: 1, maxLength: 5000 }), authorDid: l.string({ format: 'did' }), - sourceType: l.enum([ - 'askimut', - 'bsky', - 'standard.site', - 'mastodon', - 'nostr', - ]), createdAt: l.string({ format: 'datetime' }), - tags: l.optional(l.array(l.string({ maxLength: 50 }), { maxLength: 10 })), - sourceUri: l.optional(l.string({ format: 'uri' })), - sourceData: l.optional(l.lexMap()), }), ) diff --git a/src/lexicons/com/askimut/answerContent.ts b/src/lexicons/at/askimut/answerContent.ts similarity index 100% rename from src/lexicons/com/askimut/answerContent.ts rename to src/lexicons/at/askimut/answerContent.ts diff --git a/src/lexicons/com/askimut/appPermissions.defs.ts b/src/lexicons/at/askimut/appPermissions.defs.ts similarity index 63% rename from src/lexicons/com/askimut/appPermissions.defs.ts rename to src/lexicons/at/askimut/appPermissions.defs.ts index 058bd51..a202407 100644 --- a/src/lexicons/com/askimut/appPermissions.defs.ts +++ b/src/lexicons/at/askimut/appPermissions.defs.ts @@ -4,7 +4,7 @@ import { l } from '@atproto/lex' -const $nsid = 'com.askimut.appPermissions' +const $nsid = 'at.askimut.appPermissions' export { $nsid } @@ -13,11 +13,11 @@ const main = l.permissionSet( [ l.permission('repo', { collection: [ - 'com.askimut.question', - 'com.askimut.answerContent', - 'com.askimut.answer', - 'com.askimut.profile', - 'com.askimut.amaRoot', + 'at.askimut.question', + 'at.askimut.answerContent', + 'at.askimut.answer', + 'at.askimut.profile', + 'at.askimut.amaRoot', ], }), ], diff --git a/src/lexicons/com/askimut/appPermissions.ts b/src/lexicons/at/askimut/appPermissions.ts similarity index 100% rename from src/lexicons/com/askimut/appPermissions.ts rename to src/lexicons/at/askimut/appPermissions.ts diff --git a/src/lexicons/com/askimut/profile.defs.ts b/src/lexicons/at/askimut/profile.defs.ts similarity index 96% rename from src/lexicons/com/askimut/profile.defs.ts rename to src/lexicons/at/askimut/profile.defs.ts index da9570a..d867f95 100644 --- a/src/lexicons/com/askimut/profile.defs.ts +++ b/src/lexicons/at/askimut/profile.defs.ts @@ -4,12 +4,12 @@ import { l } from '@atproto/lex' -const $nsid = 'com.askimut.profile' +const $nsid = 'at.askimut.profile' export { $nsid } type Main = { - $type: 'com.askimut.profile' + $type: 'at.askimut.profile' /** * Display name for the user diff --git a/src/lexicons/com/askimut/profile.ts b/src/lexicons/at/askimut/profile.ts similarity index 100% rename from src/lexicons/com/askimut/profile.ts rename to src/lexicons/at/askimut/profile.ts diff --git a/src/lexicons/com/askimut/answerContent.defs.ts b/src/lexicons/at/askimut/question.defs.ts similarity index 50% rename from src/lexicons/com/askimut/answerContent.defs.ts rename to src/lexicons/at/askimut/question.defs.ts index fe34bd8..eb67562 100644 --- a/src/lexicons/com/askimut/answerContent.defs.ts +++ b/src/lexicons/at/askimut/question.defs.ts @@ -4,27 +4,52 @@ import { l } from '@atproto/lex' -const $nsid = 'com.askimut.answerContent' +const $nsid = 'at.askimut.question' export { $nsid } type Main = { - $type: 'com.askimut.answerContent' + $type: 'at.askimut.question' /** - * The answer content text + * The question content */ content: string /** - * DID of the user who wrote this answer content + * DID of the user being asked + */ + targetDid: l.DidString + + /** + * DID of the user asking the question */ authorDid: l.DidString /** - * When the answer content was created + * The platform/source where this question originated + */ + sourceType: 'askimut' | 'bsky' | 'standard.site' | 'mastodon' | 'nostr' + + /** + * When the question was created */ createdAt: l.DatetimeString + + /** + * Optional tags for categorizing the question + */ + tags?: string[] + + /** + * Original URI from the source platform (e.g., Bluesky post URI) + */ + sourceUri?: l.UriString + + /** + * Platform-specific metadata and additional data + */ + sourceData?: l.LexMap } export type { Main } @@ -33,9 +58,20 @@ const main = l.record<'tid', Main>( 'tid', $nsid, l.object({ - content: l.string({ minLength: 1, maxLength: 5000 }), + content: l.string({ minLength: 1, maxLength: 1000 }), + targetDid: l.string({ format: 'did' }), authorDid: l.string({ format: 'did' }), + sourceType: l.enum([ + 'askimut', + 'bsky', + 'standard.site', + 'mastodon', + 'nostr', + ]), createdAt: l.string({ format: 'datetime' }), + tags: l.optional(l.array(l.string({ maxLength: 50 }), { maxLength: 10 })), + sourceUri: l.optional(l.string({ format: 'uri' })), + sourceData: l.optional(l.lexMap()), }), ) diff --git a/src/lexicons/com/askimut/question.ts b/src/lexicons/at/askimut/question.ts similarity index 100% rename from src/lexicons/com/askimut/question.ts rename to src/lexicons/at/askimut/question.ts diff --git a/src/lexicons/index.ts b/src/lexicons/index.ts index 310a42a..1dfd6e9 100644 --- a/src/lexicons/index.ts +++ b/src/lexicons/index.ts @@ -1,15 +1,15 @@ /** * Generated Askimut Lexicon Schemas - * + * * This file provides easy access to all Askimut lexicon schemas. */ -export * as question from './com/askimut/question.js' -export * as answer from './com/askimut/answer.js' -export * as answerContent from './com/askimut/answerContent.js' -export * as profile from './com/askimut/profile.js' -export * as amaRoot from './com/askimut/amaRoot.js' -export * as appPermissions from './com/askimut/appPermissions.js' +export * as question from "./at/askimut/question.js"; +export * as answer from "./at/askimut/answer.js"; +export * as answerContent from "./at/askimut/answerContent.js"; +export * as profile from "./at/askimut/profile.js"; +export * as amaRoot from "./at/askimut/amaRoot.js"; +export * as appPermissions from "./at/askimut/appPermissions.js"; // Re-export common utilities from @atproto/lex -export { l } from '@atproto/lex' \ No newline at end of file +export { l } from "@atproto/lex"; diff --git a/src/lib/answer-content/types.ts b/src/lib/answer-content/types.ts index 347d539..c28a581 100644 --- a/src/lib/answer-content/types.ts +++ b/src/lib/answer-content/types.ts @@ -2,7 +2,7 @@ import { SOURCE_TYPES, type SourceType } from "~/lib/shared-schemas"; import { STANDARD_SITE_DOCUMENT_COLLECTION } from "./standard-site/constants"; -export const ANSWER_CONTENT_COLLECTION = "com.askimut.answerContent"; +export const ANSWER_CONTENT_COLLECTION = "at.askimut.answerContent"; export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post"; export type ProjectionKind = "full-text" | "excerpt" | "none"; diff --git a/src/lib/at-protocol.ts b/src/lib/at-protocol.ts index a49e70b..db3d4bc 100644 --- a/src/lib/at-protocol.ts +++ b/src/lib/at-protocol.ts @@ -177,7 +177,7 @@ export async function publishQuestion( did: string, record: PublishableRecord, ): Promise { - return publishRecord(did, "com.askimut.question", record); + return publishRecord(did, "at.askimut.question", record); } /** @@ -187,7 +187,7 @@ export async function publishAnswerContent( did: string, record: PublishableRecord, ): Promise { - return publishRecord(did, "com.askimut.answerContent", record); + return publishRecord(did, "at.askimut.answerContent", record); } /** @@ -197,7 +197,7 @@ export async function publishAnswer( did: string, record: PublishableRecord, ): Promise { - return publishRecord(did, "com.askimut.answer", record); + return publishRecord(did, "at.askimut.answer", record); } /** @@ -211,7 +211,7 @@ export async function publishProfile( try { const result = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "com.askimut.profile", + collection: "at.askimut.profile", rkey: "self", record, }); @@ -228,11 +228,11 @@ export async function publishAmaRoot( did: string, record: PublishableRecord, ): Promise { - return publishRecord(did, "com.askimut.amaRoot", record); + return publishRecord(did, "at.askimut.amaRoot", record); } const BSKY_POST_COLLECTION = "app.bsky.feed.post"; -const ANSWER_COLLECTION = "com.askimut.answer"; +const ANSWER_COLLECTION = "at.askimut.answer"; /** * Publish a Bluesky feed post (cross-post reply) to the author's PDS. @@ -245,7 +245,7 @@ export async function publishBskyPost( } /** - * Update an existing com.askimut.answer record (e.g. to set crossPostUri). + * Update an existing at.askimut.answer record (e.g. to set crossPostUri). */ export async function updateAnswerRecord( did: string, @@ -286,7 +286,7 @@ export async function deleteRecord(did: string, collection: string, rkey: string * Delete an AMA Root record from the Recipient's PDS. */ export async function deleteAmaRoot(did: string, rkey: string): Promise { - return deleteRecord(did, "com.askimut.amaRoot", rkey); + return deleteRecord(did, "at.askimut.amaRoot", rkey); } /** diff --git a/src/lib/backfill.ts b/src/lib/backfill.ts index 365e537..6b240f9 100644 --- a/src/lib/backfill.ts +++ b/src/lib/backfill.ts @@ -1,5 +1,5 @@ /** - * Backfill — hydrates a repo's existing com.askimut.* records that predate the + * Backfill — hydrates a repo's existing at.askimut.* records that predate the * Jetstream cursor. Resolves the repo's PDS, pages com.atproto.repo.listRecords, * and feeds each record into the shared ingester handlers with `live: false` * (no SSE notifications for historical data). @@ -16,10 +16,10 @@ type DB = LibSQLDatabase; /** Collection backfill order — profile first so authorship rows exist for later records. */ export const BACKFILL_COLLECTIONS = [ - "com.askimut.profile", - "com.askimut.question", - "com.askimut.answerContent", - "com.askimut.answer", + "at.askimut.profile", + "at.askimut.question", + "at.askimut.answerContent", + "at.askimut.answer", ] as const; const PLC_URL = process.env.PLC_URL ?? process.env.DEV_PLC_URL ?? "https://plc.directory"; @@ -114,7 +114,7 @@ export async function* listRecords( } while (cursor); } -/** Backfill every com.askimut.* record for one repo into the DB. */ +/** Backfill every at.askimut.* record for one repo into the DB. */ export async function backfillRepo( db: DB, did: string, diff --git a/src/lib/ingester.ts b/src/lib/ingester.ts index ce2dafe..359ecd2 100644 --- a/src/lib/ingester.ts +++ b/src/lib/ingester.ts @@ -23,11 +23,11 @@ import { SOURCE_TYPES } from "~/lib/shared-schemas"; type DB = LibSQLDatabase; -const QUESTION_COLLECTION = "com.askimut.question"; -const ANSWER_CONTENT_COLLECTION = "com.askimut.answerContent"; -const ANSWER_COLLECTION = "com.askimut.answer"; -const PROFILE_COLLECTION = "com.askimut.profile"; -const AMA_ROOT_COLLECTION = "com.askimut.amaRoot"; +const QUESTION_COLLECTION = "at.askimut.question"; +const ANSWER_CONTENT_COLLECTION = "at.askimut.answerContent"; +const ANSWER_COLLECTION = "at.askimut.answer"; +const PROFILE_COLLECTION = "at.askimut.profile"; +const AMA_ROOT_COLLECTION = "at.askimut.amaRoot"; export type ConfirmedEvent = { collection: string; diff --git a/src/lib/jetstream.ts b/src/lib/jetstream.ts index 7d6c195..ce870ac 100644 --- a/src/lib/jetstream.ts +++ b/src/lib/jetstream.ts @@ -1,13 +1,13 @@ /** * Jetstream consumer — subscribes to Bluesky's public Jetstream firehose filtered - * server-side to com.askimut.* collections, maps events onto the shared ingester + * server-side to at.askimut.* collections, maps events onto the shared ingester * handlers, and persists a resumable cursor (time_us). Plain JSON only (no zstd — * that needs a custom dictionary). On each commit, the repo is enqueued for backfill * so historical records predating the cursor get hydrated. * * A second connection subscribes to app.bsky.feed.post for DIDs with discovery * toggles enabled (wantedDids). wantedDids is global in Jetstream, so askimut - * records stay on the unfiltered com.askimut.* connection. + * records stay on the unfiltered at.askimut.* connection. */ import { WebSocket, type RawData } from "ws"; import type { LibSQLDatabase } from "drizzle-orm/libsql"; @@ -34,9 +34,9 @@ type DB = LibSQLDatabase; const JETSTREAM_URL = process.env.JETSTREAM_URL ?? "wss://jetstream2.us-east.bsky.network/subscribe"; -const WANTED_COLLECTIONS = "com.askimut.*"; +const WANTED_COLLECTIONS = "at.askimut.*"; const BSKY_POST_COLLECTION = "app.bsky.feed.post"; -const PROFILE_COLLECTION = "com.askimut.profile"; +const PROFILE_COLLECTION = "at.askimut.profile"; const CURSOR_ID = "jetstream"; const CURSOR_REWIND_US = 5_000_000; // 5s in microseconds const CURSOR_FLUSH_MS = 5_000; @@ -48,7 +48,7 @@ const KNOWN_USERS_REFRESH_MS = 60_000; /** * In-memory set of DIDs we track (the users table). Jetstream delivers identity - * and account events for the ENTIRE network — not just com.askimut.* repos — so + * and account events for the ENTIRE network — not just at.askimut.* repos — so * we only apply them for DIDs already in our system. Users are created by the web * app (OAuth signup); the ingester never creates users from global events. */ diff --git a/src/lib/lexicon-publish.ts b/src/lib/lexicon-publish.ts new file mode 100644 index 0000000..d3b2a0d --- /dev/null +++ b/src/lib/lexicon-publish.ts @@ -0,0 +1,130 @@ +/** + * Publish Askimut lexicon.schema records to the network authority repo. + * + * Used locally via `pnpm publish:lexicons` and in production via the web + * container entrypoint (`node .output/server/publish-lexicons.mjs`). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { AtpAgent } from "@atproto/api"; + +const LEXICON_COLLECTION = "com.atproto.lexicon.schema"; + +type LexiconDoc = { + lexicon: number; + id: string; + defs: Record; +}; + +type LexiconAgent = { + assertDid: string; + com: { + atproto: { + repo: { + putRecord: (args: { + repo: string; + collection: string; + rkey: string; + record: Record; + }) => Promise; + getRecord: (args: { + repo: string; + collection: string; + rkey: string; + }) => Promise<{ data: { value: unknown } }>; + }; + }; + }; +}; + +export function getPublishedLexiconNsids(): string[] { + const catalogPath = join(process.cwd(), "lexicons.json"); + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { lexicons: string[] }; + if (!Array.isArray(catalog.lexicons) || catalog.lexicons.length === 0) { + throw new Error(`No lexicons listed in ${catalogPath}`); + } + return catalog.lexicons; +} + +export function readLexiconDoc(nsid: string): LexiconDoc { + const filePath = join(process.cwd(), "lexicons", `${nsid}.json`); + const raw = readFileSync(filePath, "utf8"); + const doc = JSON.parse(raw) as LexiconDoc; + if (doc.id !== nsid) { + throw new Error(`Lexicon file ${filePath} id mismatch: expected ${nsid}, got ${doc.id}`); + } + return doc; +} + +export function lexiconSchemaRecord(doc: LexiconDoc) { + return { + $type: LEXICON_COLLECTION, + ...doc, + }; +} + +/** Publish all lexicons listed in lexicons.json to the authority repo. */ +export async function publishAskimutLexicons(agent: LexiconAgent) { + const repo = agent.assertDid; + + for (const nsid of getPublishedLexiconNsids()) { + const doc = readLexiconDoc(nsid); + const record = lexiconSchemaRecord(doc); + + await agent.com.atproto.repo.putRecord({ + repo, + collection: LEXICON_COLLECTION, + rkey: nsid, + record, + }); + + console.log(`Published lexicon: ${nsid}`); + } +} + +/** Verify a published lexicon resolves via getRecord. */ +export async function verifyPublishedLexicon( + agent: LexiconAgent, + nsid: string, + authorityDid: string, +): Promise { + const { data } = await agent.com.atproto.repo.getRecord({ + repo: authorityDid, + collection: LEXICON_COLLECTION, + rkey: nsid, + }); + + const value = data.value as { id?: string }; + if (value.id !== nsid) { + throw new Error(`Lexicon ${nsid} record id mismatch: ${value.id ?? "(missing)"}`); + } + + console.log(`Verified lexicon record: at://${authorityDid}/${LEXICON_COLLECTION}/${nsid}`); +} + +export async function publishLexiconsFromEnv(): Promise { + const identifier = process.env.LEXICON_AUTHORITY_IDENTIFIER; + const password = process.env.LEXICON_AUTHORITY_PASSWORD; + + if (!identifier || !password) { + throw new Error("LEXICON_AUTHORITY_IDENTIFIER and LEXICON_AUTHORITY_PASSWORD are required"); + } + + const pdsUrl = + process.env.LEXICON_AUTHORITY_PDS_URL ?? process.env.AT_PROTOCOL_PDS ?? "https://bsky.social"; + + const agent = new AtpAgent({ service: pdsUrl }); + await agent.login({ identifier, password }); + + const authorityDid = agent.assertDid; + console.log(`Publishing lexicons as ${identifier} (${authorityDid}) on ${pdsUrl}`); + + await publishAskimutLexicons(agent); + + for (const nsid of getPublishedLexiconNsids()) { + await verifyPublishedLexicon(agent, nsid, authorityDid); + } + + console.log("Lexicon publication complete"); +} diff --git a/src/lib/link-input.ts b/src/lib/link-input.ts index 9b4e2db..3aa69b4 100644 --- a/src/lib/link-input.ts +++ b/src/lib/link-input.ts @@ -11,7 +11,7 @@ const AT_URI_RE = /^at:\/\/[^/]+\/[^/]+\/[^/?#]+$/; const ASKIMUT_PERMALINK_RE = /^https?:\/\/[^/]+\/([^/]+)\/([^/?#]+)$/; -const ASKIMUT_PRODUCTION_ORIGIN = "https://askimut.app"; +const ASKIMUT_PRODUCTION_ORIGIN = "https://askimut.at"; const HTTPS_URL_RE = /^https?:\/\/.+/; diff --git a/src/lib/oauth-scope.ts b/src/lib/oauth-scope.ts index cd3ae2e..4a6609f 100644 --- a/src/lib/oauth-scope.ts +++ b/src/lib/oauth-scope.ts @@ -3,4 +3,4 @@ * @see docs/adr/0015-granular-oauth-permissions.md */ export const OAUTH_SCOPE = - "atproto include:com.askimut.appPermissions repo:app.bsky.feed.post?action=create"; + "atproto include:at.askimut.appPermissions repo:app.bsky.feed.post?action=create"; diff --git a/src/lib/question-uri.ts b/src/lib/question-uri.ts index 78d6adb..ae7fb04 100644 --- a/src/lib/question-uri.ts +++ b/src/lib/question-uri.ts @@ -16,11 +16,11 @@ export type QuestionUriFields = { authorDid: string; }; -/** Canonical at-uri used in com.askimut.answer.questionUri for a Question row. */ +/** Canonical at-uri used in at.askimut.answer.questionUri for a Question row. */ export function resolveQuestionUri(q: QuestionUriFields): string { if (q.atUri) return q.atUri; if (q.sourceUri) return q.sourceUri; - return constructAtUri(q.authorDid, "com.askimut.question", "placeholder"); + return constructAtUri(q.authorDid, "at.askimut.question", "placeholder"); } /** Find a Question by its protocol identity (native atUri or foreign sourceUri). */ diff --git a/src/migrate.ts b/src/migrate.ts index 8313c9e..f216489 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -1,8 +1,8 @@ /** * Apply pending Drizzle SQL migrations. * - * Used locally via `pnpm db:migrate` and in production via the one-shot - * `migrate` Docker Compose service (`node .output/server/migrate.mjs`). + * Used locally via `pnpm db:migrate` and in production via the web container + * entrypoint (`node .output/server/migrate.mjs` before the server starts). */ import path from "node:path"; diff --git a/src/publish-lexicons.ts b/src/publish-lexicons.ts new file mode 100644 index 0000000..3bd94b4 --- /dev/null +++ b/src/publish-lexicons.ts @@ -0,0 +1,12 @@ +/** + * Publish Askimut lexicon.schema records to the network authority repo. + * + * Used locally via `pnpm publish:lexicons` and in production via the web + * container entrypoint (`node .output/server/publish-lexicons.mjs`). + */ +import { publishLexiconsFromEnv } from "~/lib/lexicon-publish"; + +publishLexiconsFromEnv().catch((err) => { + console.error("Lexicon publication failed:", err); + process.exit(1); +}); diff --git a/src/routes/[handle]/GroupedStackDisplay.tsx b/src/routes/[handle]/GroupedStackDisplay.tsx index b6c7902..4a25c3d 100644 --- a/src/routes/[handle]/GroupedStackDisplay.tsx +++ b/src/routes/[handle]/GroupedStackDisplay.tsx @@ -1,7 +1,7 @@ -import { type JSX } from "solid-js"; +import { createEffect, createSignal, type JSX } from "solid-js"; import QuestionCard from "~/components/QuestionCard"; -import { StackCards } from "~/components/StackCards"; +import { StackCards, type StackDirection } from "~/components/StackCards"; import { MAX_CARDS, type QuestionWithAnswer } from "~/lib/grouping"; import styles from "./[id].module.css"; @@ -29,6 +29,17 @@ export function GroupedStackDisplay(props: { onNavigate: (id: string) => void; }): JSX.Element { const activeIndex = () => props.questions.findIndex((q) => q.id === props.activeId); + const [direction, setDirection] = createSignal(0); + + let prevIndex = activeIndex(); + createEffect(() => { + const idx = activeIndex(); + if (idx < 0) return; + if (prevIndex >= 0 && idx !== prevIndex) { + setDirection(idx > prevIndex ? 1 : -1); + } + prevIndex = idx; + }); const canPrev = () => activeIndex() > 0; const canNext = () => { @@ -93,6 +104,9 @@ export function GroupedStackDisplay(props: {
q.id} similar={visibleSimilar() as unknown as QuestionWithAnswer[]} hiddenCount={hiddenCount()} renderCard={ diff --git a/src/routes/[handle]/StackedGroupItem.tsx b/src/routes/[handle]/StackedGroupItem.tsx index 8378b04..c8b769a 100644 --- a/src/routes/[handle]/StackedGroupItem.tsx +++ b/src/routes/[handle]/StackedGroupItem.tsx @@ -2,7 +2,7 @@ import { Show, createSignal, type JSX } from "solid-js"; import AnswerBlock from "~/components/AnswerBlock"; import QuestionCard from "~/components/QuestionCard"; -import { StackCards } from "~/components/StackCards"; +import { StackCards, type StackDirection } from "~/components/StackCards"; import type { QuestionGroup, QuestionWithAnswer } from "~/lib/grouping"; import { answerContentAttribution } from "~/lib/answer-content/types"; import { SOURCE_TYPES } from "~/lib/shared-schemas"; @@ -15,6 +15,7 @@ export function StackedGroupItem(props: { showPendingHint?: boolean; }): JSX.Element { const [activeIdx, setActiveIdx] = createSignal(0); + const [direction, setDirection] = createSignal(0); const visible = () => [props.group.primary, ...props.group.similar]; const activeQuestion = () => visible()[activeIdx()] ?? props.group.primary; @@ -28,8 +29,14 @@ export function StackedGroupItem(props: { const canPrev = () => activeIdx() > 0; const canNext = () => activeIdx() < visible().length - 1; - const prev = () => setActiveIdx(Math.max(0, activeIdx() - 1)); - const next = () => setActiveIdx(Math.min(visible().length - 1, activeIdx() + 1)); + const prev = () => { + setDirection(-1); + setActiveIdx(Math.max(0, activeIdx() - 1)); + }; + const next = () => { + setDirection(1); + setActiveIdx(Math.min(visible().length - 1, activeIdx() + 1)); + }; const renderCard = (q: QuestionWithAnswer, style: Record) => (
@@ -69,6 +76,9 @@ export function StackedGroupItem(props: {
q.id} similar={similar()} hiddenCount={props.group.hiddenCount} renderCard={renderCard} diff --git a/src/routes/[handle]/[id].tsx b/src/routes/[handle]/[id].tsx index 7c4bacf..0e982fb 100644 --- a/src/routes/[handle]/[id].tsx +++ b/src/routes/[handle]/[id].tsx @@ -67,7 +67,7 @@ export default function QuestionDetail() { if (q.answer?.answerContent?.id && q.answer.answerContent.atUri) { const acUri = q.answer.answerContent.atUri; - if (!acUri.includes("com.askimut.answerContent")) { + if (!acUri.includes("at.askimut.answerContent")) { void revalidateExternalContent(q.answer.answerContent.id).catch(() => {}); } }