diff --git a/at/knot/README.md b/at/knot/README.md index 2b1ccb5660..76fdb4b979 100644 --- a/at/knot/README.md +++ b/at/knot/README.md @@ -4,6 +4,34 @@ Self-hosted [Tangled](https://tangled.org) knot server co-located on the PDS droplet (`at.aesthetic.computer`). Provides decentralized git hosting under AC's ATProto identity. +## Current deployment — 2026-10-08 + +Production runs **v1.16.1-alpha**, upstream commit +`1d379a324497da39a27e49453c72615607a9b199`, built with Go 1.25.9. +The public version endpoint advertises `knot-acl`: + +```bash +curl --fail https://knot.aesthetic.computer/xrpc/sh.tangled.knot.version +git ls-remote git@knot.aesthetic.computer:aesthetic.computer/core refs/heads/main +``` + +The upgrade preserved all Git refs; SQLite integrity, HTTPS and SSH reads, +and the Tangled repository page were checked. The service now uses +`GOMEMLIMIT=768MiB`, `MemoryHigh=1536M`, and `MemoryMax=2G` after four kernel +OOM kills in the preceding day. These limits protect the shared PDS host; +longer observation is still needed to assess stability under load. + +Consistent PDS and repository backups plus the previous Knot binary, +configuration, and database are stored privately on Blueberry under +`~/.local/share/aesthetic-computer/backups/at-20261008/`. The droplet retains +its rollback files in `/root/ac-at-upkeep-20261008/rollback/`. + +Signed in as `aesthetic.computer` at +[Tangled's knot dashboard](https://tangled.org/settings/knots) and completed +**Retry knot verification** after the upgrade. The AppView now reports +**Verified** rather than **Needs upgrade**. +See the [upstream migration guide](https://tangled.org/tangled.org/core/blob/master/docs/DOCS.md). + ## Prerequisites 1. PDS droplet running at `at.aesthetic.computer` (165.227.120.137) @@ -16,6 +44,12 @@ AC's ATProto identity. ## Deploy +The script below bootstraps a host; it overwrites configuration and is not an +in-place production upgrade procedure. For an existing knot, back up its +database, repositories, binary and configuration, build the pinned release +separately, and stop the service only for the final snapshot and binary swap. +Preserve the existing Caddy/PDS configuration and allow for database rollback. + ```fish cd at/knot/deployment fish deploy.fish @@ -63,9 +97,9 @@ SSH :22 /home/git/ ├─ .knot.env # config - ├─ repositories/ # bare git repos + ├─ repositories/ # bare git repos, keyed by repository DID ├─ database/ # knotserver.db (SQLite) - └─ log/ # knot.log + └─ logs/ # knot.log (service stdout/stderr) ``` Co-hosts with PDS — same droplet, separate subdomain. diff --git a/at/knot/deployment/deploy.sh b/at/knot/deployment/deploy.sh index ad4b5cb930..2a9a8b6d3e 100755 --- a/at/knot/deployment/deploy.sh +++ b/at/knot/deployment/deploy.sh @@ -22,8 +22,8 @@ KNOT_PUBLIC_PORT=5555 KNOT_INTERNAL_PORT=5444 APPVIEW_ENDPOINT="https://tangled.org" # Tag or commit SHA from https://tangled.org/@tangled.org/core (empty = build master HEAD). -# Currently pinned to a master SHA past v1.13.0-alpha — no v1.14 tag exists yet. -KNOT_VERSION="${KNOT_VERSION:-3ff418dad639a9755b9ef7509ed948578d89be8b}" +# v1.16.1-alpha, verified 2026-10-08 (1d379a324497da39a27e49453c72615607a9b199). +KNOT_VERSION="${KNOT_VERSION:-v1.16.1-alpha}" # PDS droplet — knot co-hosts here PDS_DEPLOY_DIR="$SCRIPT_DIR/../../pds/deployment/digitalocean" @@ -165,7 +165,8 @@ if [ -n "${KNOT_VERSION:-}" ]; then git checkout "$KNOT_VERSION" fi -CGO_ENABLED=1 go build -o knot ./cmd/knot +# The PDS shares this droplet; bound compiler parallelism and Go heap use. +GOMAXPROCS=1 GOMEMLIMIT=700MiB CGO_ENABLED=1 go build -p 1 -o knot ./cmd/knot # Preserve previous binary for rollback if [ -f /usr/local/bin/knot ]; then @@ -217,8 +218,8 @@ EOF set -euo pipefail chown git:git /home/git/.knot.env chmod 600 /home/git/.knot.env -mkdir -p /home/git/repositories /home/git/database /home/git/log -chown -R git:git /home/git/repositories /home/git/database /home/git/log +mkdir -p /home/git/repositories /home/git/database /home/git/logs +chown -R git:git /home/git/repositories /home/git/database /home/git/logs # Optional MOTD printf "aesthetic computer knot\n" > /home/git/motd diff --git a/at/knot/infra/knotserver.service b/at/knot/infra/knotserver.service index 73aa90d2bb..cac4c23e2d 100644 --- a/at/knot/infra/knotserver.service +++ b/at/knot/infra/knotserver.service @@ -11,8 +11,11 @@ EnvironmentFile=/home/git/.knot.env ExecStart=/usr/local/bin/knot server Restart=on-failure RestartSec=5 -StandardOutput=append:/home/git/log/knot.log -StandardError=append:/home/git/log/knot.log +Environment=GOMEMLIMIT=768MiB +MemoryHigh=1536M +MemoryMax=2G +StandardOutput=append:/home/git/logs/knot.log +StandardError=append:/home/git/logs/knot.log # Hardening NoNewPrivileges=true diff --git a/at/pds/README.md b/at/pds/README.md index d54805cc54..f9a094c787 100644 --- a/at/pds/README.md +++ b/at/pds/README.md @@ -12,11 +12,18 @@ Running our own PDS gives us: - **Privacy**: No third-party data hosting - **Federation**: Still interoperate with Bluesky and other ATProto networks -## Current Status - -🔴 **Not Deployed** - Planning and preparation phase - -Currently using: `https://bsky.social` (Bluesky's official PDS) +## Current Status — 2026-10-08 + +Live at **https://at.aesthetic.computer**, serving 4,203 repositories marked +active (account state, not daily activity). The running version is `0.4.5037`; +pulling the official `ghcr.io/bluesky-social/pds:0.4` image confirmed it is current. +Your `jeffrey.at.aesthetic.computer` repository's latest revision and CID match +Bluesky's relay. The separate `aesthetic.computer` account remains Bluesky-hosted. + +The droplet also hosts [Tangled Knot](../knot/README.md). Run +`bash at/pds/scripts/health-check.sh` from the repository root for public checks. +The deployment sizing and initial setup notes below are historical planning; +the live hostname is `at.aesthetic.computer`, not `pds.aesthetic.computer`. ## Architecture diff --git a/at/pds/scripts/health-check.sh b/at/pds/scripts/health-check.sh index 6a561a962b..f645628af2 100644 --- a/at/pds/scripts/health-check.sh +++ b/at/pds/scripts/health-check.sh @@ -5,7 +5,7 @@ set -euo pipefail # Configuration -PDS_URL="${PDS_URL:-https://pds.aesthetic.computer}" +PDS_URL="${PDS_URL:-https://at.aesthetic.computer}" ALERT_EMAIL="${ALERT_EMAIL:-me@jas.life}" # Colors @@ -120,11 +120,12 @@ check_dns() { check_response_time() { echo -n "Response Time... " - START=$(date +%s%N) - curl -s -o /dev/null "$PDS_URL/xrpc/_health" || true - END=$(date +%s%N) - - ELAPSED=$(( ($END - $START) / 1000000 )) + # curl's timer works on both BSD/macOS and GNU systems (date +%N does not). + SECONDS_ELAPSED=$(curl --connect-timeout 5 --max-time 15 -s -o /dev/null -w '%{time_total}' "$PDS_URL/xrpc/_health") || { + echo -e "${RED}✗ FAILED${NC} (request failed)" + return 1 + } + ELAPSED=$(awk -v seconds="$SECONDS_ELAPSED" 'BEGIN { printf "%.0f", seconds * 1000 }') if [ $ELAPSED -lt 200 ]; then echo -e "${GREEN}✓ EXCELLENT${NC} (${ELAPSED}ms)" @@ -144,7 +145,7 @@ check_http_health || FAILED=$((FAILED + 1)) check_websocket || FAILED=$((FAILED + 1)) check_ssl || FAILED=$((FAILED + 1)) check_dns || FAILED=$((FAILED + 1)) -check_response_time +check_response_time || FAILED=$((FAILED + 1)) echo "" if [ $FAILED -eq 0 ]; then diff --git a/plans/atproto-integration-plan.md b/plans/atproto-integration-plan.md index 5d48a4c1fa..87d9ce503e 100644 --- a/plans/atproto-integration-plan.md +++ b/plans/atproto-integration-plan.md @@ -1,5 +1,60 @@ # ATProto Integration Plan +## Bluesky login assessment — 2026-10-08 + +This assessment supersedes the older authentication/bootstrap suggestions below. +The PDS is live at `https://at.aesthetic.computer`; AC already publishes creative +records. Google and Apple now enter through Auth0, and AC authorization and data +ownership still use the canonical Auth0 subject. The implementation below is +proposed, not enabled. + +Keep that session boundary for a first Bluesky login implementation. Add a +server-side AT Protocol OAuth broker on Lith, using the official +`@atproto/oauth-client-node` client. Expose the broker through an Auth0 custom +connection so successful logins still produce the tokens accepted by +`system/backend/authorization.mjs`. Confirm the tenant's custom-connection +capabilities before selecting its OAuth2 or OIDC adapter; AT Protocol OAuth is +not itself a drop-in OIDC provider. This is a recommendation, not a tested +Auth0 integration. + +1. Start with **Connect Bluesky** for a signed-in AC user. Require a fresh AC + authentication and a completed AT Protocol OAuth flow, bound to the same + browser and one-time linking transaction. Store an atomically unique + `(provider: atproto, subject: DID) → canonical AC subject` mapping. Refuse + links already owned by another AC account; do not merge their histories. +2. Offer **Continue with Bluesky** after linking. Resolve the verified OAuth + DID to that mapping and preserve the primary Auth0 subject. A new DID needs + an explicit existing-account linking or new-account onboarding choice; + never silently create a second AC account for a returning person. +3. Request the identity-only `atproto` scope initially. Publishing permissions + are a separate, explicit connection upgrade. Do not collect app passwords + or require people to migrate their PDS. + +The broker must use the SDK's handle/DID and issuer validation, PKCE, PAR, +DPoP, and callback state checks. Pin redirect destinations, persist expiring +OAuth state server-side, protect discovery from private-network requests, and +encrypt any retained session material. Preserve logout, account locks, +deletion checks, and recovery through an existing AC login method. Test lost +callbacks, replay, conflicting links, handle changes, and PDS migration. + +Do **not** add Bluesky to `link-email-identity.js`'s automatic email-linking +allowlist. An arbitrary self-hosted PDS can assert an email and its verification +status. Email equality, matching handles, or a public DID record alone do not +prove control of an AC account. Use the OAuth-verified DID and proof of the +existing AC session. + +Keep login identities separate from `users.atproto`, which currently points to +an AC-managed publishing repository and includes its credentials. Connecting +an external Bluesky identity must not replace that DID or move existing art. +The signup UI's provider callback also currently assumes an Auth0 client and +a verified-email provider; accommodate the DID-linking state explicitly before +adding a visible Bluesky button. + +References: [AT Protocol OAuth](https://atproto.com/specs/oauth), +[Auth0 OIDC connections](https://auth0.com/docs/authenticate/identity-providers/enterprise-identity-providers/oidc), +[current auth Action](../system/backend/auth0-actions/link-email-identity.js), +[current signup flow](../system/public/aesthetic.computer/lib/signup-flow.mjs). + ## Overview - **Goal:** Connect aesthetic computer (AC) clients, services, and media pipeline to the AT Protocol (ATProto) so creative works, identity, and live interactions flow across both ecosystems. - **Success criteria:** diff --git a/system/backend/bluesky-engagement.mjs b/system/backend/bluesky-engagement.mjs index e8f5d63365..a4c25f4d6a 100644 --- a/system/backend/bluesky-engagement.mjs +++ b/system/backend/bluesky-engagement.mjs @@ -5,7 +5,7 @@ import { AtpAgent } from "@atproto/api"; import { shell } from "./shell.mjs"; -const BSKY_SERVICE = "https://bsky.social"; +const BSKY_SERVICE = "https://public.api.bsky.app"; /** * Fetch engagement stats for a Bluesky post diff --git a/system/backend/bluesky-mirror.mjs b/system/backend/bluesky-mirror.mjs index 910e531ea5..65c693b644 100644 --- a/system/backend/bluesky-mirror.mjs +++ b/system/backend/bluesky-mirror.mjs @@ -109,7 +109,7 @@ export async function postMoodToBluesky(database, moodText, handle, atprotoRkey) const rkey = response.uri.split("/").pop(); shell.log(`✅ Posted to Bluesky: ${rkey}`); - shell.log(`🔗 View: https://bsky.app/profile/${BSKY_IDENTIFIER}/post/${rkey}`); + shell.log(`🔗 View: https://bsky.app/profile/${agent.session.did}/post/${rkey}`); return { uri: response.uri, diff --git a/system/tests/bluesky-moods.test.mjs b/system/tests/bluesky-moods.test.mjs new file mode 100644 index 0000000000..b82f39c71b --- /dev/null +++ b/system/tests/bluesky-moods.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { postMoodToBluesky } from "../backend/bluesky-mirror.mjs"; +import { fetchBlueskyEngagement, resolveDidToHandle } from "../backend/bluesky-engagement.mjs"; + +const DID = "did:plc:k3k3wknzkcnekbnyde4dbatz"; +const URI = `at://${DID}/app.bsky.feed.post/3mwec2oefr625`; +const CID = "bafyreif7345s5wcejbal2q7bxa5szldnvbdwrw2okahc2vd3hz4mps5mqy"; +const WHEN = "2026-09-25T17:25:38.586Z"; +const empty = { likes: 0, reposts: 0, replies: [], quoteCount: 0 }; +const database = { db: { collection: () => ({ findOne: async () => ({ + identifier: "aesthetic.computer", appPassword: "test-only-password", +}) }) } }; + +function mockNetwork(t, respond) { + const requests = []; + t.mock.method(globalThis, "fetch", async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return respond(request); + }); + return requests; +} + +function post(overrides = {}) { + return { + uri: URI, cid: CID, + author: { did: DID, handle: "aesthetic.computer" }, + record: { $type: "app.bsky.feed.post", text: "A mood", createdAt: WHEN }, + indexedAt: WHEN, likeCount: 3, repostCount: 2, quoteCount: 1, + ...overrides, + }; +} + +test("successful mood publication returns its reference for persistence", async (t) => { + let published; + const requests = mockNetwork(t, async (request) => { + switch (new URL(request.url).pathname) { + case "/xrpc/com.atproto.server.createSession": + return Response.json({ did: DID, handle: "aesthetic.computer", accessJwt: "test-access", refreshJwt: "test-refresh" }); + case "/xrpc/com.atproto.repo.createRecord": + published = await request.json(); + return Response.json({ uri: URI, cid: CID }); + default: + throw new Error(`Unexpected request: ${request.url}`); + } + }); + const result = await postMoodToBluesky(database, "hello", "@jeffrey", "mood-rkey"); + assert.deepEqual(result, { uri: URI, cid: CID, rkey: "3mwec2oefr625" }); + assert.equal(published.repo, DID); + assert.equal(published.collection, "app.bsky.feed.post"); + assert.equal(published.record.text, "@jeffrey: hello\n\nhttps://aesthetic.computer/moods~jeffrey~mood-rkey"); + assert.equal(requests.length, 2, "one login and one publication"); +}); + +test("a rejected Bluesky login does not publish or return a success reference", async (t) => { + const requests = mockNetwork(t, () => Response.json({ error: "AuthenticationRequired", message: "Invalid credentials" }, { status: 401 })); + assert.equal(await postMoodToBluesky(database, "hello", "@jeffrey", "mood-rkey"), null); + assert.equal(requests.length, 1); +}); + +test("engagement uses the public AppView and omits inaccessible replies", async (t) => { + const requests = mockNetwork(t, (request) => { + if (new URL(request.url).origin !== "https://public.api.bsky.app") { + return Response.json({ error: "AuthMissing", message: "Authentication Required" }, { status: 401 }); + } + return Response.json({ thread: { + $type: "app.bsky.feed.defs#threadViewPost", post: post(), + replies: [ + { $type: "app.bsky.feed.defs#notFoundPost", uri: `${URI}-deleted`, notFound: true }, + { $type: "app.bsky.feed.defs#threadViewPost", post: post({ uri: `${URI}-reply`, record: { $type: "app.bsky.feed.post", text: "hello back", createdAt: WHEN } }) }, + ], + } }); + }); + const result = await fetchBlueskyEngagement(URI); + assert.equal(result.likes, 3); + assert.equal(result.reposts, 2); + assert.equal(result.quoteCount, 1); + assert.equal(result.replies.length, 1); + assert.equal(result.replies[0].text, "hello back"); + assert.equal(result.webUrl, `https://bsky.app/profile/${DID}/post/3mwec2oefr625`); + assert.equal(requests.length, 1); + assert.equal(requests[0].headers.has("authorization"), false); + assert.equal(new URL(requests[0].url).searchParams.get("uri"), URI); +}); + +test("DID-to-handle lookup also uses the public AppView", async (t) => { + const requests = mockNetwork(t, request => new URL(request.url).origin === "https://public.api.bsky.app" + ? Response.json({ did: DID, handle: "aesthetic.computer" }) + : Response.json({ error: "AuthMissing" }, { status: 401 })); + assert.equal(await resolveDidToHandle(DID), "aesthetic.computer"); + assert.equal(requests.length, 1); + assert.equal(requests[0].headers.has("authorization"), false); +}); + +test("unmirrored moods need no request; unavailable engagement stays nonfatal", async (t) => { + const requests = mockNetwork(t, () => Response.json({ error: "NotFound" }, { status: 404 })); + assert.deepEqual(await fetchBlueskyEngagement(null), empty); + assert.equal(requests.length, 0); + assert.deepEqual(await fetchBlueskyEngagement(URI), empty); + assert.equal(requests.length, 1); +});