0
2
web: Calls grain-serve by a client of its own, and logs in by its routes
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
test/web/auth.test.tsnew+144−0
| @@ -0,0 +1,144 @@ | ||
import { afterEach, describe, expect, test, vi } from "vitest"; | ||
import { | ||
AuthError, | ||
createAccount, | ||
login, | ||
logout, | ||
setViewerHandle, | ||
switchTo, | ||
whoami, | ||
} from "$lib/auth"; | ||
import { call, type Fetch } from "$lib/xrpc"; | ||
import type * as deleteRecord from "$lib/lexicons/social/grain/repo/deleteRecord.ts"; | ||
| ||
type Sent = { url: string; init: RequestInit }; | ||
| ||
/** A fetch answering each path as `answers` says, keeping what it was sent. */ | ||
function server(answers: Record<string, [number, unknown]>) { | ||
const sent: Sent[] = []; | ||
const fetch = (async (url: string, init: RequestInit = {}) => { | ||
sent.push({ url, init }); | ||
const [status, body] = answers[url] ?? [404, {}]; | ||
return new Response(JSON.stringify(body), { status }); | ||
}) as unknown as Fetch; | ||
return { fetch, sent }; | ||
} | ||
| ||
function inBrowser(path = "/alice.test/gallery/x", search = "?y=1") { | ||
const location = { pathname: path, search, href: `https://grain.social${path}${search}` }; | ||
vi.stubGlobal("window", { location }); | ||
return location; | ||
} | ||
| ||
const body = (s: Sent) => JSON.parse(s.init.body as string); | ||
| ||
afterEach(() => { | ||
vi.unstubAllGlobals(); | ||
setViewerHandle(null); | ||
}); | ||
| ||
describe("whoami", () => { | ||
test("names the account in use and the others", async () => { | ||
const { fetch, sent } = server({ | ||
"/oauth/session": [ | ||
200, | ||
{ | ||
account: { did: "did:plc:a", handle: "a.test" }, | ||
accounts: [{ did: "did:plc:b", handle: "b.test" }, { nope: true }], | ||
}, | ||
], | ||
}); | ||
expect(await whoami(fetch)).toEqual({ | ||
account: { did: "did:plc:a", handle: "a.test" }, | ||
accounts: [{ did: "did:plc:b", handle: "b.test" }], | ||
}); | ||
expect(sent[0].init.method).toBeUndefined(); | ||
}); | ||
| ||
test("names no one for no session, or a server that fails", async () => { | ||
const none = { account: null, accounts: [] }; | ||
expect( | ||
await whoami(server({ "/oauth/session": [200, { account: null, accounts: [] }] }).fetch), | ||
).toEqual(none); | ||
expect(await whoami(server({ "/oauth/session": [200, {}] }).fetch)).toEqual(none); | ||
expect(await whoami(server({ "/oauth/session": [501, {}] }).fetch)).toEqual(none); | ||
}); | ||
}); | ||
| ||
describe("login", () => { | ||
test("asks for the handle's login, coming back here, and goes to approve it", async () => { | ||
const location = inBrowser(); | ||
const { fetch, sent } = server({ | ||
"/oauth/login": [200, { url: "https://pds.test/authorize?x" }], | ||
}); | ||
await login("alice.test", undefined, fetch); | ||
expect(sent[0].init.method).toBe("POST"); | ||
expect(body(sent[0])).toEqual({ handle: "alice.test", back: "/alice.test/gallery/x?y=1" }); | ||
expect(location.href).toBe("https://pds.test/authorize?x"); | ||
}); | ||
| ||
test("says why a login is refused", async () => { | ||
const location = inBrowser(); | ||
const { fetch } = server({ | ||
"/oauth/login": [ | ||
400, | ||
{ error: "InvalidRequest", message: "No account answers to nobody.test." }, | ||
], | ||
}); | ||
const err = await login("nobody.test", "/", fetch).catch((e) => e); | ||
expect(err).toBeInstanceOf(AuthError); | ||
expect(err.status).toBe(400); | ||
expect(err.message).toBe("No account answers to nobody.test."); | ||
expect(location.href).toBe("https://grain.social/alice.test/gallery/x?y=1"); | ||
}); | ||
| ||
test("making an account asks for one", async () => { | ||
const location = inBrowser("/", ""); | ||
const { fetch, sent } = server({ "/oauth/login": [200, { url: "https://pds.test/signup" }] }); | ||
await createAccount(fetch); | ||
expect(body(sent[0])).toEqual({ create: true, back: "/" }); | ||
expect(location.href).toBe("https://pds.test/signup"); | ||
}); | ||
}); | ||
| ||
describe("switching and logging out", () => { | ||
test("switches by DID", async () => { | ||
const { fetch, sent } = server({ "/oauth/switch": [200, {}] }); | ||
await switchTo("did:plc:b", fetch); | ||
expect(body(sent[0])).toEqual({ did: "did:plc:b" }); | ||
}); | ||
| ||
test("a switch refused throws", async () => { | ||
const { fetch } = server({ "/oauth/switch": [400, { message: "not logged in to that" }] }); | ||
await expect(switchTo("did:plc:z", fetch)).rejects.toThrow("not logged in to that"); | ||
}); | ||
| ||
test("logging out asks once, and a failure is not thrown", async () => { | ||
const { fetch, sent } = server({ "/oauth/logout": [403, {}] }); | ||
await logout(fetch); | ||
expect(sent.map((s) => [s.url, s.init.method])).toEqual([["/oauth/logout", "POST"]]); | ||
}); | ||
}); | ||
| ||
describe("a session that ends", () => { | ||
test("a call answered 401 logs in again as the viewer", async () => { | ||
inBrowser(); | ||
const sent: Sent[] = []; | ||
const answers: Record<string, [number, unknown]> = { | ||
"/xrpc/social.grain.repo.deleteRecord": [401, { error: "AuthenticationRequired" }], | ||
"/oauth/login": [200, { url: "https://pds.test/authorize" }], | ||
}; | ||
vi.stubGlobal("fetch", async (url: string, init: RequestInit = {}) => { | ||
sent.push({ url, init }); | ||
const [status, out] = answers[url]; | ||
return new Response(JSON.stringify(out), { status }); | ||
}); | ||
setViewerHandle("alice.test"); | ||
await call<typeof deleteRecord.main>("social.grain.repo.deleteRecord", { | ||
collection: "social.grain.favorite", | ||
rkey: "a", | ||
}).catch(() => {}); | ||
await vi.waitFor(() => expect(sent.map((s) => s.url)).toContain("/oauth/login")); | ||
expect(body(sent[1]).handle).toBe("alice.test"); | ||
}); | ||
}); | ||
test/web/grainServe.test.tsnew+95−0
| @@ -0,0 +1,95 @@ | ||
import { describe, expect, test } from "vitest"; | ||
import { parseSetCookie, servedByGrainServe, toGrainServe } from "$lib/server/grainServe"; | ||
| ||
const page = new URL("https://grain.social/alice.test"); | ||
const browser = new Headers({ cookie: "grain_session=abc; other=1" }); | ||
| ||
describe("toGrainServe", () => { | ||
test("sends grain-serve's paths to it, with the browser's cookie", () => { | ||
const request = new Request("https://grain.social/xrpc/social.grain.actor.getProfile?actor=a"); | ||
const sent = toGrainServe(request, page, browser, "http://grain-serve:2600")!; | ||
expect(sent.url).toBe("http://grain-serve:2600/xrpc/social.grain.actor.getProfile?actor=a"); | ||
expect(sent.headers.get("cookie")).toBe("grain_session=abc; other=1"); | ||
expect(sent.method).toBe("GET"); | ||
}); | ||
| ||
test("defaults to where grain-serve listens", () => { | ||
const request = new Request("https://grain.social/oauth/session"); | ||
expect(toGrainServe(request, page, browser, undefined)!.url).toBe( | ||
"http://127.0.0.1:2600/oauth/session", | ||
); | ||
}); | ||
| ||
test("sends a request with no cookie, for a browser with none", () => { | ||
const request = new Request("https://grain.social/oauth/session", { | ||
headers: { cookie: "stale=1" }, | ||
}); | ||
const sent = toGrainServe(request, page, new Headers(), undefined)!; | ||
expect(sent.headers.get("cookie")).toBeNull(); | ||
}); | ||
| ||
test("keeps a procedure's body", async () => { | ||
const request = new Request("https://grain.social/xrpc/social.grain.notification.updateSeen", { | ||
method: "POST", | ||
headers: { "content-type": "application/json" }, | ||
body: '{"seenAt":"x"}', | ||
}); | ||
const sent = toGrainServe(request, page, browser, undefined)!; | ||
expect(sent.method).toBe("POST"); | ||
expect(await sent.text()).toBe('{"seenAt":"x"}'); | ||
}); | ||
| ||
test("leaves anything else where it was going", () => { | ||
for (const url of [ | ||
"https://grain.social/profile/alice.test", | ||
"https://cdn.example/xrpc/x", | ||
"https://grain.social/xrpcs", | ||
]) { | ||
expect(toGrainServe(new Request(url), page, browser, undefined)).toBeNull(); | ||
} | ||
}); | ||
| ||
test("names grain-serve's paths", () => { | ||
expect(servedByGrainServe("/xrpc/a.b.c")).toBe(true); | ||
expect(servedByGrainServe("/oauth/login")).toBe(true); | ||
expect(servedByGrainServe("/oauth-client-metadata.json")).toBe(true); | ||
expect(servedByGrainServe("/oauth")).toBe(false); | ||
expect(servedByGrainServe("/settings")).toBe(false); | ||
}); | ||
}); | ||
| ||
describe("parseSetCookie", () => { | ||
test("reads a cookie grain-serve sets", () => { | ||
const cookie = parseSetCookie( | ||
"grain_session=a.b.c; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure", | ||
)!; | ||
expect(cookie.name).toBe("grain_session"); | ||
expect(cookie.value).toBe("a.b.c"); | ||
expect(cookie.options).toMatchObject({ | ||
path: "/", | ||
maxAge: 2592000, | ||
httpOnly: true, | ||
sameSite: "lax", | ||
secure: true, | ||
}); | ||
expect(cookie.options.encode?.("a~b")).toBe("a~b"); | ||
}); | ||
| ||
test("reads one it clears", () => { | ||
const cookie = parseSetCookie("grain_login=; Path=/; Max-Age=0")!; | ||
expect(cookie).toMatchObject({ name: "grain_login", value: "", options: { maxAge: 0 } }); | ||
expect(cookie.options.httpOnly).toBe(false); | ||
}); | ||
| ||
test("keeps an = in the value", () => { | ||
expect(parseSetCookie("a=b=c; Path=/x")).toMatchObject({ | ||
value: "b=c", | ||
options: { path: "/x" }, | ||
}); | ||
}); | ||
| ||
test("takes no cookie from a header with no name", () => { | ||
expect(parseSetCookie("=x")).toBeNull(); | ||
expect(parseSetCookie("junk")).toBeNull(); | ||
}); | ||
}); | ||
test/web/preferences.test.tsnew+131−0
| @@ -0,0 +1,131 @@ | ||
import { afterEach, describe, expect, test, vi } from "vitest"; | ||
import { | ||
forgetPreferences, | ||
keepPreferences, | ||
putPreference, | ||
readPreferences, | ||
toSettings, | ||
withSetting, | ||
} from "$lib/preferenceSync"; | ||
import type { GrainActorDefsPreferences } from "$lib/preferenceSync"; | ||
| ||
const feeds = [{ id: "recent", label: "Recent", type: "feed", path: "/feeds/recent" }]; | ||
| ||
const prefs: GrainActorDefsPreferences = [ | ||
{ $type: "social.grain.actor.defs#pinnedFeedsPref", items: feeds }, | ||
{ $type: "social.grain.actor.defs#photoMetadataPref", includeExif: false }, | ||
{ $type: "social.grain.actor.defs#themePref", theme: "dark" }, | ||
{ | ||
$type: "social.grain.actor.defs#notificationsPref", | ||
follows: { push: false, inApp: true, from: "follows" }, | ||
}, | ||
]; | ||
| ||
afterEach(() => { | ||
vi.unstubAllGlobals(); | ||
forgetPreferences(); | ||
}); | ||
| ||
describe("toSettings", () => { | ||
test("reads each kind of preference", () => { | ||
expect(toSettings(prefs)).toEqual({ | ||
pinnedFeeds: feeds, | ||
includeExif: false, | ||
theme: "dark", | ||
notificationPrefs: { follows: { push: false, inApp: true, from: "follows" } }, | ||
}); | ||
}); | ||
| ||
test("reads none as none", () => { | ||
expect(toSettings([])).toEqual({}); | ||
expect(toSettings(null)).toEqual({}); | ||
}); | ||
}); | ||
| ||
describe("withSetting", () => { | ||
test("replaces the one item, in its place", () => { | ||
const next = withSetting(prefs, "theme", "light"); | ||
expect(next[2]).toEqual({ $type: "social.grain.actor.defs#themePref", theme: "light" }); | ||
expect(next.filter((p, i) => p !== prefs[i])).toHaveLength(1); | ||
}); | ||
| ||
test("keeps the other half of the photo metadata", () => { | ||
const next = withSetting(prefs, "includeLocation", false); | ||
expect(next[1]).toEqual({ | ||
$type: "social.grain.actor.defs#photoMetadataPref", | ||
includeExif: false, | ||
includeLocation: false, | ||
}); | ||
}); | ||
| ||
test("adds an item not set before", () => { | ||
const next = withSetting([], "includeExif", true); | ||
expect(next).toEqual([ | ||
{ $type: "social.grain.actor.defs#photoMetadataPref", includeExif: true }, | ||
]); | ||
}); | ||
}); | ||
| ||
describe("putPreference", () => { | ||
test("puts the whole set, two changes at once both landing", async () => { | ||
const put: GrainActorDefsPreferences[] = []; | ||
vi.stubGlobal("window", {}); | ||
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { | ||
expect(url).toBe("/xrpc/social.grain.actor.putPreferences"); | ||
put.push(JSON.parse(init.body as string).preferences); | ||
return new Response("{}", { status: 200 }); | ||
}); | ||
keepPreferences(prefs); | ||
await Promise.all([putPreference("theme", "light"), putPreference("includeLocation", true)]); | ||
expect(put).toHaveLength(2); | ||
expect(toSettings(put[1])).toMatchObject({ | ||
theme: "light", | ||
includeLocation: true, | ||
includeExif: false, | ||
}); | ||
}); | ||
| ||
test("reads the set first when it has none", async () => { | ||
const urls: string[] = []; | ||
vi.stubGlobal("window", {}); | ||
vi.stubGlobal("fetch", async (url: string) => { | ||
urls.push(url); | ||
const out = url.includes("getPreferences") ? { preferences: prefs } : {}; | ||
return new Response(JSON.stringify(out), { status: 200 }); | ||
}); | ||
await putPreference("theme", "system"); | ||
expect(urls).toEqual([ | ||
"/xrpc/social.grain.actor.getPreferences", | ||
"/xrpc/social.grain.actor.putPreferences", | ||
]); | ||
}); | ||
| ||
test("a change that fails throws, and the next goes on", async () => { | ||
let fail = true; | ||
vi.stubGlobal("window", {}); | ||
vi.stubGlobal("fetch", async () => { | ||
const status = fail ? 500 : 200; | ||
fail = false; | ||
return new Response("{}", { status }); | ||
}); | ||
keepPreferences(prefs); | ||
await expect(putPreference("theme", "light")).rejects.toThrow(/failed: 500/); | ||
await expect(putPreference("theme", "light")).resolves.toBeUndefined(); | ||
}); | ||
| ||
test("on the server, a set read is not kept for the next request", async () => { | ||
vi.stubGlobal("window", undefined); | ||
const read = async () => new Response(JSON.stringify({ preferences: prefs }), { status: 200 }); | ||
await readPreferences(read as typeof fetch); | ||
keepPreferences(prefs); | ||
const urls: string[] = []; | ||
vi.stubGlobal("window", {}); | ||
vi.stubGlobal("fetch", async (url: string) => { | ||
urls.push(url); | ||
const out = url.includes("getPreferences") ? { preferences: [] } : {}; | ||
return new Response(JSON.stringify(out), { status: 200 }); | ||
}); | ||
await putPreference("theme", "dark"); | ||
expect(urls[0]).toBe("/xrpc/social.grain.actor.getPreferences"); | ||
}); | ||
}); | ||
test/web/xrpc.test.tsnew+149−0
| @@ -0,0 +1,149 @@ | ||
import { afterEach, describe, expect, test, vi } from "vitest"; | ||
import { call, onUnauthorized, query, searched, upload, XrpcError, type Fetch } from "$lib/xrpc"; | ||
import type * as describeFeeds from "$lib/lexicons/social/grain/feed/describeFeeds.ts"; | ||
import type * as getProfile from "$lib/lexicons/social/grain/actor/getProfile.ts"; | ||
import type * as putPreferences from "$lib/lexicons/social/grain/actor/putPreferences.ts"; | ||
import type * as deleteRecord from "$lib/lexicons/social/grain/repo/deleteRecord.ts"; | ||
import type * as updateSeen from "$lib/lexicons/social/grain/notification/updateSeen.ts"; | ||
import type * as uploadBlob from "$lib/lexicons/social/grain/repo/uploadBlob.ts"; | ||
| ||
/** A fetch that answers `body` with `status`, keeping what it was sent. */ | ||
function answering(status: number, body: unknown) { | ||
const sent: { url: string; init: RequestInit }[] = []; | ||
const fetch = (async (url: string, init: RequestInit = {}) => { | ||
sent.push({ url, init }); | ||
return new Response(body === undefined ? "" : JSON.stringify(body), { status }); | ||
}) as unknown as Fetch; | ||
return { fetch, sent }; | ||
} | ||
| ||
afterEach(() => { | ||
vi.unstubAllGlobals(); | ||
onUnauthorized(null); | ||
}); | ||
| ||
describe("searched", () => { | ||
test("repeats arrays and leaves out nulls", () => { | ||
expect(searched({ a: "x y", b: [1, 2], c: undefined, d: null, e: false })).toBe( | ||
"?a=x+y&b=1&b=2&e=false", | ||
); | ||
expect(searched({})).toBe(""); | ||
expect(searched(undefined)).toBe(""); | ||
}); | ||
}); | ||
| ||
describe("query", () => { | ||
test("a query's parameters go in its URL", async () => { | ||
const { fetch, sent } = answering(200, { did: "did:plc:a", handle: "a.test" }); | ||
const out = await query<typeof getProfile.main>( | ||
"social.grain.actor.getProfile", | ||
{ actor: "a.test" }, | ||
fetch, | ||
); | ||
expect(out.handle).toBe("a.test"); | ||
expect(sent[0].url).toBe("/xrpc/social.grain.actor.getProfile?actor=a.test"); | ||
expect(sent[0].init.method).toBeUndefined(); | ||
}); | ||
| ||
test("a refusal throws with its status and the server's error", async () => { | ||
const { fetch } = answering(400, { error: "InvalidRequest", message: "No account" }); | ||
const err = await query<typeof getProfile.main>( | ||
"social.grain.actor.getProfile", | ||
{ actor: "x" }, | ||
fetch, | ||
).catch((e) => e); | ||
expect(err).toBeInstanceOf(XrpcError); | ||
expect(err.message).toBe("XRPC social.grain.actor.getProfile failed: 400"); | ||
expect(err.status).toBe(400); | ||
expect(err.error).toBe("InvalidRequest"); | ||
expect(err.reason).toBe("No account"); | ||
}); | ||
| ||
test("a call that never arrives throws with status 0", async () => { | ||
const fetch = (async () => { | ||
throw new TypeError("offline"); | ||
}) as unknown as Fetch; | ||
const err = await query<typeof describeFeeds.main>( | ||
"social.grain.feed.describeFeeds", | ||
{}, | ||
fetch, | ||
).catch((e) => e); | ||
expect(err).toBeInstanceOf(XrpcError); | ||
expect(err.status).toBe(0); | ||
}); | ||
| ||
test("on the server, a call takes the load's fetch", async () => { | ||
await expect( | ||
query<typeof describeFeeds.main>("social.grain.feed.describeFeeds", {}), | ||
).rejects.toThrow(/pass the load's fetch/); | ||
}); | ||
}); | ||
| ||
describe("call and upload", () => { | ||
test("a procedure's input goes as JSON", async () => { | ||
const { fetch, sent } = answering(200, {}); | ||
await call<typeof updateSeen.main>( | ||
"social.grain.notification.updateSeen", | ||
{ seenAt: "2026-10-31T00:00:00.000Z" }, | ||
fetch, | ||
); | ||
expect(sent[0].url).toBe("/xrpc/social.grain.notification.updateSeen"); | ||
expect(sent[0].init.method).toBe("POST"); | ||
expect(new Headers(sent[0].init.headers).get("content-type")).toBe("application/json"); | ||
expect(JSON.parse(sent[0].init.body as string)).toEqual({ | ||
seenAt: "2026-10-31T00:00:00.000Z", | ||
}); | ||
}); | ||
| ||
test("bytes go as atproto's JSON writes them", async () => { | ||
const { fetch, sent } = answering(200, {}); | ||
const input = { preferences: [], raw: new Uint8Array([1, 2, 3]) } as never; | ||
await call<typeof putPreferences.main>("social.grain.actor.putPreferences", input, fetch); | ||
expect(JSON.parse(sent[0].init.body as string).raw).toEqual({ $bytes: "AQID" }); | ||
}); | ||
| ||
test("an empty answer reads as an empty object", async () => { | ||
const { fetch } = answering(200, undefined); | ||
const out = await call<typeof putPreferences.main>( | ||
"social.grain.actor.putPreferences", | ||
{ preferences: [] }, | ||
fetch, | ||
); | ||
expect(out).toEqual({}); | ||
}); | ||
| ||
test("a blob goes as its bytes, typed as it is", async () => { | ||
const { fetch, sent } = answering(200, { blob: { $type: "blob" } }); | ||
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/jpeg" }); | ||
await upload<typeof uploadBlob.main>("social.grain.repo.uploadBlob", blob, fetch); | ||
expect(sent[0].init.body).toBe(blob); | ||
expect(new Headers(sent[0].init.headers).get("content-type")).toBe("image/jpeg"); | ||
}); | ||
}); | ||
| ||
describe("a 401", () => { | ||
test("in the browser, calls the handler", async () => { | ||
vi.stubGlobal("window", {}); | ||
const seen: string[] = []; | ||
onUnauthorized((nsid) => seen.push(nsid)); | ||
const { fetch } = answering(401, { error: "AuthenticationRequired" }); | ||
await expect( | ||
call<typeof deleteRecord.main>( | ||
"social.grain.repo.deleteRecord", | ||
{ collection: "social.grain.favorite", rkey: "a" }, | ||
fetch, | ||
), | ||
).rejects.toBeInstanceOf(XrpcError); | ||
expect(seen).toEqual(["social.grain.repo.deleteRecord"]); | ||
}); | ||
| ||
test("on the server, calls no handler", async () => { | ||
const seen: string[] = []; | ||
onUnauthorized((nsid) => seen.push(nsid)); | ||
const { fetch } = answering(401, {}); | ||
await query<typeof describeFeeds.main>("social.grain.feed.describeFeeds", {}, fetch).catch( | ||
() => {}, | ||
); | ||
expect(seen).toEqual([]); | ||
}); | ||
}); | ||
web/src/lib/components/organisms/LoginModal.svelte+12−3
| @@ -1,6 +1,7 @@ | ||
<script lang="ts"> | ||
import { login, createAccount } from '$lib/auth' | ||
import { callXrpc } from '$hatk/client' | ||
import { query } from '$lib/xrpc' | ||
import type * as searchActorsTypeahead from '$lib/lexicons/social/grain/actor/searchActorsTypeahead.ts' | ||
import Modal from '../atoms/Modal.svelte' | ||
import Button from '../atoms/Button.svelte' | ||
| ||
| @@ -35,6 +36,14 @@ | ||
} | ||
} | ||
| ||
async function signUp() { | ||
try { | ||
await createAccount() | ||
} catch (err: any) { | ||
alert('Sign up failed: ' + err.message) | ||
} | ||
} | ||
| ||
function onInput() { | ||
const q = handle.trim() | ||
if (debounceTimer) clearTimeout(debounceTimer) | ||
| @@ -47,7 +56,7 @@ | ||
| ||
async function search(q: string) { | ||
try { | ||
const result = await callXrpc('social.grain.unspecced.searchActorsTypeahead', { q, limit: 5 }) | ||
const result = await query<typeof searchActorsTypeahead.main>('social.grain.actor.searchActorsTypeahead', { q, limit: 5 }) | ||
suggestions = result.actors || [] | ||
activeIndex = -1 | ||
} catch { | ||
| @@ -156,7 +165,7 @@ | ||
</div> | ||
<div class="create-account"> | ||
<span class="create-account-text">Don't have an account?</span> | ||
<button class="create-account-link" onclick={createAccount}>Create one</button> | ||
<button class="create-account-link" onclick={signUp}>Create one</button> | ||
</div> | ||
</Modal> | ||
| ||
web/src/lib/server/grainServe.tsnew+89−0
| @@ -0,0 +1,89 @@ | ||
// The web's server side reaching grain-serve: where it is, which of this | ||
// site's paths are its, and the cookies it answers with. | ||
| ||
import type { Cookies } from "@sveltejs/kit"; | ||
| ||
/** Where grain-serve listens unless `GRAIN_SERVE_URL` says otherwise. */ | ||
export const DEFAULT_URL = "http://127.0.0.1:2600"; | ||
| ||
/** Whether `pathname`, on this site, is one grain-serve answers. */ | ||
export function servedByGrainServe(pathname: string): boolean { | ||
return ( | ||
pathname.startsWith("/xrpc/") || | ||
pathname.startsWith("/oauth/") || | ||
pathname === "/oauth-client-metadata.json" | ||
); | ||
} | ||
| ||
/** | ||
* `request`, made by a load on the page at `page`, sent to grain-serve at | ||
* `base` with the cookie of the browser's request (`from`); null for a | ||
* request of anything else, which goes where it was going. | ||
*/ | ||
export function toGrainServe( | ||
request: Request, | ||
page: URL, | ||
from: Headers, | ||
base: string | undefined, | ||
): Request | null { | ||
const url = new URL(request.url); | ||
if (url.origin !== page.origin || !servedByGrainServe(url.pathname)) return null; | ||
const target = new URL(url.pathname + url.search, base || DEFAULT_URL); | ||
const headers = new Headers(request.headers); | ||
const cookie = from.get("cookie"); | ||
if (cookie) headers.set("cookie", cookie); | ||
else headers.delete("cookie"); | ||
return new Request(target, { | ||
method: request.method, | ||
headers, | ||
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, | ||
// A body that streams goes once; `half` says so. | ||
...(request.body ? { duplex: "half" } : {}), | ||
} as RequestInit); | ||
} | ||
| ||
type CookieOptions = Parameters<Cookies["set"]>[2]; | ||
| ||
/** A `Set-Cookie` header's cookie, and what it says of it. */ | ||
export function parseSetCookie( | ||
header: string, | ||
): { name: string; value: string; options: CookieOptions } | null { | ||
const [pair, ...attrs] = header.split(";").map((part) => part.trim()); | ||
const eq = pair.indexOf("="); | ||
if (eq <= 0) return null; | ||
const options: CookieOptions = { | ||
path: "/", | ||
httpOnly: false, | ||
secure: false, | ||
// grain-serve's cookies are written as they are to be sent. | ||
encode: (v: string) => v, | ||
}; | ||
for (const attr of attrs) { | ||
const [k, ...rest] = attr.split("="); | ||
const v = rest.join("="); | ||
switch (k.toLowerCase()) { | ||
case "path": | ||
options.path = v || "/"; | ||
break; | ||
case "max-age": | ||
options.maxAge = Number(v); | ||
break; | ||
case "expires": | ||
options.expires = new Date(v); | ||
break; | ||
case "httponly": | ||
options.httpOnly = true; | ||
break; | ||
case "secure": | ||
options.secure = true; | ||
break; | ||
case "samesite": | ||
options.sameSite = v.toLowerCase() as "lax" | "strict" | "none"; | ||
break; | ||
case "domain": | ||
options.domain = v; | ||
break; | ||
} | ||
} | ||
return { name: pair.slice(0, eq), value: pair.slice(eq + 1), options }; | ||
} | ||
web/src/lib/xrpc/index.tsnew+138−0
| @@ -0,0 +1,138 @@ | ||
// grain's XRPC client, as knotless's web has its own: each call names its | ||
// method's schema from src/lib/lexicons, which `npm run lex` makes with | ||
// @atproto/lex from lexicons/, and is typed by it. | ||
// | ||
// query<typeof getProfile.main>("social.grain.actor.getProfile", { actor }, fetch) | ||
// call<typeof updateSeen.main>("social.grain.notification.updateSeen", { seenAt }) | ||
// upload<typeof uploadBlob.main>("social.grain.repo.uploadBlob", blob) | ||
// | ||
// Calls go to `/xrpc/<nsid>` on the page's own origin. In the browser that is | ||
// grain-serve, behind the same host; on the server the fetch passed in is | ||
// SvelteKit's, which hooks.server.ts sends on to grain-serve with the | ||
// browser's cookie. | ||
| ||
import type { | ||
InferInput, | ||
InferMethodInputBody, | ||
InferMethodOutputBody, | ||
LexErrorData, | ||
Procedure, | ||
Query, | ||
} from "@atproto/lex"; | ||
| ||
export type Fetch = typeof globalThis.fetch; | ||
| ||
/** What the query `Q` answers. */ | ||
export type Answer<Q extends Query> = InferMethodOutputBody<Q, never>; | ||
| ||
/** What the query `Q` is asked with, its defaults left to the server. */ | ||
export type Asked<Q extends Query> = InferInput<Q["parameters"]>; | ||
| ||
/** What the procedure `P` takes. */ | ||
export type Input<P extends Procedure> = InferMethodInputBody<P, never>; | ||
| ||
/** What the procedure `P` answers. */ | ||
export type Output<P extends Procedure> = InferMethodOutputBody<P, never>; | ||
| ||
/** A call grain-serve refused, or that never reached it. */ | ||
export class XrpcError extends Error { | ||
/** The HTTP status, 0 for one that never reached the server. */ | ||
readonly status: number; | ||
/** The error's name, as the server gave it (`InvalidRequest`, ...). */ | ||
readonly error?: string; | ||
/** Why, as the server said. */ | ||
readonly reason?: string; | ||
| ||
constructor(nsid: string, status: number, body?: Partial<LexErrorData> | null) { | ||
super(`XRPC ${nsid} failed: ${status}`); | ||
this.name = "XrpcError"; | ||
this.status = status; | ||
if (typeof body?.error === "string") this.error = body.error; | ||
if (typeof body?.message === "string") this.reason = body.message; | ||
} | ||
} | ||
| ||
/** What happens to a call the browser makes once the session has ended. */ | ||
let unauthorized: ((nsid: string) => void) | null = null; | ||
| ||
/** Sets what a browser's call answered 401 does, besides failing. */ | ||
export function onUnauthorized(handler: ((nsid: string) => void) | null): void { | ||
unauthorized = handler; | ||
} | ||
| ||
/** A query's parameters as its address has them: an array's values each under its name. */ | ||
export function searched(params: object | undefined): string { | ||
const query = new URLSearchParams(); | ||
for (const [k, v] of Object.entries(params ?? {})) { | ||
for (const one of v == null ? [] : [v].flat()) query.append(k, String(one)); | ||
} | ||
return query.size ? `?${query}` : ""; | ||
} | ||
| ||
/** Bytes as atproto's JSON writes them, `{"$bytes": base64}`; anything else as it is. */ | ||
function lexJson(_: string, value: unknown): unknown { | ||
if (!(value instanceof Uint8Array)) return value; | ||
let text = ""; | ||
for (const b of value) text += String.fromCharCode(b); | ||
return { $bytes: btoa(text) }; | ||
} | ||
| ||
async function send<T>(nsid: string, path: string, init: RequestInit, fetch?: Fetch): Promise<T> { | ||
const browser = typeof window !== "undefined"; | ||
if (!fetch && !browser) { | ||
throw new Error(`xrpc ${nsid}: on the server, pass the load's fetch`); | ||
} | ||
let res: Response; | ||
try { | ||
res = await (fetch ?? globalThis.fetch)(path, init); | ||
} catch { | ||
throw new XrpcError(nsid, 0); | ||
} | ||
if (!res.ok) { | ||
const failure: Partial<LexErrorData> | null = await res.json().catch(() => null); | ||
if (browser && res.status === 401) unauthorized?.(nsid); | ||
throw new XrpcError(nsid, res.status, failure); | ||
} | ||
const text = await res.text(); | ||
return (text ? JSON.parse(text) : {}) as T; | ||
} | ||
| ||
/** | ||
* What the XRPC query `Q`, by its NSID `nsid`, answers `params` with. On the | ||
* server `fetch` is the load's or the request's own, which carries the cookie. | ||
*/ | ||
export function query<Q extends Query>( | ||
nsid: Q["nsid"], | ||
params: Asked<Q>, | ||
fetch?: Fetch, | ||
): Promise<Answer<Q>> { | ||
return send(nsid, `/xrpc/${nsid}${searched(params)}`, {}, fetch); | ||
} | ||
| ||
/** What the XRPC procedure `P`, by its NSID `nsid`, answers `input` with. */ | ||
export function call<P extends Procedure>( | ||
nsid: P["nsid"], | ||
input: Input<P>, | ||
fetch?: Fetch, | ||
): Promise<Output<P>> { | ||
const init = { | ||
method: "POST", | ||
headers: { "content-type": "application/json" }, | ||
body: JSON.stringify(input ?? {}, lexJson), | ||
}; | ||
return send(nsid, `/xrpc/${nsid}`, init, fetch); | ||
} | ||
| ||
/** What the XRPC procedure `P` answers `body` with, sent as its own bytes and type. */ | ||
export function upload<P extends Procedure>( | ||
nsid: P["nsid"], | ||
body: Blob, | ||
fetch?: Fetch, | ||
): Promise<Output<P>> { | ||
const init = { | ||
method: "POST", | ||
headers: { "content-type": body.type || "application/octet-stream" }, | ||
body, | ||
}; | ||
return send(nsid, `/xrpc/${nsid}`, init, fetch); | ||
} | ||
web/src/lib/accounts.ts+25−44
| @@ -1,15 +1,13 @@ | ||
// Known-account list backing the settings account switcher. | ||
// The accounts the settings switcher lists. | ||
// | ||
// The server holds an OAuth session per DID, so switching accounts is a | ||
// server-side operation: `POST /auth/switch` re-issues the session cookie for | ||
// an account this browser has already signed in as. hatk tracks that set in an | ||
// encrypted cookie, which `GET /auth/accounts` reads back — the source of | ||
// truth for what can be switched to. | ||
// grain-serve holds an OAuth session for each account, and the browser a | ||
// cookie for each it is logged in to, so switching is grain-serve's to do | ||
// (`switchTo`), and which accounts it can switch to is what `whoami` says. | ||
// | ||
// localStorage carries only display detail (avatar, display name) that the | ||
// server list doesn't have, so rows render fully populated on first paint. | ||
// On hatk builds without the switch endpoints, everything degrades to the | ||
// original behaviour: re-run the OAuth login for that handle. | ||
// localStorage carries only what that list does not, an account's avatar and | ||
// display name, so rows show them on first paint. | ||
| ||
import { login, switchTo, whoami, type Account } from "./auth"; | ||
| ||
const STORAGE_KEY = "grain:accounts"; | ||
const MAX_ACCOUNTS = 10; | ||
| @@ -74,65 +72,48 @@ export function forgetAccount(did: string): void { | ||
writeAccounts(listAccounts().filter((a) => a.did !== did)); | ||
} | ||
| ||
/** An account the server will switch to without a trip to the PDS. */ | ||
export interface ServerAccount { | ||
did: string; | ||
handle: string; | ||
available: boolean; | ||
} | ||
| ||
/** | ||
* Accounts the server recognises for this browser. Returns null when the | ||
* endpoint isn't there (older hatk), which is the signal to fall back to | ||
* re-login switching rather than to show an empty list. | ||
* The accounts grain-serve will switch this browser to without a login, the | ||
* one in use first, and which that is. Null when it cannot be asked, which | ||
* leaves the switcher to the accounts this browser remembers. | ||
*/ | ||
export async function fetchServerAccounts(): Promise<{ | ||
accounts: ServerAccount[]; | ||
accounts: Account[]; | ||
active: string | null; | ||
} | null> { | ||
try { | ||
const res = await fetch("/auth/accounts", { headers: { accept: "application/json" } }); | ||
if (!res.ok) return null; | ||
const body = await res.json(); | ||
if (!Array.isArray(body?.accounts)) return null; | ||
return { accounts: body.accounts, active: body.active ?? null }; | ||
const session = await whoami(globalThis.fetch); | ||
const accounts = session.account ? [session.account, ...session.accounts] : []; | ||
return { accounts, active: session.account?.did ?? null }; | ||
} catch { | ||
return null; | ||
} | ||
} | ||
| ||
/** | ||
* Re-run the OAuth login for an account. hatk clears the session cookie on the | ||
* way out to the PDS, so backing out of it lands the user signed out — the | ||
* account stays in the list, one click from signing back in. | ||
* Logs in to an account again, at its PDS. Backing out of the PDS's page comes | ||
* back logged in as before, and the account stays in the list. | ||
*/ | ||
export function loginAsAccount(account: StoredAccount): void { | ||
const hint = account.handle || account.did; | ||
window.location.href = `/oauth/login?handle=${encodeURIComponent(hint)}`; | ||
login(account.handle || account.did).catch(() => {}); | ||
} | ||
| ||
/** | ||
* Switch the active account. Asks the server to re-issue the session cookie | ||
* from the grant it already holds; only if that isn't possible — no endpoint, | ||
* or the stored grant is gone — does the user go back through the PDS. | ||
* Switches to an account. grain-serve switches to one this browser is logged | ||
* in to already; any other logs in again at its PDS. | ||
*/ | ||
export async function switchAccount(account: StoredAccount): Promise<void> { | ||
let switched = false; | ||
try { | ||
const res = await fetch("/auth/switch", { | ||
method: "POST", | ||
headers: { "content-type": "application/json" }, | ||
body: JSON.stringify({ did: account.did }), | ||
}); | ||
switched = res.ok; | ||
await switchTo(account.did); | ||
switched = true; | ||
} catch { | ||
// Network error — fall through to the login redirect. | ||
// Not logged in to it here, or grain-serve unreachable: log in instead. | ||
} | ||
| ||
if (switched) { | ||
// Full reload rather than a client navigation: the session cookie changed, | ||
// so every cached SSR payload and query on the page belongs to the account | ||
// we just left. | ||
// A full load rather than a client navigation: the cookie changed, so | ||
// every page and query loaded so far is the account left's. | ||
window.location.href = "/"; | ||
return; | ||
} | ||
web/src/lib/auth.ts+111−22
| @@ -1,31 +1,120 @@ | ||
export async function login(handle: string): Promise<void> { | ||
const res = await fetch(`/oauth/login?handle=${encodeURIComponent(handle)}`, { | ||
redirect: "manual", | ||
}); | ||
if (res.type === "opaqueredirect") { | ||
window.location.href = `/oauth/login?handle=${encodeURIComponent(handle)}`; | ||
return; | ||
// Logging in, out and between accounts, by grain-serve's routes: | ||
// | ||
// GET /oauth/session who the browser is logged in as, and the others | ||
// it can switch to | ||
// POST /oauth/login {handle, back} or {create: true}: the PDS page to | ||
// approve the login at, where the browser goes next | ||
// POST /oauth/switch {did}: another account the browser is logged in to | ||
// POST /oauth/logout the account in use; the browser goes on as the | ||
// other it used last, if any | ||
// | ||
// grain-serve keeps the session and sets the `grain_session` cookie, which | ||
// the browser sends with every call; nothing here reads the cookie. | ||
| ||
import { onUnauthorized, type Fetch } from "$lib/xrpc"; | ||
| ||
/** An account the browser is logged in to. */ | ||
export interface Account { | ||
did: string; | ||
handle: string; | ||
} | ||
| ||
/** Who the browser is logged in as, and the other accounts it can switch to. */ | ||
export interface Session { | ||
account: Account | null; | ||
accounts: Account[]; | ||
} | ||
| ||
/** A login, logout or switch grain-serve refused. */ | ||
export class AuthError extends Error { | ||
readonly status: number; | ||
constructor(status: number, message: string) { | ||
super(message); | ||
this.name = "AuthError"; | ||
this.status = status; | ||
} | ||
if (res.ok) return; | ||
const body = await res.json().catch(() => ({ error: "Login failed" })); | ||
throw new Error(body.error || "Login failed"); | ||
} | ||
| ||
export async function createAccount(): Promise<void> { | ||
const pds = import.meta.env.DEV ? "localhost:2583" : "selfhosted.social"; | ||
window.location.href = `/oauth/login?prompt=create&pds=${encodeURIComponent(pds)}`; | ||
/** Who `fetch`'s cookie names: no one for a cookie that no longer holds. */ | ||
export async function whoami(fetch: Fetch): Promise<Session> { | ||
const res = await fetch("/oauth/session", { headers: { accept: "application/json" } }); | ||
if (!res.ok) return { account: null, accounts: [] }; | ||
const body = await res.json().catch(() => ({})); | ||
const account = isAccount(body?.account) ? body.account : null; | ||
const accounts = Array.isArray(body?.accounts) ? body.accounts.filter(isAccount) : []; | ||
return { account, accounts }; | ||
} | ||
| ||
function isAccount(value: unknown): value is Account { | ||
return !!value && typeof (value as Account).did === "string"; | ||
} | ||
| ||
async function post(path: string, body: unknown, fetch: Fetch): Promise<Record<string, unknown>> { | ||
let res: Response; | ||
try { | ||
res = await fetch(path, { | ||
method: "POST", | ||
headers: { "content-type": "application/json" }, | ||
body: JSON.stringify(body), | ||
}); | ||
} catch { | ||
throw new AuthError(0, "Could not reach grain."); | ||
} | ||
const answer = await res.json().catch(() => ({})); | ||
if (!res.ok) { | ||
const message = typeof answer?.message === "string" ? answer.message : "Login failed"; | ||
throw new AuthError(res.status, message); | ||
} | ||
return answer ?? {}; | ||
} | ||
| ||
export async function logout(): Promise<void> { | ||
(globalThis as any).__hatk_viewer = null; | ||
await fetch("/auth/logout", { method: "POST" }).catch(() => {}); | ||
/** The page a login comes back to: the one it started on. */ | ||
function here(): string { | ||
if (typeof window === "undefined") return "/"; | ||
return window.location.pathname + window.location.search; | ||
} | ||
| ||
export function viewerDid(): string | null { | ||
if (typeof window === "undefined") return null; | ||
const viewer = (globalThis as any).__hatk_viewer; | ||
return viewer?.did ?? null; | ||
/** Starts logging in as `handle`, and goes to its PDS to approve it. */ | ||
export async function login( | ||
handle: string, | ||
back = here(), | ||
fetch: Fetch = globalThis.fetch, | ||
): Promise<void> { | ||
const { url } = await post("/oauth/login", { handle, back }, fetch); | ||
if (typeof url !== "string") throw new AuthError(502, "Login failed"); | ||
window.location.href = url; | ||
} | ||
| ||
/** Starts making an account, on the PDS grain sends new accounts to. */ | ||
export async function createAccount(fetch: Fetch = globalThis.fetch): Promise<void> { | ||
const { url } = await post("/oauth/login", { create: true, back: here() }, fetch); | ||
if (typeof url !== "string") throw new AuthError(502, "Sign up failed"); | ||
window.location.href = url; | ||
} | ||
| ||
/** Switches to `did`, another account this browser is logged in to. */ | ||
export async function switchTo(did: string, fetch: Fetch = globalThis.fetch): Promise<void> { | ||
await post("/oauth/switch", { did }, fetch); | ||
} | ||
| ||
/** Logs the account in use out. A failure leaves it logged in. */ | ||
export async function logout(fetch: Fetch = globalThis.fetch): Promise<void> { | ||
await post("/oauth/logout", {}, fetch).catch(() => {}); | ||
} | ||
| ||
/** | ||
* A call the server answered 401 had the session end under it: the browser | ||
* logs in again as the same account, coming back to the same page. | ||
*/ | ||
export function relogin(handle: string | null | undefined): void { | ||
if (handle) login(handle).catch(() => {}); | ||
} | ||
| ||
let viewerHandle: string | null = null; | ||
| ||
/** Sets who a call answered 401 logs in again as. */ | ||
export function setViewerHandle(handle: string | null): void { | ||
viewerHandle = handle; | ||
} | ||
| ||
// Expose viewer for getViewer() bridge | ||
(globalThis as any).__hatk_auth = { viewerDid }; | ||
onUnauthorized(() => relogin(viewerHandle)); | ||
web/src/lib/preferences.ts+13−10
| @@ -1,5 +1,8 @@ | ||
import { writable, get } from "svelte/store"; | ||
import { callXrpc } from "$hatk/client"; | ||
import type { DatetimeString } from "@atproto/lex"; | ||
import { call } from "$lib/xrpc"; | ||
import type * as updateSeen from "$lib/lexicons/social/grain/notification/updateSeen.ts"; | ||
import { forgetPreferences, putPreference, type Settings } from "./preferenceSync"; | ||
import { Images, Users, Camera, MapPin, Hash, Pin, Sparkles, UsersRound } from "lucide-svelte"; | ||
import { applyStoredTheme } from "./theme"; | ||
| ||
| @@ -62,7 +65,7 @@ function isValidFeed(f: unknown): f is PinnedFeed { | ||
); | ||
} | ||
| ||
export function loadPreferences(prefs: Record<string, unknown> | null): void { | ||
export function loadPreferences(prefs: Settings | null): void { | ||
if (!prefs) return; | ||
if (Array.isArray(prefs.pinnedFeeds)) { | ||
const valid = prefs.pinnedFeeds | ||
| @@ -84,12 +87,12 @@ export function loadPreferences(prefs: Record<string, unknown> | null): void { | ||
| ||
export async function setIncludeExif(value: boolean): Promise<void> { | ||
includeExif.set(value); | ||
await callXrpc("dev.hatk.putPreference", { key: "includeExif", value }); | ||
await putPreference("includeExif", value); | ||
} | ||
| ||
export async function setIncludeLocation(value: boolean): Promise<void> { | ||
includeLocation.set(value); | ||
await callXrpc("dev.hatk.putPreference", { key: "includeLocation", value }); | ||
await putPreference("includeLocation", value); | ||
} | ||
| ||
export async function pinFeed(feed: PinnedFeed): Promise<boolean> { | ||
| @@ -98,7 +101,7 @@ export async function pinFeed(feed: PinnedFeed): Promise<boolean> { | ||
const updated = [...previous, feed]; | ||
pinnedFeeds.set(updated); | ||
try { | ||
await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: updated }); | ||
await putPreference("pinnedFeeds", updated); | ||
} catch { | ||
pinnedFeeds.set(previous); | ||
} | ||
| @@ -112,7 +115,7 @@ export async function unpinFeed(id: string): Promise<boolean> { | ||
if (updated.length === 0) return false; | ||
pinnedFeeds.set(updated); | ||
try { | ||
await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: updated }); | ||
await putPreference("pinnedFeeds", updated); | ||
} catch { | ||
pinnedFeeds.set(previous); | ||
} | ||
| @@ -123,21 +126,21 @@ export async function reorderFeeds(feeds: PinnedFeed[]): Promise<void> { | ||
const previous = get(pinnedFeeds); | ||
pinnedFeeds.set(feeds); | ||
try { | ||
await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: feeds }); | ||
await putPreference("pinnedFeeds", feeds); | ||
} catch { | ||
pinnedFeeds.set(previous); | ||
} | ||
} | ||
| ||
export function resetPreferences(): void { | ||
forgetPreferences(); | ||
pinnedFeeds.set(DEFAULT_PINNED); | ||
includeExif.set(true); | ||
includeLocation.set(true); | ||
} | ||
| ||
export async function markNotificationsSeen(): Promise<void> { | ||
await callXrpc("dev.hatk.putPreference", { | ||
key: "lastSeenNotifications", | ||
value: new Date().toISOString(), | ||
await call<typeof updateSeen.main>("social.grain.notification.updateSeen", { | ||
seenAt: new Date().toISOString() as DatetimeString, | ||
}); | ||
} | ||
web/src/lib/preferenceSync.tsnew+149−0
web/src/lib/theme.ts+2−2
web/src/routes/oauth/callback/+page.sveltedeleted+0−11
web/src/routes/settings/notifications/+page.svelte+4−4
web/src/routes/+layout.server.ts+18−23
web/src/routes/+layout.svelte+9−31
web/src/hooks.server.tsnew+18−0
.gitignore+2−0
package-lock.json+1228−3
1231 lines changed: not loaded until asked for.