Skip to content

feat(signing): sign records written by certified.app with #createdVia (TECH-789) - #254

Merged
holkexyz merged 17 commits into
mainfrom
staging
Oct 29, 2026
Merged

holkexyz merged 17 commits into
mainfrom
staging

Conversation

@svc-agent

@svc-agent svc-agent commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Implements TECH-789. Every hypercerts-lexicon record certified.app writes (app.certified.*, org.hypercerts.*, org.hyperboards.*) carries an app.certified.signature.defs#createdVia signature, following the ATProtocol Attestation Spec. app.bsky.* and org.impactindexer.* stay unsigned.

What changed

  • Signing points:

    • the xrpc proxy, for own-repo create and put;
    • groupServiceFetch, for every group service create and put, bound to the group DID;
    • the first-sign-in profile seed.

    Signing fails open and never blocks a write.

  • Group activity route: it called the group service with a raw fetch. It now goes through groupServiceFetch, so group activities are signed too.

  • /.well-known/did.json: serves the per-host did:web document. It returns 200 when a key is set, 404 when signing is off, and 500 when the key is malformed.

  • Same-session reads: getRecord and listRecords return lexToJson output, so $bytes survives the edit round trip. Legacy blobs keep their shape.

  • Docs: env vars, AGENTS.md §16a and the public keys for both environments.

  • Tests:

    • a contract test that pins the signing points;
    • scripts/verify-created-via.mjs, which verifies a stored record the way a third party would.

Keys

Environment DID Public key
staging did:web:staging.certified.app did:key:zQ3shkw7ytTFdJmn3epcWaqTXED3tZFu8SbsCwZBfGAWFA868
production did:web:certified.app did:key:zQ3shirLmopLSHcwUNnfBaoPMNCrtLWxLZtup2UpiX7oEV56P

The production CREATED_VIA_SIGNING_KEY and CREATED_VIA_DID are set in Vercel, scoped to Production only.

Verified on staging

staging.certified.app/.well-known/did.json returns 200 without authentication and serves the recorded key. scripts/verify-created-via.mjs passes for each record below: the CID matches the PDS and the signature verifies for the right repository.

  • personal activity: created, then edited
  • group activity: created, then edited, signed for the group DID
  • app.certified.actor.profile: saved in the app

After merge

  • curl https://certified.app/.well-known/did.json should return 200 with zQ3shirLm….
  • Verify one new activity on certified.app.

Follow-up issue: server-side verification and a "Created via certified.app" badge.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 4/5

The PR appears safe to merge, with a non-blocking compatibility fix needed in the verification script.

Fix All in Claude CodeFindings

  1. P2 Full service references fail ▶
Fix with agent prompt
### Issue 1
scripts/verify-created-via.mjs:82
`verify-created-via.mjs` only finds a PDS service whose `id` is exactly `#atproto_pds`. A valid DID document can use the full reference, such as `did:plc:abc#atproto_pds`. Those records fail with “no #atproto_pds service” before verification starts. Accept both forms, as `resolvePdsUrl` already does.

```suggestion
  const pds = repoDoc.service?.find(
    (s) => s.id === "#atproto_pds" || s.id === `${repo}#atproto_pds`,
  )?.serviceEndpoint
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds #createdVia signatures to personal writes, group writes, and the first-sign-in Certified profile seed.

  • Supported Certified records now carry a signature for their repository.
  • Group record writes now carry a signature for the group DID.
  • The public DID endpoint now publishes the signing key.
  • Same-session record reads now keep bytes usable in edits.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Personal create or put] --> D[signCreatedVia]
  B[Group create or put] --> C[groupServiceFetch]
  C --> D
  E[First-sign-in profile seed] --> D
  D --> F{Signing succeeds?}
  F -->|Yes| G[Write with fresh createdVia]
  F -->|No| H[Write without createdVia]
  I[did.json] --> J[Publish public signing key]
Loading

Reviews (1) · Last reviewed commit: "Merge main into staging (#259, groups sp..." · Reviewed by Greptile

holkexyz and others added 3 commits October 27, 2026 18:10
… (TECH-789)

Every hypercerts-lexicon record certified.app writes (app.certified.*,
org.hypercerts.*, org.hyperboards.*) now carries an
app.certified.signature.defs#createdVia entry, signed per the ATProtocol
Attestation Spec: secp256k1 over the CID of the record plus $sig bound
to the repository DID. app.bsky.* and org.impactindexer.* stay unsigned.

- Signing points: the xrpc proxy (own-repo create/put), groupServiceFetch
  (every CGS create/put, signed for the group DID) and the first-sign-in
  profile seed. Signing fails open and never blocks a write.
- The group activity route used a raw fetch to CGS; it now goes through
  groupServiceFetch so group activities are signed too.
- /.well-known/did.json serves the per-host did:web document (200 when
  a key is set, 404 when disabled, 500 for a malformed key).
- Same-session getRecord/listRecords return lexToJson output so $bytes
  survives the client edit round trip; legacy blobs keep their shape.
- The key is CREATED_VIA_SIGNING_KEY plus CREATED_VIA_DID, which must
  match did:web:<PUBLIC_URL host>. Unset means signing is off.
- A contract test pins the signing points; scripts/verify-created-via.mjs
  verifies a stored record the way a third party would.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
certified-app Ready Ready Preview Oct 29, 2026 8:27am UTC
certified-app (staging) Ready Ready Preview Oct 29, 2026 8:27am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedserver-only@​0.0.11001004679100
Added@​atproto/​lex-cbor@​0.1.71001007699100
Added@​atproto/​lex-json@​0.1.71001007799100
Added@​atproto/​lex-data@​0.1.81001007899100
Added@​atproto/​crypto@​0.5.71001008598100

View full report

…XSS, TECH-796)

prosemirror-view 1.41.8 rebuilt the data-pm-slice paste context without
running the schema's attribute checks (GHSA-c8x8-7fp4-3x9w, fixed in
1.42.3). @tiptap/core <= 3.30.4 also allowed __proto__ attribute
injection into the DOM (GHSA-cp6q-959q-f8rh). Both sit on the editor's
paste path.

- @tiptap/* 3.23.4 -> 3.32.0 (pins @tiptap/pm 3.32.0)
- prosemirror-view 1.41.8 -> 1.42.8, prosemirror-model 1.25.6 -> 1.25.12
- overrides floors for prosemirror-view and @tiptap/core so a lockfile
  regeneration cannot fall back to a vulnerable version
- vitest guard asserting the installed versions stay above the floors

npm audit --omit=dev no longer reports any prosemirror or tiptap advisory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
holkexyz and others added 2 commits October 28, 2026 14:52
next 16.2.6 is in the range of about 20 Next.js advisories
(9.3.4-canary.0 - 16.4.0), including Critical unauthenticated RCE in the
Image Optimization API (AVIF and SVG), which the app uses through
next/image, plus SSRF in Image Optimization and rewrites, a
middleware/proxy bypass under Turbopack, and Server Action DoS.

Only next is bumped. It also brings sharp 0.35.5, which clears that
High advisory. eslint-config-next stays at 16.2.6: 16.4.3 pulls in
eslint-plugin-react-hooks 7.1, whose new rules raise 17 lint errors in
unrelated code, so it gets a separate cleanup.

npm audit --omit=dev no longer reports next or sharp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isAllowedEmbedHost checked only the hostname, so a URL such as
javascript://www.youtube.com/... passed and reached <iframe src> in both
the editor node view and the read-only LeafletDocument renderer. React
19 currently rewrites javascript: URLs in src, so this was not
exploitable, but the guard should not rely on that.

http: embeds are also rejected now. The CSP frame-src already allows
only the https provider origins, so they could never render anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mount

With next 16.4.3 the /dev/preview smoke specs failed with "Failed to
load user profile: Profile not found". StrictMode's dev-only effect
re-run called MockFetchProvider's cleanup, which restored the real
window.fetch. The children's re-run fetch effects then hit the real
/api/resolve-did instead of the fixtures.

The restore is now deferred to the next tick and cancelled when the
effect re-runs, so the mock survives the StrictMode remount and a real
unmount still restores the real fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- postcss override and devDependency ^8.5.10 -> ^8.5.23. The override
  was forcing next's own pinned postcss 8.5.23 down to 8.5.15, which
  kept postcss (GHSA-r28c-9q8g-f849, GHSA-fxqj-rqcc-2cmp), nanoid and
  source-map-js flagged. Resolves to postcss 8.5.32.
- undici 6.24.1 -> 6.29.1 (lockfile only, within @atproto-labs/fetch-node's
  ^6.14.1). The handle resolver fetches attacker-chosen domains, so the
  DoS, header injection and TLS advisories were reachable.
- resend 6.10 -> 6.35, which drops svix and its vulnerable uuid.

npm audit --omit=dev: 24 -> 16 findings, all of them zod <4.6.6
(GHSA-mrhx-c7xc-m3w5) through @atproto/*, which has no zod 3 fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the standard nonce-based "strict CSP" alongside the existing
policy, as Content-Security-Policy-Report-Only, so violations are
reported without blocking anything:

  script-src 'nonce-<per request>' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none'; report-uri /api/csp-report; report-to csp-endpoint

`https:` and 'unsafe-inline' are fallbacks for pre-CSP3 browsers; browsers
that understand nonces ignore them.

- src/lib/security/csp.ts is now the single source for both policies.
  The enforced policy is unchanged (a test pins it to the previous
  strings).
- src/proxy.ts generates a nonce per page request (prefetches skipped)
  and sets the policy on the request, where Next.js reads it to stamp its
  own scripts, and on the response.
- The root layout reads the nonce and passes it to next-themes for its
  inline anti-flash script. Reading request headers renders every page
  per request, which nonces require. The 19 previously static pages are
  now dynamic; only metadata files (icons, manifest, robots, sitemap)
  stay static.
- /welcome lost its hourly ISR, so its featured profiles and network
  counts now sit in an hourly unstable_cache instead of being fetched on
  every visit. (`use cache` needs Cache Components, whose static shells
  can't carry a nonce.)
- /api/csp-report logs both report formats as `[csp-report]` lines with
  query strings stripped (the OAuth callback URL carries a one-time
  code), rate-limited per IP.
- e2e/csp.spec.ts fails on any CSP violation across the public routes
  and the editor/profile/feed preview surfaces, and checks the nonce is
  fresh per request.

Verified against a production build (next start): zero violations on
11 routes including /home, /create and a record page; an injected
onerror handler is reported (negative control).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r name

On Vercel, the proxy's `content-security-policy-report-only` request
override never reached the renderer: staging served every script
without a nonce, so the report-only policy flagged all of them (locally
under `next start` it worked). Set the request copy as
`content-security-policy`, the form the Next.js guide uses. Request
headers are only input to Next.js; the browser still gets the policy as
Content-Security-Policy-Report-Only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Vercel, the enforced Content-Security-Policy from next.config.ts
headers also reached the page render as a request header and took
precedence over the proxy's. Next.js parsed that nonce-less policy and
stamped no nonces, so staging's report-only policy flagged every
script. `next start` doesn't merge headers this way, which is why
local runs were clean.

The proxy now sends both policies per request with the same nonce:

- Content-Security-Policy: the previous directives and host allowlist,
  plus the nonce. Browsers ignore 'unsafe-inline' once a nonce is
  present, so injected inline scripts and inline event handlers are now
  blocked, not just reported.
- Content-Security-Policy-Report-Only: the strict 'strict-dynamic'
  policy, unchanged.

next.config.ts no longer sets a CSP for pages. API routes, which skip
the proxy, get a static `default-src 'none'; frame-ancestors 'none'`.
The e2e spec checks both headers share the nonce, and that an injected
onerror handler doesn't run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The strict policy (script-src 'nonce-...' 'strict-dynamic', CSP2
fallbacks https: 'unsafe-inline', object-src 'none') becomes the one
enforced Content-Security-Policy and the host-allowlist policy goes.
Scripts now run only with the per-request nonce or when a nonced script
loads them; 'self' and the Vercel script hosts are no longer trusted on
their own. report-uri and report-to stay, so blocks keep reaching
/api/csp-report.

The e2e spec now also covers /oauth/callback, /embed/board and the
activity-edit, profile-org, profile-edit, settings and workspace
preview surfaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
holkexyz and others added 2 commits October 28, 2026 19:26
…ned out

Signed out, OrgProvider returned before fetching without clearing
isLoading, so every page waiting on the org list sat on its spinner
forever instead of showing the sign-in prompt. useOrgCreationLimit had
the same hang on its own since c749825: its render-time reset only
fires when the did|orgsLoading key changes, so with the org list
already settled at mount, isChecking stayed true. It now reports
isChecking only while there is a signed-in DID to check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On production the graph took about 19s to appear: about 6s of indexer
pages, then about 12s resolving ~240 DIDs in five 50-DID requests sent
one after another, and only then the first draw.

- useEndorsementGraph shows the graph as soon as its structure and the
  actor-profile index data are in, then fills handles and avatars into
  the same node objects (react-force-graph keeps layout positions on
  them, so the layout doesn't restart).
- The resolve-dids batcher sends up to three chunks of a flush at once.
  The server charges its rate limit per identity, so the volume is
  unchanged. After a 429, remaining chunks degrade to null instead of
  being sent into the cooldown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@holkexyz
holkexyz marked this pull request as ready for review October 29, 2026 12:26
@holkexyz
holkexyz merged commit e29a104 into main Oct 29, 2026
7 checks passed
const [, repo, collection, rkey] = match

const repoDoc = await resolveDidDoc(repo)
const pds = repoDoc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Full service references fail

verify-created-via.mjs only finds a PDS service whose id is exactly #atproto_pds. A valid DID document can use the full reference, such as did:plc:abc#atproto_pds. Those records fail with “no #atproto_pds service” before verification starts. Accept both forms, as resolvePdsUrl already does.

Suggested change
const pds = repoDoc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint
const pds = repoDoc.service?.find(
(s) => s.id === "#atproto_pds" || s.id === `${repo}#atproto_pds`,
)?.serviceEndpoint
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/verify-created-via.mjs
Line: 82

Comment:
**Full service references fail**

`verify-created-via.mjs` only finds a PDS service whose `id` is exactly `#atproto_pds`. A valid DID document can use the full reference, such as `did:plc:abc#atproto_pds`. Those records fail with “no #atproto_pds service” before verification starts. Accept both forms, as `resolvePdsUrl` already does.

```suggestion
  const pds = repoDoc.service?.find(
    (s) => s.id === "#atproto_pds" || s.id === `${repo}#atproto_pds`,
  )?.serviceEndpoint
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

svc-agent pushed a commit that referenced this pull request Oct 29, 2026
The verify script only matched a service id of exactly "#atproto_pds". DID
documents may also use the full reference ("did:plc:...#atproto_pds"), and
those records failed with "no #atproto_pds service" before verification
started. Accept both forms; the app's resolvePdsUrl already does.

Found by Greptile on #254.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
svc-agent pushed a commit that referenced this pull request Oct 29, 2026
The verify script only matched a service id of exactly "#atproto_pds". DID
documents may also use the full reference ("did:plc:...#atproto_pds"), and
those records failed with "no #atproto_pds service" before verification
started. Accept both forms; the app's resolvePdsUrl already does.

Found by Greptile on #254.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
staging — 6f50a37d Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants