Skip to content

[feature] public share links on a separate origin - #7

Merged
yui0303 merged 1 commit into
mainfrom
feature/public-share-links
Aug 24, 2026
Merged

yui0303 merged 1 commit into
mainfrom
feature/public-share-links

Conversation

@yui0303

@yui0303 yui0303 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Publishes a document as a read-only link that needs no sign-in — the shape of Claude.ai artifact sharing: create, copy, revoke. Refs #1.

Why a separate origin, not just a new path

Documents are stored author HTML written verbatim into the response body. There is no CSP, no sandbox and no sanitiser anywhere in the app — deliberately, per the README trust model: members are trusted authors, and documents are allowed their own CSS/JS/mermaid. That containment holds only because nobody but a member can reach them.

A path prefix is not a security boundary. The browser's boundary is the origin.

flowchart LR
  subgraph A["pensieve.pathors.com — session cookie"]
    APP["/o/** · /api/**"]
  end
  subgraph B["share.pensieve.pathors.com — no cookie"]
    SH["/s/:token"] --> IF["iframe: opaque origin<br/>sandbox=allow-scripts"]
  end
  IF -. "blocked: cross-origin + opaque" .-x APP
Loading

On the app origin, a shared document's script is same-origin with the app, so a logged-in visitor's cookie rides along on anything it fetches: /api/search and /api/graph return workspace content, and POST /api/sources {org, deleteId} deletes every document a sync owns. HttpOnly does not help — the script never needs to read the cookie, only to have the browser send it.

Two independent boundaries:

  1. middleware.ts (new — the repo had none) keeps the origins disjoint: /s/* serves only on SHARE_HOST, and that host serves only /s/*.
  2. The document is framed with sandbox="allow-scripts" and no allow-same-origin, so it renders in an opaque origin.

⚠️ Adding allow-same-origin to that sandbox rejoins the origins and silently removes the second boundary. There is a comment saying so at the call site.

Scoping decisions

Concern Why it needed handling What it does
Assets asset is only org-scoped — a naive share would expose the whole workspace's asset tree Referenced assets derived from the shared document's own HTML per request. No schema change, stays correct as the document changes
Backlinks The injected nav fetches member-gated /api/graph (403 → silent no-op) and its backlinks leak titles of unshared documents Nav is not injected on share routes
Links The rewrite hardcodes /o/${slug}/d — visitors would land in the authenticated namespace and 403 Root-relative asset refs get the share prefix; document links are defused
Caching Runs on Workers; a cached authz result is an authz bypass no-store on the document, private, max-age=60 on assets
Probing — Every miss returns the same 404 shape: unknown, expired and revoked are indistinguishable

Schema

One additive table. Already applied to production (hand-written DDL in a single transaction, ON_ERROR_STOP=1) — verified: 7 columns, both FKs, share_org_doc UNIQUE. Constraint names match what drizzle-kit generate produces, so this table is a genuine no-op for any future diff.

CREATE TABLE "share" (
  "id"              text PRIMARY KEY NOT NULL,
  "organization_id" text NOT NULL REFERENCES "organization"("id") ON DELETE CASCADE,
  "document_path"   text NOT NULL,
  "visibility"      text NOT NULL DEFAULT 'link',
  "created_by"      text REFERENCES "user"("id") ON DELETE SET NULL,
  "created_at"      timestamp NOT NULL DEFAULT now(),
  "expires_at"      timestamp
);
CREATE UNIQUE INDEX "share_org_doc" ON "share" ("organization_id","document_path");

The row id is the capability, so revoking deletes the row and re-sharing mints a fresh token — old links die immediately. document_path is deliberately not a composite FK to document(organization_id, path): lib/github.ts prunes every document a source owns when a sync yields zero files, and ON DELETE CASCADE would take all of that workspace's share links with it. Both share routes join to document and 404 on an orphan instead. Worth revisiting once that prune is fixed.

visibility ships as link only; authenticated and org are reserved. Note the app has no role checks anywhere today (member.role is stored but never consulted), so any member can share — consistent with the rest, flagged in the route if publishing should be owner-only.

Infra

share.pensieve.pathors.com is already bound to the pensieve worker (zone pathors.com); DNS resolves and TLS is issued. SHARE_HOST is a plaintext var in wrangler.jsonc, not a secret, so it ships with the deploy. Until this merges and deploys, that host serves the old worker — harmless (cookies are host-only and the OAuth redirect is pinned to the app origin, so nothing authenticates there), but share URLs 404 until then.

SHARE_HOST unset disables the host split, so local dev still works on localhost:3000.

Verification

tsc --noEmit clean; next build clean, with /s/[token], /s/[token]/d/[...path], /api/shares and Middleware all registered. Not yet exercised end-to-end against prod — that needs this deployed.

Documents can be published as read-only links that need no sign-in, in the
shape of Claude.ai artifact sharing: create a link, copy it, revoke it.

Why a separate origin rather than a new path

Documents are stored author HTML and are written verbatim into the response
body — there is no CSP, no sandbox and no sanitiser anywhere in the app, by
design (README's trust model: members are trusted authors, and documents are
allowed their own CSS/JS/mermaid). That containment holds only because just
members can reach them.

A path prefix is not a security boundary; the browser's boundary is the
origin. On pensieve.pathors.com a shared document's script would be same-origin
with the app, so a logged-in visitor's cookie would ride along on any fetch it
makes to /api/* — readable workspace content, and POST /api/sources can delete
every document a sync owns. So shares live on share.pensieve.pathors.com, and
middleware.ts keeps the two from overlapping: /s/* serves only there, and that
host serves only /s/*.

The document is additionally framed with sandbox="allow-scripts" and no
allow-same-origin, so it renders in an opaque origin. The two boundaries are
independent; adding allow-same-origin silently removes the second one.

Scoping

- Assets: the asset table is only org-scoped, so a naive share would expose the
  whole workspace's asset tree. Referenced assets are derived from the shared
  document's own HTML per request — no schema change, stays correct as the
  document changes.
- Backlinks: the workspace nav is not injected. It fetches the member-gated
  /api/graph (which would 403 into a silent no-op) and its backlinks would leak
  the titles of unshared documents.
- Links: root-relative asset refs get the share prefix; root-relative document
  links are defused rather than pointed at the authenticated namespace.
- Caching: no-store on the document, so revocation is not outlived by a cached
  copy; assets private, max-age=60.
- Misses all return the same 404 shape, so probing cannot separate unknown from
  expired from revoked.

The share row's id is the capability, so revoking deletes the row and re-sharing
mints a fresh token — old links die. document_path is deliberately not a foreign
key; both share routes join to document and 404 on an orphan.
@yui0303
yui0303 merged commit d24edda into main Aug 24, 2026
1 check failed
@github-actions

Copy link
Copy Markdown

❌ SonarQube Quality Gate ERROR — pathorsAI_pensieve

failed condition value threshold
new_violations 1 ≤ 0

1 open issue on this PR:

  • MAJOR typescript:S3358 — Extract this nested ternary operation into an independent statement. (app/o/[slug]/share-button.tsx:97)

@yui0303

yui0303 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@yui 這個 PR 掛 auto-merge 時因為 repo 沒有必要審核/blocking check,當場就 merge 了,沒有經過你 review。內容已經在 main,但還沒部署(worker 最後部署是 8/18,Workers Builds 尚未接上),所以還來得及在上線前看。

兩個想請你特別確認的點:

  1. sandbox 屬性 — app/s/[token]/route.ts 的 iframe 是 sandbox="allow-scripts" 且刻意不給 allow-same-origin。兩者同時出現等於防護歸零。call site 有註解,但這是最容易被後人「順手加上去修好」而破功的地方。

  2. schema 已先套用到 production — share 表是手寫 DDL 在單一 transaction 跑的,不是 drizzle-kit push。所以這次不需要再跑 migration。

另外順帶回報一個不在本次範圍的既有問題:lib/github.ts:127-131 的 prune 在同步產出零份文件時會走 sql\true`分支,刪光該來源的所有文件(folder 打錯字、分支沒有 .html、或 GitHub 回傳 truncated tree 都會觸發)。這也是我沒有把share.document_path` 設成複合 FK 的原因 —— 否則那個 bug 會連分享連結一起清空。

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.

1 participant