The Appview for the kipclip.com atproto bookmarking service
1/**
2 * Shared utilities for route handlers.
3 */
4
5import { getClearSessionCookie, getSessionFromRequest } from "./session.ts";
6import { tagIncludes } from "../shared/tag-utils.ts";
7
8/** AT Protocol collection names */
9export const BOOKMARK_COLLECTION = "community.lexicon.bookmarks.bookmark";
10export const TAG_COLLECTION = "com.kipclip.tag";
11export const ANNOTATION_COLLECTION = "com.kipclip.annotation";
12export const PREFERENCES_COLLECTION = "com.kipclip.preferences";
13
14/** OAuth scopes - granular permissions for only the collections kipclip uses */
15export const OAUTH_SCOPES = "atproto " +
16 "repo:community.lexicon.bookmarks.bookmark?action=create " +
17 "repo:community.lexicon.bookmarks.bookmark?action=read " +
18 "repo:community.lexicon.bookmarks.bookmark?action=update " +
19 "repo:community.lexicon.bookmarks.bookmark?action=delete " +
20 "repo:com.kipclip.tag?action=create " +
21 "repo:com.kipclip.tag?action=read " +
22 "repo:com.kipclip.tag?action=update " +
23 "repo:com.kipclip.tag?action=delete " +
24 "repo:com.kipclip.annotation?action=create " +
25 "repo:com.kipclip.annotation?action=read " +
26 "repo:com.kipclip.annotation?action=update " +
27 "repo:com.kipclip.annotation?action=delete " +
28 "repo:com.kipclip.preferences?action=create " +
29 "repo:com.kipclip.preferences?action=read " +
30 "repo:com.kipclip.preferences?action=update";
31
32/**
33 * Set the session cookie header on a response if provided.
34 */
35export function setSessionCookie(
36 response: Response,
37 setCookieHeader: string | undefined,
38): Response {
39 if (setCookieHeader) {
40 response.headers.set("Set-Cookie", setCookieHeader);
41 }
42 return response;
43}
44
45/**
46 * Create a 401 response for unauthenticated requests.
47 */
48export function createAuthErrorResponse(error?: {
49 message?: string;
50 type?: string;
51}): Response {
52 const response = Response.json(
53 {
54 error: "Authentication required",
55 message: error?.message || "Please log in again",
56 code: error?.type || "SESSION_EXPIRED",
57 },
58 { status: 401 },
59 );
60 response.headers.set("Set-Cookie", getClearSessionCookie());
61 return response;
62}
63
64/**
65 * Helper to get session and return auth error if not authenticated.
66 * Returns null if not authenticated (response already sent).
67 */
68export async function requireAuth(request: Request): Promise<
69 {
70 session: Awaited<ReturnType<typeof getSessionFromRequest>>["session"];
71 setCookieHeader: string | undefined;
72 } | null
73> {
74 const result = await getSessionFromRequest(request);
75 if (!result.session) {
76 return null;
77 }
78 return {
79 session: result.session,
80 setCookieHeader: result.setCookieHeader,
81 };
82}
83
84/**
85 * Create PDS tag records for tags that don't already exist.
86 * Comparison is case-insensitive — "swift" won't create a new record if "Swift" exists.
87 * Optionally pass pre-fetched tag records to avoid a redundant listRecords call.
88 */
89export async function createNewTagRecords(
90 oauthSession: any,
91 tags: string[],
92 existingRecords?: any[],
93): Promise<void> {
94 const records = existingRecords ??
95 await listAllRecords(oauthSession, TAG_COLLECTION);
96 const existingTagValues: string[] = records.map((rec: any) =>
97 rec.value?.value
98 ).filter(Boolean);
99 const newTags = tags.filter((t) => !tagIncludes(existingTagValues, t));
100 await Promise.all(newTags.map((tagValue) =>
101 oauthSession.makeRequest(
102 "POST",
103 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.createRecord`,
104 {
105 headers: { "Content-Type": "application/json" },
106 body: JSON.stringify({
107 repo: oauthSession.did,
108 collection: TAG_COLLECTION,
109 record: { value: tagValue, createdAt: new Date().toISOString() },
110 }),
111 },
112 ).catch((err: any) =>
113 console.error(`Failed to create tag "${tagValue}":`, err)
114 )
115 ));
116}
117
118/**
119 * Fetch a single page of records from an AT Protocol collection.
120 * Returns records and an optional cursor for the next page.
121 */
122export async function listOnePage(
123 oauthSession: any,
124 collection: string,
125 options?: { cursor?: string; reverse?: boolean; limit?: number },
126): Promise<{ records: any[]; cursor?: string }> {
127 const params = new URLSearchParams({
128 repo: oauthSession.did,
129 collection,
130 limit: String(options?.limit ?? 100),
131 });
132 if (options?.cursor) params.set("cursor", options.cursor);
133 if (options?.reverse) params.set("reverse", "true");
134
135 const res = await oauthSession.makeRequest(
136 "GET",
137 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`,
138 );
139 if (!res.ok) return { records: [] };
140
141 const data = await res.json();
142 return {
143 records: data.records || [],
144 cursor: data.cursor || undefined,
145 };
146}
147
148/**
149 * Paginate through all records in an AT Protocol collection.
150 * Returns every record, following cursors until exhausted.
151 */
152export async function listAllRecords(
153 oauthSession: any,
154 collection: string,
155): Promise<any[]> {
156 const all: any[] = [];
157 let cursor: string | undefined;
158
159 do {
160 const params = new URLSearchParams({
161 repo: oauthSession.did,
162 collection,
163 limit: "100",
164 });
165 if (cursor) params.set("cursor", cursor);
166
167 const res = await oauthSession.makeRequest(
168 "GET",
169 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`,
170 );
171 if (!res.ok) break;
172
173 const data = await res.json();
174 all.push(...(data.records || []));
175 cursor = data.cursor;
176 } while (cursor);
177
178 return all;
179}
180
181/** Re-export for convenience */
182export { getClearSessionCookie, getSessionFromRequest };