feat: panproto v0.39.0 + per-file Merkle schema trees + granular OAuth scopes Pin panproto to v0.39.0 (panproto/panproto#49) so each commit's project schema is stored as a SchemaTree of FileSchemaObject leaves, deduplicated by git blob OID. Cospan's import flow shrinks from ~9 GB to ~260 MiB projected for the full 215-commit history. - Replace every Object::Schema match with vcs::resolve_commit_schema or native walk_tree iteration. get_project_schema and get_file_schema now read per-file leaves directly instead of parsing vertex-id prefixes. - Round-trip integration test exercises FileSchema → SchemaTree → Commit → ref → walk; verifies content-addressed dedup holds (re-putting an unchanged leaf returns the same ObjectId). - Granular OAuth scopes: dev.cospan.auth.{reader,contributor,maintainer, owner,push}Access permission-set lexicons drive ATProto consent screens. RequiredScope/RequiredRole extractors gate XRPC handlers; collaborator. add and createPushToken now demand session + role. - Frontend lib/auth/scopes.ts mirrors the Rust intents and surfaces the granted scope through locals.user.scope. - Push-token JWT signature verification: cospan-node now resolves the appview JWKS by `kid` and rejects forged or expired tokens. - Refresh stale test_handlers assertions to match the updated wire format (listRefs `name`/`target`, getHead flat branch/detached, negotiate contract); fix three pre-existing CI clippy errors.

Author: Aaron Steven White
Commit 0ea2d76b1f38f2c3ff9cae5e533b5772e3307f23
Parent: aedf5c8b48
Structural diff unavailable

These commits were pushed via plain git push, so no pre-parsed schemas are available. Install git-remote-cospan and re-push via panproto:// to see scope-level changes, breaking change detection, and semantic diffs.

brew install panproto/tap/git-remote-cospan
71 files changed +3053 -1104
@@ -610,9 +610,12 @@ dependencies = [
610610  "blake3",
611611  "chrono",
612612  "dirs",
613+ "elliptic-curve",
613614  "git2",
614615  "jsonwebtoken",
615616  "mockall",
617+ "moka",
618+ "p256",
616619  "panproto-check",
617620  "panproto-core",
618621  "panproto-git",
@@ -2492,8 +2495,8 @@ dependencies = [
24922495 
24932496 [[package]]
24942497 name = "panproto-check"
2495-version = "0.34.0"
2496-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2498+version = "0.39.0"
2499+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
24972500 dependencies = [
24982501  "memchr",
24992502  "panproto-gat",
@@ -2508,8 +2511,8 @@ dependencies = [
25082511 
25092512 [[package]]
25102513 name = "panproto-core"
2511-version = "0.34.0"
2512-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2514+version = "0.39.0"
2515+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25132516 dependencies = [
25142517  "panproto-check",
25152518  "panproto-gat",
@@ -2524,8 +2527,8 @@ dependencies = [
25242527 
25252528 [[package]]
25262529 name = "panproto-expr"
2527-version = "0.34.0"
2528-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2530+version = "0.39.0"
2531+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25292532 dependencies = [
25302533  "rustc-hash",
25312534  "serde",
@@ -2534,8 +2537,8 @@ dependencies = [
25342537 
25352538 [[package]]
25362539 name = "panproto-expr-parser"
2537-version = "0.34.0"
2538-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2540+version = "0.39.0"
2541+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25392542 dependencies = [
25402543  "chumsky",
25412544  "logos 0.16.1",
@@ -2544,9 +2547,10 @@ dependencies = [
25442547 
25452548 [[package]]
25462549 name = "panproto-gat"
2547-version = "0.34.0"
2548-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2550+version = "0.39.0"
2551+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25492552 dependencies = [
2553+ "miette",
25502554  "panproto-expr",
25512555  "rustc-hash",
25522556  "serde",
@@ -2555,8 +2559,8 @@ dependencies = [
25552559 
25562560 [[package]]
25572561 name = "panproto-git"
2558-version = "0.34.0"
2559-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2562+version = "0.39.0"
2563+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25602564 dependencies = [
25612565  "git2",
25622566  "miette",
@@ -2573,8 +2577,8 @@ dependencies = [
25732577 
25742578 [[package]]
25752579 name = "panproto-grammars"
2576-version = "0.34.0"
2577-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2580+version = "0.39.0"
2581+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25782582 dependencies = [
25792583  "cc",
25802584  "serde",
@@ -2585,8 +2589,8 @@ dependencies = [
25852589 
25862590 [[package]]
25872591 name = "panproto-inst"
2588-version = "0.34.0"
2589-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2592+version = "0.39.0"
2593+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
25902594 dependencies = [
25912595  "bumpalo",
25922596  "panproto-expr",
@@ -2601,8 +2605,8 @@ dependencies = [
26012605 
26022606 [[package]]
26032607 name = "panproto-io"
2604-version = "0.34.0"
2605-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2608+version = "0.39.0"
2609+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
26062610 dependencies = [
26072611  "bumpalo",
26082612  "memchr",
@@ -2624,8 +2628,8 @@ dependencies = [
26242628 
26252629 [[package]]
26262630 name = "panproto-lens"
2627-version = "0.34.0"
2628-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2631+version = "0.39.0"
2632+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
26292633 dependencies = [
26302634  "panproto-expr",
26312635  "panproto-gat",
@@ -2641,8 +2645,8 @@ dependencies = [
26412645 
26422646 [[package]]
26432647 name = "panproto-lens-dsl"
2644-version = "0.34.0"
2645-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2648+version = "0.39.0"
2649+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
26462650 dependencies = [
26472651  "miette",
26482652  "nickel-lang",
@@ -2660,9 +2664,10 @@ dependencies = [
26602664 
26612665 [[package]]
26622666 name = "panproto-mig"
2663-version = "0.34.0"
2664-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2667+version = "0.39.0"
2668+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
26652669 dependencies = [
2670+ "blake3",
26662671  "panproto-expr",
26672672  "panproto-gat",
26682673  "panproto-inst",
@@ -2674,8 +2679,8 @@ dependencies = [
26742679 
26752680 [[package]]
26762681 name = "panproto-parse"
2677-version = "0.34.0"
2678-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2682+version = "0.39.0"
2683+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
26792684 dependencies = [
26802685  "memchr",
26812686  "miette",
@@ -2694,8 +2699,8 @@ dependencies = [
26942699 
26952700 [[package]]
26962701 name = "panproto-project"
2697-version = "0.34.0"
2698-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2702+version = "0.39.0"
2703+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
26992704 dependencies = [
27002705  "blake3",
27012706  "globset",
@@ -2704,6 +2709,7 @@ dependencies = [
27042709  "panproto-parse",
27052710  "panproto-protocols",
27062711  "panproto-schema",
2712+ "panproto-vcs",
27072713  "rustc-hash",
27082714  "serde",
27092715  "serde_json",
@@ -2714,8 +2720,8 @@ dependencies = [
27142720 
27152721 [[package]]
27162722 name = "panproto-protocols"
2717-version = "0.34.0"
2718-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2723+version = "0.39.0"
2724+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
27192725 dependencies = [
27202726  "blake3",
27212727  "panproto-gat",
@@ -2729,8 +2735,8 @@ dependencies = [
27292735 
27302736 [[package]]
27312737 name = "panproto-schema"
2732-version = "0.34.0"
2733-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2738+version = "0.39.0"
2739+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
27342740 dependencies = [
27352741  "panproto-expr",
27362742  "panproto-gat",
@@ -2742,8 +2748,8 @@ dependencies = [
27422748 
27432749 [[package]]
27442750 name = "panproto-vcs"
2745-version = "0.34.0"
2746-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2751+version = "0.39.0"
2752+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
27472753 dependencies = [
27482754  "blake3",
27492755  "panproto-check",
@@ -2763,8 +2769,8 @@ dependencies = [
27632769 
27642770 [[package]]
27652771 name = "panproto-xrpc"
2766-version = "0.34.0"
2767-source = "git+https://github.com/panproto/panproto.git?tag=v0.34.0#c3880731588e50c142dd693d8108f0f16456c929"
2772+version = "0.39.0"
2773+source = "git+https://github.com/panproto/panproto.git?tag=v0.39.0#02158abb80252378a21bb1a9bee839d053a21795"
27682774 dependencies = [
27692775  "hex",
27702776  "miette",
@@ -13,24 +13,24 @@ license = "AGPL-3.0-or-later"
1313 repository = "https://github.com/cospan-dev/cospan"
1414 
1515 [workspace.dependencies]
16-# panproto v0.34.0
17-panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
18-panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
19-panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
20-panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
21-panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
22-panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
23-panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
24-panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
25-panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
26-panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
27-panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
28-panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
29-panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
30-panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
31-panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0", features = ["group-all"] }
32-panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
33-panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.34.0" }
16+# panproto v0.39.0 (Merkle file-schema trees, https://github.com/panproto/panproto/issues/49)
17+panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
18+panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
19+panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
20+panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
21+panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
22+panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
23+panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
24+panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
25+panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
26+panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
27+panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
28+panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
29+panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
30+panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
31+panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0", features = ["group-all"] }
32+panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
33+panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
3434 git2 = "0.20"
3535 
3636 # Web framework
@@ -7,6 +7,9 @@ declare global {
77 				authenticated: boolean;
88 				did: string;
99 				handle: string;
10+				avatar?: string;
11+				/** Raw granted scope string from the PDS (space-separated). */
12+				scope?: string;
1013 			};
1114 		}
1215 
@@ -18,6 +18,7 @@ export const handle: Handle = async ({ event, resolve }) => {
1818 					did: session.did,
1919 					handle: session.handle,
2020 					avatar: avatarCookie ? decodeURIComponent(avatarCookie) : undefined,
21+					scope: session.scope,
2122 				};
2223 			}
2324 		} catch {
@@ -0,0 +1,81 @@
1+// ATProto OAuth granular scopes — frontend helpers.
2+//
3+// Mirrors the Rust `auth::scope` module in the appview. Supports the
4+// four Cospan intents and checks whether the session's granted scope
5+// string covers a required operation. Used to gate UI actions before
6+// hitting the backend, and to compute the `?intent=...` value for
7+// login-upgrade redirects.
8+
9+export type AuthIntent = 'browse' | 'contribute' | 'maintain' | 'own';
10+
11+const PERMISSION_SET: Record<AuthIntent, string> = {
12+	browse: 'dev.cospan.auth.readerAccess',
13+	contribute: 'dev.cospan.auth.contributorAccess',
14+	maintain: 'dev.cospan.auth.maintainerAccess',
15+	own: 'dev.cospan.auth.ownerAccess',
16+};
17+
18+const INTENT_ORDER: AuthIntent[] = ['browse', 'contribute', 'maintain', 'own'];
19+
20+// Which permission-sets each intent implies (via `includes:` hierarchy).
21+// Must match apps/web/../lexicons/dev/cospan/auth/*.json.
22+const INCLUDES_CHAIN: Record<AuthIntent, AuthIntent[]> = {
23+	browse: ['browse'],
24+	contribute: ['browse', 'contribute'],
25+	maintain: ['browse', 'contribute', 'maintain'],
26+	own: ['browse', 'contribute', 'maintain', 'own'],
27+};
28+
29+/** Build the scope string to request at OAuth login for a given intent. */
30+export function buildScopeString(intent: AuthIntent, appviewDid: string | null): string {
31+	const aud = appviewDid ? encodeURIComponent(appviewDid) : '*';
32+	return `atproto include:${PERMISSION_SET[intent]}?aud=${aud}`;
33+}
34+
35+/**
36+ * Return the highest intent tier fully covered by the given granted scope
37+ * string, or `null` if only `atproto` (or nothing) was granted.
38+ */
39+export function grantedIntent(granted: string | null | undefined): AuthIntent | null {
40+	if (!granted) return null;
41+	const tokens = granted.split(/\s+/).filter(Boolean);
42+	const includes = new Set<string>();
43+	for (const tok of tokens) {
44+		if (tok.startsWith('include:')) {
45+			const head = tok.slice('include:'.length).split('?')[0];
46+			includes.add(head);
47+		}
48+	}
49+	for (const intent of [...INTENT_ORDER].reverse()) {
50+		if (includes.has(PERMISSION_SET[intent])) {
51+			return intent;
52+		}
53+	}
54+	return null;
55+}
56+
57+/** Does the granted scope string include at least `required`'s tier? */
58+export function hasIntent(
59+	granted: string | null | undefined,
60+	required: AuthIntent,
61+): boolean {
62+	const g = grantedIntent(granted);
63+	if (!g) return false;
64+	// Higher intents include lower ones.
65+	return INCLUDES_CHAIN[g].includes(required);
66+}
67+
68+/** Build a login-upgrade URL that re-triggers OAuth with a higher intent. */
69+export function buildUpgradeUrl(
70+	appviewUrl: string,
71+	handle: string,
72+	intent: AuthIntent,
73+	returnTo?: string,
74+): string {
75+	const params = new URLSearchParams({ handle, intent });
76+	if (returnTo) params.set('return', returnTo);
77+	return `${appviewUrl}/oauth/login?${params.toString()}`;
78+}
79+
80+export const ALL_INTENTS = INTENT_ORDER;
81+export const PERMISSION_SET_NSID = PERMISSION_SET;
cospan · schematic version control on atproto built on AT Protocol