Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
- Content pages have no revalidate timer. `src/libs/orpc/client.rsc.ts` tags each server read with `WwwCacheTag` or `wwwFeedCacheTag`, and `workflow` invalidates those tags through the Vercel API, one five-minute window per tag; nothing calls into www. A write that changes what www renders must end in `scheduleFeedRevalidationStep` (in a workflow) or `workflow.startSiteRevalidation(tag)`.
- The blog navigation and related posts load in the browser, so no cached page depends on a post other than its own. Home and projects keep a timer because Spotify and GitHub send no change event.
- Pages set canonical, hreflang and `og:url` through `localizedMetadata` in `src/libs/i18n/alternates.ts`; the middleware sends no alternate links. Every URL the site emits (links, sitemap, RSS, `feedUrl`) leaves the default locale unprefixed, because `/zh-TW/…` redirects.
- Browser requests use the Better Auth session cookie and may access only public or caller-owned procedures.
- Browser requests use the Better Auth session cookie and may access only public or caller-owned procedures. Shared reads (`@chia/services/shared/shared-reads`) go without credentials so a CDN can serve them.
- Content rendering belongs to `@chia/contents`, localization to `@chia/i18n` and shared agent UI to `@chia/agent-elements`.
- Render access from `session.access`; do not infer authorization from failed requests or the raw role column.
- `next/image` resizes through Cloudflare Image Transformations (`/cdn-cgi/image`) on the proxied production zone and serves originals elsewhere. Allowed image hosts are the zone's transformation origins, not `next.config` patterns; that list must name the site host (`chia1104.dev`) and `storage` too, because it replaces the same-zone default. `www` 301s to the apex at the Cloudflare edge.
Expand All @@ -41,6 +41,7 @@
- Business logic belongs in packages. Database access goes through `@chia/db/repos/*`.
- The service starts, resumes and cancels workflows but never executes them.
- Keep heavy provider and agent dependencies behind dynamic imports so route imports stay lightweight.
- A Cloudflare cache rule makes GETs under `/api/v1/rpc/` cacheable, honoring the response's cache headers and bypassing the cache when there are none.

## `workflow`

Expand Down
54 changes: 53 additions & 1 deletion apps/service/__tests__/spotify.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,17 @@ describe("Spotify Controller", () => {
id: "playlist-id",
});
mocks.getSpotifyNowPlayingService.mockResolvedValue({
is_playing: true,
track: {
name: "Song",
url: "https://open.spotify.com/track/1",
artists: ["Artist"],
album: "Album",
imageUrl: null,
durationMs: 200_000,
},
isPlaying: true,
progressMs: 60_000,
observedAt: 1_000_000,
});
mocks.completeSpotifyAuthorizationService.mockResolvedValue("connected");
});
Expand All @@ -67,10 +77,41 @@ describe("Spotify Controller", () => {

expect(res.status).toBe(200);
expect(mocks.getSpotifyNowPlayingService).toHaveBeenCalledWith(
expect.anything(),
expect.anything()
);
});

it("serves GET as a shared read any origin may cache", async () => {
const res = await app.request("/api/v1/rpc/spotify/playing", {
headers: { Origin: "http://localhost:3000" },
});

expect(res.status).toBe(200);
expect(res.headers.get("cdn-cache-control")).toContain("max-age=");
expect(res.headers.get("cache-control")).toBe("no-cache");
expect(res.headers.get("access-control-allow-origin")).toBe("*");
expect(res.headers.get("access-control-allow-credentials")).toBeNull();
});

it("leaves POST responses uncached", async () => {
const res = await playing();

expect(res.headers.get("cdn-cache-control")).toBeNull();
});

it("does not cache a failed GET", async () => {
mocks.getSpotifyNowPlayingService.mockRejectedValueOnce(
new mocks.SpotifyCredentialUnavailableError()
);

const res = await app.request("/api/v1/rpc/spotify/playing");

expect(res.status).toBe(503);
expect(res.headers.get("cdn-cache-control")).toBeNull();
expect(res.headers.get("cache-control")).toBeNull();
});

it("returns 503 when no active or fallback credential exists", async () => {
mocks.getSpotifyNowPlayingService.mockRejectedValueOnce(
new mocks.SpotifyCredentialUnavailableError()
Expand All @@ -85,6 +126,17 @@ describe("Spotify Controller", () => {
describe("spotify.playlist", () => {
beforeEach(() => guardMocks.setCallerTier(CallerTier.ApiKey));

it("does not answer GET, which only shared reads accept", async () => {
const res = await app.request(
`/api/v1/rpc/spotify/playlist?data=${encodeURIComponent(
JSON.stringify({ json: { playlistId: "default" } })
)}`
);

expect(res.status).toBe(404);
expect(mocks.getSpotifyPlaylistService).not.toHaveBeenCalled();
});

it("returns the default playlist", async () => {
const res = await playlist("default");

Expand Down
112 changes: 97 additions & 15 deletions apps/service/__tests__/spotify.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ import {

const db = {} as DB;

const store = new Map<string, unknown>();
const kv = {
get: vi.fn((key: string) => store.get(key)),
set: vi.fn((key: string, value: unknown) => {
store.set(key, value);
return true;
}),
delete: vi.fn((key: string) => store.delete(key)),
} as unknown as Keyv;

const createCredential = (expiresAt: Date) => ({
userId: "admin-id",
spotifyUserId: "spotify-id",
Expand Down Expand Up @@ -165,9 +175,91 @@ describe("Spotify now playing service", () => {
);
};
const unauthorizedError = createHTTPError(401, "Unauthorized");
const spotifyTrack = (progressMs: number) => ({
is_playing: true,
progress_ms: progressMs,
item: {
name: "Song",
duration_ms: 200_000,
external_urls: { spotify: "https://open.spotify.com/track/1" },
artists: [{ name: "Artist" }, { name: "Featured" }],
album: { name: "Album", images: [{ url: "https://i.scdn.co/image/1" }] },
},
});

beforeEach(() => {
store.clear();
vi.clearAllMocks();
vi.useRealTimers();
mocks.getActiveSpotifyCredential.mockResolvedValue(
createCredential(new Date(Date.now() + 10 * 60 * 1000))
);
});

it("returns the track and when its progress was read", async () => {
vi.useFakeTimers({ now: 1_000_000 });
mocks.getNowPlaying.mockResolvedValueOnce(spotifyTrack(60_000));

await expect(getSpotifyNowPlayingService(db, kv)).resolves.toEqual({
track: {
name: "Song",
url: "https://open.spotify.com/track/1",
artists: ["Artist", "Featured"],
album: "Album",
imageUrl: "https://i.scdn.co/image/1",
durationMs: 200_000,
},
isPlaying: true,
progressMs: 60_000,
observedAt: 1_000_000,
});
});

it("returns null while an ad plays", async () => {
mocks.getNowPlaying.mockResolvedValueOnce({
is_playing: true,
progress_ms: 0,
item: null,
});

await expect(getSpotifyNowPlayingService(db, kv)).resolves.toBeNull();
});

it("shares one Spotify call within the cache window", async () => {
vi.useFakeTimers({ now: 1_000_000 });
mocks.getNowPlaying.mockResolvedValueOnce(spotifyTrack(60_000));

await getSpotifyNowPlayingService(db, kv);
vi.advanceTimersByTime(5000);

await expect(getSpotifyNowPlayingService(db, kv)).resolves.toMatchObject({
progressMs: 60_000,
observedAt: 1_000_000,
});
expect(mocks.getNowPlaying).toHaveBeenCalledTimes(1);
});

it("asks Spotify again once the cached track would have ended", async () => {
vi.useFakeTimers({ now: 1_000_000 });
mocks.getNowPlaying
.mockResolvedValueOnce(spotifyTrack(198_000))
.mockResolvedValueOnce(spotifyTrack(1000));

await getSpotifyNowPlayingService(db, kv);
vi.advanceTimersByTime(3000);

await expect(getSpotifyNowPlayingService(db, kv)).resolves.toMatchObject({
progressMs: 1000,
});
expect(mocks.getNowPlaying).toHaveBeenCalledTimes(2);
});

it("caches an idle player", async () => {
mocks.getNowPlaying.mockResolvedValueOnce(null);

await expect(getSpotifyNowPlayingService(db, kv)).resolves.toBeNull();
await expect(getSpotifyNowPlayingService(db, kv)).resolves.toBeNull();
expect(mocks.getNowPlaying).toHaveBeenCalledTimes(1);
});

it("forces a refresh and retries when the stored token was revoked", async () => {
Expand All @@ -187,10 +279,10 @@ describe("Spotify now playing service", () => {
});
mocks.getNowPlaying
.mockRejectedValueOnce(unauthorizedError)
.mockResolvedValueOnce({ is_playing: true });
.mockResolvedValueOnce(spotifyTrack(1000));

await expect(getSpotifyNowPlayingService(db)).resolves.toEqual({
is_playing: true,
await expect(getSpotifyNowPlayingService(db, kv)).resolves.toMatchObject({
progressMs: 1000,
});
expect(mocks.getNowPlaying).toHaveBeenNthCalledWith(1, {
accessToken: "stored-access-token",
Expand All @@ -216,7 +308,7 @@ describe("Spotify now playing service", () => {
);
mocks.getNowPlaying.mockRejectedValueOnce(unauthorizedError);

await expect(getSpotifyNowPlayingService(db)).rejects.toBeInstanceOf(
await expect(getSpotifyNowPlayingService(db, kv)).rejects.toBeInstanceOf(
SpotifyCredentialUnavailableError
);
});
Expand All @@ -228,24 +320,14 @@ describe("Spotify now playing service", () => {
createHTTPError(500, "Internal Server Error")
);

await expect(getSpotifyNowPlayingService(db)).rejects.toThrow(
await expect(getSpotifyNowPlayingService(db, kv)).rejects.toThrow(
"500 Internal Server Error"
);
expect(mocks.refreshSpotifyAccessToken).not.toHaveBeenCalled();
});
});

describe("Spotify authorization service", () => {
const store = new Map<string, unknown>();
const kv = {
get: vi.fn((key: string) => store.get(key)),
set: vi.fn((key: string, value: unknown) => {
store.set(key, value);
return true;
}),
delete: vi.fn((key: string) => store.delete(key)),
} as unknown as Keyv;

beforeEach(() => {
store.clear();
vi.clearAllMocks();
Expand Down
17 changes: 17 additions & 0 deletions apps/service/src/routes/rpc.route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { trace } from "@opentelemetry/api";
import { RPCHandler } from "@orpc/server/fetch";
import { RPC_DEFAULT_ALLOW_METHODS } from "@orpc/server/standard";
import { Hono } from "hono";
import { timeout } from "hono/timeout";

import { router } from "@chia/services/router";
import { SHARED_READS } from "@chia/services/shared/shared-reads";

import { env } from "../env";
import {
Expand Down Expand Up @@ -32,6 +34,11 @@ const isUntimedProcedure = (path: string): boolean =>
/** Built once per process; holds no per-request state. */
const handler = new RPCHandler(router, {
errorStatusMap,
// GET is CSRF-safe only for reads that change nothing and ignore the caller's cookies.
allowMethods: (method, _procedure, path) =>
method === "GET"
? SHARED_READS.has(path.join("."))
: RPC_DEFAULT_ALLOW_METHODS.includes(method),
// Around the procedure call only: a body that fails to decode is oRPC's own BAD_REQUEST.
clientInterceptors: [
(options) => withErrorReporting(options.context, () => options.next()),
Expand Down Expand Up @@ -60,6 +67,16 @@ const api = new Hono<HonoContext>()
c.set("rpcProcedure", procedure);
trace.getActiveSpan()?.setAttribute("rpc.method", procedure);
}
const cdnCacheControl = procedure && SHARED_READS.get(procedure);
// Through `c.header`: Hono copies the CORS headers set earlier onto whatever is returned.
if (c.req.method === "GET" && response.ok && cdnCacheControl) {
c.header("CDN-Cache-Control", cdnCacheControl);
// Browsers ask again on every poll; the page decides when to.
c.header("Cache-Control", "no-cache");
// A CDN stores one copy for every origin and ignores `Vary: Origin`.
c.header("Access-Control-Allow-Origin", "*");
c.header("Access-Control-Allow-Credentials", undefined);
}
return c.newResponse(response.body, response);
}

Expand Down
Loading
Loading