Skip to content
File

Blob: src/worker/repositories/route.ts

typescript126 lines
1import { createLogger, type Logger } from "@/worker/common";
2import { createDb, type Db } from "@/worker/db/d1/client";
3import type { RepositoryVisibility } from "@/worker/db/d1/schema";
4import {
5 findNamespaceById,
6 findNamespaceBySlug,
7 findRepositoryById,
8 findRepositoryByNamespaceAndSlug,
9} from "@/worker/db/d1/dal";
10import { getRouteCacheRecord } from "./routeCache";
11 
12// A route resolves only when D1 confirms the namespace slug, repo slug,
13// namespace id, repository id, and `doName` all describe the same row. KV
14// is a candidate cache; a stale entry from before a rename or visibility
15// flip must never authorize itself.
16 
17export type RepositoryRoute = {
18 routeNamespaceSlug: string;
19 routeRepoSlug: string;
20 namespaceId: string;
21 repositoryId: string;
22 doName: string;
23 visibility: RepositoryVisibility;
24 source: "kv" | "d1";
25};
26 
27export type RepositoryRouteResolutionMode = "route-cache-only" | "allow-d1-fallback";
28 
29export type RepositoryRouteResolutionOptions = {
30 // Anonymous repository serving uses only the ROUTES candidate cache so a
31 // scanner cannot force namespace/repo D1 lookups by walking arbitrary URLs.
32 // Authenticated browser and PAT paths can fall back to D1 because they
33 // already carry a principal that can be checked before data is served.
34 mode: RepositoryRouteResolutionMode;
35 db?: Db;
36 log?: Logger;
37};
38 
39export async function resolveRepositoryRoute(
40 env: Env,
41 namespaceSlug: string,
42 repoSlug: string,
43 options: RepositoryRouteResolutionOptions = { mode: "allow-d1-fallback" }
44): Promise<RepositoryRoute | null> {
45 const log = options.log ?? createLogger(env.LOG_LEVEL, { service: "RepoRoute" });
46 const db = options.db ?? createDb(env.DB);
47 const cached = await getRouteCacheRecord(env, namespaceSlug, repoSlug);
48 if (cached) {
49 const repository = await findRepositoryById(db, cached.repositoryId);
50 // Every cached field must still match the canonical D1 row, AND the
51 // canonical row must still be addressable at the requested URL.
52 // Otherwise we fall through to the slug-based D1 lookup and let the
53 // caller decide whether to refresh KV.
54 if (
55 repository &&
56 repository.namespaceId === cached.namespaceId &&
57 repository.doName === cached.doName &&
58 repository.slug === repoSlug
59 ) {
60 const namespace = await findNamespaceById(db, repository.namespaceId);
61 if (namespace && namespace.slug === namespaceSlug) {
62 log.debug("route:resolve-kv-hit", {
63 namespaceSlug,
64 repoSlug,
65 repositoryId: repository.id,
66 });
67 return {
68 routeNamespaceSlug: namespaceSlug,
69 routeRepoSlug: repoSlug,
70 namespaceId: repository.namespaceId,
71 repositoryId: repository.id,
72 doName: repository.doName,
73 visibility: repository.visibility,
74 source: "kv",
75 };
76 }
77 log.debug("route:resolve-kv-stale-namespace-mismatch", {
78 namespaceSlug,
79 repoSlug,
80 repositoryId: repository.id,
81 cachedNamespaceId: cached.namespaceId,
82 });
83 } else {
84 log.debug("route:resolve-kv-stale-repo-mismatch", {
85 namespaceSlug,
86 repoSlug,
87 cachedRepositoryId: cached.repositoryId,
88 cachedDoName: cached.doName,
89 });
90 }
91 }
92 if (options.mode === "route-cache-only") {
93 log.debug("route:resolve-cache-only-miss", { namespaceSlug, repoSlug });
94 return null;
95 }
96 
97 const namespace = await findNamespaceBySlug(db, namespaceSlug);
98 if (!namespace) {
99 log.debug("route:resolve-namespace-not-found", { namespaceSlug, repoSlug });
100 return null;
101 }
102 const repository = await findRepositoryByNamespaceAndSlug(db, namespace.id, repoSlug);
103 if (!repository) {
104 log.debug("route:resolve-repo-not-found", {
105 namespaceSlug,
106 repoSlug,
107 namespaceId: namespace.id,
108 });
109 return null;
110 }
111 log.debug("route:resolve-d1-hit", {
112 namespaceSlug,
113 repoSlug,
114 repositoryId: repository.id,
115 });
116 return {
117 routeNamespaceSlug: namespaceSlug,
118 routeRepoSlug: repoSlug,
119 namespaceId: namespace.id,
120 repositoryId: repository.id,
121 doName: repository.doName,
122 visibility: repository.visibility,
123 source: "d1",
124 };
125}