~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

web: Calls grain-serve by a client of its own, and logs in by its routes

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:web/client

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.
package.json+5−3
svelte.config.js+1−1
vite.config.ts+28−46
vitest.config.tsnew+15−0