Compare commits
19 Commits
7875d24d48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7623fff227 | ||
|
|
2105d75cb5 | ||
|
|
e937863bc6 | ||
|
|
5c4b189136 | ||
|
|
764ba09f77 | ||
|
|
365deb305a | ||
|
|
02f3139f46 | ||
|
|
6159ec00e0 | ||
|
|
3611add21a | ||
|
|
34b8221376 | ||
|
|
292fe8f85a | ||
|
|
2d7863d9cc | ||
|
|
c62936b819 | ||
|
|
62a55fd3e4 | ||
|
|
49608cff20 | ||
|
|
01048c753e | ||
|
|
8964f66184 | ||
|
|
6760c434b4 | ||
|
|
dc6daf3f54 |
@@ -14,6 +14,8 @@ jobs:
|
|||||||
- uses: denoland/setup-deno@v2
|
- uses: denoland/setup-deno@v2
|
||||||
with:
|
with:
|
||||||
deno-version: v2.x
|
deno-version: v2.x
|
||||||
|
cache: true
|
||||||
|
cache-hash: ${{ hashFiles('deno.lock') }}
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: deno fmt --check
|
run: deno fmt --check
|
||||||
@@ -22,7 +24,7 @@ jobs:
|
|||||||
run: deno lint
|
run: deno lint
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: deno check jetstream.ts server.ts scripts/*.ts src/migrations/init.ts
|
run: deno check jetstream.ts server.ts scripts/*.ts src/migrations/init.ts tests/
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: deno test --allow-all --permit-no-files
|
run: deno test --allow-all tests/
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@oak/oak": "jsr:@oak/oak@^17.2.0",
|
"@oak/oak": "jsr:@oak/oak@^17.2.0",
|
||||||
"@opensearch-project/opensearch": "npm:@opensearch-project/opensearch@^3.6.0",
|
"@opensearch-project/opensearch": "npm:@opensearch-project/opensearch@^3.6.0",
|
||||||
"@skyware/jetstream": "npm:@skyware/jetstream@^0.2.5",
|
"@skyware/jetstream": "npm:@skyware/jetstream@^0.2.5",
|
||||||
"@std/assert": "jsr:@std/assert@1"
|
"@std/assert": "jsr:@std/assert@1",
|
||||||
|
"@std/testing": "jsr:@std/testing@1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
deno.lock
generated
17
deno.lock
generated
@@ -7,8 +7,10 @@
|
|||||||
"jsr:@oak/commons@1": "1.0.0",
|
"jsr:@oak/commons@1": "1.0.0",
|
||||||
"jsr:@oak/oak@^17.2.0": "17.2.0",
|
"jsr:@oak/oak@^17.2.0": "17.2.0",
|
||||||
"jsr:@std/assert@1": "1.0.18",
|
"jsr:@std/assert@1": "1.0.18",
|
||||||
|
"jsr:@std/async@^1.4.0": "1.4.0",
|
||||||
"jsr:@std/bytes@1": "1.0.4",
|
"jsr:@std/bytes@1": "1.0.4",
|
||||||
"jsr:@std/crypto@1": "1.0.3",
|
"jsr:@std/crypto@1": "1.0.3",
|
||||||
|
"jsr:@std/data-structures@^1.1.0": "1.1.0",
|
||||||
"jsr:@std/encoding@1": "1.0.6",
|
"jsr:@std/encoding@1": "1.0.6",
|
||||||
"jsr:@std/encoding@^1.0.5": "1.0.6",
|
"jsr:@std/encoding@^1.0.5": "1.0.6",
|
||||||
"jsr:@std/fmt@1": "1.0.9",
|
"jsr:@std/fmt@1": "1.0.9",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"jsr:@std/path@1": "1.1.4",
|
"jsr:@std/path@1": "1.1.4",
|
||||||
"jsr:@std/path@1.0": "1.0.9",
|
"jsr:@std/path@1.0": "1.0.9",
|
||||||
"jsr:@std/path@^1.1.1": "1.1.4",
|
"jsr:@std/path@^1.1.1": "1.1.4",
|
||||||
|
"jsr:@std/testing@1": "1.0.19",
|
||||||
"npm:@opensearch-project/opensearch@3": "3.6.0",
|
"npm:@opensearch-project/opensearch@3": "3.6.0",
|
||||||
"npm:@opensearch-project/opensearch@^3.6.0": "3.6.0",
|
"npm:@opensearch-project/opensearch@^3.6.0": "3.6.0",
|
||||||
"npm:@skyware/jetstream@~0.2.5": "0.2.5",
|
"npm:@skyware/jetstream@~0.2.5": "0.2.5",
|
||||||
@@ -71,12 +74,18 @@
|
|||||||
"jsr:@std/internal@^1.0.12"
|
"jsr:@std/internal@^1.0.12"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@std/async@1.4.0": {
|
||||||
|
"integrity": "4d70b008634f571cff9b554090d628c76141c32613aae0ff283fd5fa23d0c379"
|
||||||
|
},
|
||||||
"@std/bytes@1.0.4": {
|
"@std/bytes@1.0.4": {
|
||||||
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
|
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
|
||||||
},
|
},
|
||||||
"@std/crypto@1.0.3": {
|
"@std/crypto@1.0.3": {
|
||||||
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
||||||
},
|
},
|
||||||
|
"@std/data-structures@1.1.0": {
|
||||||
|
"integrity": "c35ae4ad5d8e41a38573c2fe3e19b18ea2505f63cfea201edcb8720aca1f7f58"
|
||||||
|
},
|
||||||
"@std/encoding@1.0.6": {
|
"@std/encoding@1.0.6": {
|
||||||
"integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069"
|
"integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069"
|
||||||
},
|
},
|
||||||
@@ -110,6 +119,13 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/internal@^1.0.12"
|
"jsr:@std/internal@^1.0.12"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"@std/testing@1.0.19": {
|
||||||
|
"integrity": "f4236172365b216728dc3cc8b5e80a9f4c33083d1e4ede7613d5b25b4014898e",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/async",
|
||||||
|
"jsr:@std/data-structures"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
@@ -231,6 +247,7 @@
|
|||||||
"jsr:@db/sqlite@0.13",
|
"jsr:@db/sqlite@0.13",
|
||||||
"jsr:@oak/oak@^17.2.0",
|
"jsr:@oak/oak@^17.2.0",
|
||||||
"jsr:@std/assert@1",
|
"jsr:@std/assert@1",
|
||||||
|
"jsr:@std/testing@1",
|
||||||
"npm:@opensearch-project/opensearch@^3.6.0",
|
"npm:@opensearch-project/opensearch@^3.6.0",
|
||||||
"npm:@skyware/jetstream@~0.2.5"
|
"npm:@skyware/jetstream@~0.2.5"
|
||||||
]
|
]
|
||||||
|
|||||||
290
jetstream.ts
290
jetstream.ts
@@ -5,11 +5,12 @@ import {
|
|||||||
getWebhooksByDidAndVerb,
|
getWebhooksByDidAndVerb,
|
||||||
saveCursor,
|
saveCursor,
|
||||||
upsertNote,
|
upsertNote,
|
||||||
type WebhookVerb,
|
|
||||||
} from "./src/data/db.ts";
|
} from "./src/data/db.ts";
|
||||||
import { Note } from "./src/data/note.ts";
|
import { Note } from "./src/data/note.ts";
|
||||||
import { log } from "./src/log.ts";
|
import { log } from "./src/log.ts";
|
||||||
import { ensureIndex, indexNote, removeNote } from "./src/search/opensearch.ts";
|
import { ensureIndex, indexNote, removeNote } from "./src/search/opensearch.ts";
|
||||||
|
import { dispatchAll, fireWebhooks } from "./src/jetstream/webhooks.ts";
|
||||||
|
import { type BulkDeps, queueBulkCreate } from "./src/jetstream/bulk.ts";
|
||||||
|
|
||||||
const safeIndex = async (note: Note): Promise<void> => {
|
const safeIndex = async (note: Note): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -27,190 +28,127 @@ const safeRemove = async (did: string, rkey: string): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.addEventListener("unhandledrejection", (e) => {
|
const bulkDeps: BulkDeps = {
|
||||||
log("[jetstream] unhandled rejection:", e.reason);
|
getSubs: getWebhooksByDidAndVerb,
|
||||||
});
|
dispatch: dispatchAll,
|
||||||
|
|
||||||
globalThis.addEventListener("error", (e) => {
|
|
||||||
log("[jetstream] uncaught error:", e.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
type WebhookTarget = {
|
|
||||||
method: string;
|
|
||||||
url: string;
|
|
||||||
token?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispatchAll = async (
|
if (import.meta.main) {
|
||||||
webhooks: WebhookTarget[],
|
globalThis.addEventListener("unhandledrejection", (e) => {
|
||||||
payload: Record<string, unknown>,
|
log("[jetstream] unhandled rejection:", e.reason);
|
||||||
label: string,
|
|
||||||
): Promise<void> => {
|
|
||||||
if (webhooks.length === 0) return;
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
webhooks.map(({ method, url, token }) => {
|
|
||||||
const hasBody = method !== "GET" && method !== "HEAD";
|
|
||||||
return fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: hasBody ? JSON.stringify(payload) : undefined,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const result of results) {
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
log(`[jetstream] ${label} webhook error:`, result.reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fireWebhooks = async (
|
|
||||||
did: string,
|
|
||||||
verb: WebhookVerb,
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
): Promise<void> => {
|
|
||||||
const webhooks = getWebhooksByDidAndVerb(did, verb);
|
|
||||||
await dispatchAll(webhooks, payload, `${verb} ${did}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BULK_CREATE_DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
type BulkBuffer = {
|
|
||||||
records: Record<string, unknown>[];
|
|
||||||
timer: number;
|
|
||||||
};
|
|
||||||
const bulkBuffers = new Map<string, BulkBuffer>();
|
|
||||||
|
|
||||||
const flushBulkCreate = async (did: string): Promise<void> => {
|
|
||||||
const buffer = bulkBuffers.get(did);
|
|
||||||
if (!buffer) return;
|
|
||||||
bulkBuffers.delete(did);
|
|
||||||
const webhooks = getWebhooksByDidAndVerb(did, "bulk-create");
|
|
||||||
log(`[jetstream] bulk-create ${did}: ${buffer.records.length} record(s)`);
|
|
||||||
await dispatchAll(
|
|
||||||
webhooks,
|
|
||||||
{ event: "bulk-create", did, records: buffer.records },
|
|
||||||
`bulk-create ${did}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Buffered records are not persisted: if jetstream restarts mid-window, those
|
|
||||||
// `bulk-create` notifications are lost. Subscribers reconcile on cold start
|
|
||||||
// because the underlying notes are already saved to the `note` table.
|
|
||||||
const queueBulkCreate = (
|
|
||||||
did: string,
|
|
||||||
record: Record<string, unknown>,
|
|
||||||
): void => {
|
|
||||||
const existing = bulkBuffers.get(did);
|
|
||||||
if (existing) {
|
|
||||||
clearTimeout(existing.timer);
|
|
||||||
existing.records.push(record);
|
|
||||||
existing.timer = setTimeout(
|
|
||||||
() => flushBulkCreate(did),
|
|
||||||
BULK_CREATE_DEBOUNCE_MS,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bulkBuffers.set(did, {
|
|
||||||
records: [record],
|
|
||||||
timer: setTimeout(() => flushBulkCreate(did), BULK_CREATE_DEBOUNCE_MS),
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const cursor = getCursor();
|
globalThis.addEventListener("error", (e) => {
|
||||||
log(`[jetstream] starting with cursor: ${cursor ?? "none"}`);
|
log("[jetstream] uncaught error:", e.error);
|
||||||
|
});
|
||||||
|
|
||||||
const jetstream = new Jetstream({
|
const cursor = getCursor();
|
||||||
wantedCollections: ["space.remanso.note"],
|
log(`[jetstream] starting with cursor: ${cursor ?? "none"}`);
|
||||||
cursor: cursor ? Number(cursor) : undefined,
|
|
||||||
endpoint: "https://jetstream2.fr.hose.cam/subscribe",
|
const jetstream = new Jetstream({
|
||||||
});
|
wantedCollections: ["space.remanso.note"],
|
||||||
|
cursor: cursor ? Number(cursor) : undefined,
|
||||||
|
endpoint: "https://jetstream2.fr.hose.cam/subscribe",
|
||||||
|
});
|
||||||
|
|
||||||
|
jetstream.onCreate("space.remanso.note", async (event) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
did,
|
||||||
|
commit: { rkey, record },
|
||||||
|
} = event;
|
||||||
|
log(`[jetstream] creating ${did}/${rkey}...`);
|
||||||
|
const note = record as unknown as Omit<Note, "did" | "rkey">;
|
||||||
|
upsertNote({ did, rkey, ...note });
|
||||||
|
log(`[jetstream] create ${did}/${rkey}: ${note.title}`);
|
||||||
|
await Promise.allSettled([
|
||||||
|
fireWebhooks(
|
||||||
|
did,
|
||||||
|
"create",
|
||||||
|
{ event: "create", did, rkey, ...note },
|
||||||
|
getWebhooksByDidAndVerb,
|
||||||
|
),
|
||||||
|
safeIndex({ did, rkey, ...note }),
|
||||||
|
]);
|
||||||
|
queueBulkCreate(did, { rkey, ...note }, bulkDeps);
|
||||||
|
} catch (error) {
|
||||||
|
log(`[jetstream] error on create:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jetstream.onUpdate("space.remanso.note", async (event) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
did,
|
||||||
|
commit: { rkey, record },
|
||||||
|
} = event;
|
||||||
|
log(`[jetstream] updating ${did}/${rkey}...`);
|
||||||
|
const note = record as unknown as Omit<Note, "did" | "rkey">;
|
||||||
|
upsertNote({ did, rkey, ...note });
|
||||||
|
log(`[jetstream] update ${did}/${rkey}: ${note.title}`);
|
||||||
|
// Updates fold into the `create` verb — subscribers reconcile by (did, rkey).
|
||||||
|
await Promise.allSettled([
|
||||||
|
fireWebhooks(
|
||||||
|
did,
|
||||||
|
"create",
|
||||||
|
{ event: "create", did, rkey, ...note },
|
||||||
|
getWebhooksByDidAndVerb,
|
||||||
|
),
|
||||||
|
safeIndex({ did, rkey, ...note }),
|
||||||
|
]);
|
||||||
|
queueBulkCreate(did, { rkey, ...note }, bulkDeps);
|
||||||
|
} catch (error) {
|
||||||
|
log(`[jetstream] error on update:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jetstream.onDelete("space.remanso.note", async (event) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
did,
|
||||||
|
commit: { rkey },
|
||||||
|
} = event;
|
||||||
|
log(`[jetstream] deleting ${did}/${rkey}...`);
|
||||||
|
deleteNote({ did, rkey });
|
||||||
|
log(`[jetstream] delete ${did}/${rkey}`);
|
||||||
|
await Promise.allSettled([
|
||||||
|
fireWebhooks(
|
||||||
|
did,
|
||||||
|
"delete",
|
||||||
|
{ event: "delete", did, rkey },
|
||||||
|
getWebhooksByDidAndVerb,
|
||||||
|
),
|
||||||
|
safeRemove(did, rkey),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
log(`[jetstream] error on delete:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jetstream.on("open", () => {
|
||||||
|
log("[jetstream] connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
jetstream.on("close", () => {
|
||||||
|
log("[jetstream] connection closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
jetstream.on("error", (error) => {
|
||||||
|
log("[jetstream] connection closed with error", error);
|
||||||
|
});
|
||||||
|
|
||||||
jetstream.onCreate("space.remanso.note", async (event) => {
|
|
||||||
try {
|
try {
|
||||||
const {
|
await ensureIndex();
|
||||||
did,
|
|
||||||
commit: { rkey, record },
|
|
||||||
} = event;
|
|
||||||
log(`[jetstream] creating ${did}/${rkey}...`);
|
|
||||||
const note = record as unknown as Omit<Note, "did" | "rkey">;
|
|
||||||
upsertNote({ did, rkey, ...note });
|
|
||||||
log(`[jetstream] create ${did}/${rkey}: ${note.title}`);
|
|
||||||
await Promise.allSettled([
|
|
||||||
fireWebhooks(did, "create", { event: "create", did, rkey, ...note }),
|
|
||||||
safeIndex({ did, rkey, ...note }),
|
|
||||||
]);
|
|
||||||
queueBulkCreate(did, { rkey, ...note });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[jetstream] error on create:`, error);
|
log("[opensearch] ensureIndex failed at startup:", error);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
jetstream.onUpdate("space.remanso.note", async (event) => {
|
log("[jetstream] launching");
|
||||||
try {
|
jetstream.start();
|
||||||
const {
|
|
||||||
did,
|
|
||||||
commit: { rkey, record },
|
|
||||||
} = event;
|
|
||||||
log(`[jetstream] updating ${did}/${rkey}...`);
|
|
||||||
const note = record as unknown as Omit<Note, "did" | "rkey">;
|
|
||||||
upsertNote({ did, rkey, ...note });
|
|
||||||
log(`[jetstream] update ${did}/${rkey}: ${note.title}`);
|
|
||||||
// Updates fold into the `create` verb — subscribers reconcile by (did, rkey).
|
|
||||||
await Promise.allSettled([
|
|
||||||
fireWebhooks(did, "create", { event: "create", did, rkey, ...note }),
|
|
||||||
safeIndex({ did, rkey, ...note }),
|
|
||||||
]);
|
|
||||||
queueBulkCreate(did, { rkey, ...note });
|
|
||||||
} catch (error) {
|
|
||||||
log(`[jetstream] error on update:`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
jetstream.onDelete("space.remanso.note", async (event) => {
|
setInterval(() => {
|
||||||
try {
|
if (jetstream.cursor) {
|
||||||
const {
|
saveCursor(jetstream.cursor);
|
||||||
did,
|
}
|
||||||
commit: { rkey },
|
}, 5000);
|
||||||
} = event;
|
|
||||||
log(`[jetstream] deleting ${did}/${rkey}...`);
|
|
||||||
deleteNote({ did, rkey });
|
|
||||||
log(`[jetstream] delete ${did}/${rkey}`);
|
|
||||||
await Promise.allSettled([
|
|
||||||
fireWebhooks(did, "delete", { event: "delete", did, rkey }),
|
|
||||||
safeRemove(did, rkey),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
log(`[jetstream] error on delete:`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
jetstream.on("open", () => {
|
|
||||||
log("[jetstream] connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
jetstream.on("close", () => {
|
|
||||||
log("[jetstream] connection closed");
|
|
||||||
});
|
|
||||||
|
|
||||||
jetstream.on("error", (error) => {
|
|
||||||
log("[jetstream] connection closed with error", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureIndex();
|
|
||||||
} catch (error) {
|
|
||||||
log("[opensearch] ensureIndex failed at startup:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log("[jetstream] launching");
|
|
||||||
jetstream.start();
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
if (jetstream.cursor) {
|
|
||||||
saveCursor(jetstream.cursor);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// Requires OPENSEARCH_URL to be set; falls back to a no-op otherwise.
|
// Requires OPENSEARCH_URL to be set; falls back to a no-op otherwise.
|
||||||
// SQLite path follows the same SQLITE_PATH convention as the server/jetstream.
|
// SQLite path follows the same SQLITE_PATH convention as the server/jetstream.
|
||||||
|
|
||||||
import { db } from "../src/data/db.ts";
|
import { closeDb, listDistinctNoteDids } from "../src/data/db.ts";
|
||||||
import { resolvePds } from "../src/auth/verify.ts";
|
import { resolvePds } from "../src/auth/verify.ts";
|
||||||
import { ensureIndex, indexNote } from "../src/search/opensearch.ts";
|
import { ensureIndex, indexNote } from "../src/search/opensearch.ts";
|
||||||
import { log } from "../src/log.ts";
|
import { log } from "../src/log.ts";
|
||||||
@@ -100,14 +100,12 @@ const main = async () => {
|
|||||||
Deno.exit(1);
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = db.prepare("SELECT DISTINCT did FROM note ORDER BY did").all<
|
const dids = listDistinctNoteDids();
|
||||||
{ did: string }
|
log(`[backfill] ${dids.length} DID(s) to process`);
|
||||||
>();
|
|
||||||
log(`[backfill] ${rows.length} DID(s) to process`);
|
|
||||||
|
|
||||||
let totalIndexed = 0;
|
let totalIndexed = 0;
|
||||||
let totalFailed = 0;
|
let totalFailed = 0;
|
||||||
for (const { did } of rows) {
|
for (const did of dids) {
|
||||||
try {
|
try {
|
||||||
const { indexed, failed } = await backfillDid(did);
|
const { indexed, failed } = await backfillDid(did);
|
||||||
log(`[backfill] ${did}: indexed=${indexed} failed=${failed}`);
|
log(`[backfill] ${did}: indexed=${indexed} failed=${failed}`);
|
||||||
@@ -119,7 +117,7 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log(`[backfill] done. indexed=${totalIndexed} failed=${totalFailed}`);
|
log(`[backfill] done. indexed=${totalIndexed} failed=${totalFailed}`);
|
||||||
db.close();
|
closeDb();
|
||||||
};
|
};
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
|
|||||||
80
server.ts
80
server.ts
@@ -19,13 +19,25 @@ type AuthCtx = {
|
|||||||
response: { status: number; body: unknown };
|
response: { status: number; body: unknown };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let authenticator: typeof authenticateRequest = authenticateRequest;
|
||||||
|
|
||||||
|
export const _setAuthenticatorForTest = (
|
||||||
|
fn: typeof authenticateRequest,
|
||||||
|
): void => {
|
||||||
|
authenticator = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _resetAuthenticatorForTest = (): void => {
|
||||||
|
authenticator = authenticateRequest;
|
||||||
|
};
|
||||||
|
|
||||||
const requireDidOwnership = async (
|
const requireDidOwnership = async (
|
||||||
ctx: AuthCtx,
|
ctx: AuthCtx,
|
||||||
did: string,
|
did: string,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
let verifiedDid: string;
|
let verifiedDid: string;
|
||||||
try {
|
try {
|
||||||
verifiedDid = await authenticateRequest(
|
verifiedDid = await authenticator(
|
||||||
ctx.request.headers.get("Authorization"),
|
ctx.request.headers.get("Authorization"),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -41,17 +53,18 @@ const requireDidOwnership = async (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADMIN_DIDS = new Set(
|
const adminDids = (): Set<string> =>
|
||||||
(Deno.env.get("ADMIN_DIDS") ?? "")
|
new Set(
|
||||||
.split(",")
|
(Deno.env.get("ADMIN_DIDS") ?? "")
|
||||||
.map((d) => d.trim())
|
.split(",")
|
||||||
.filter(Boolean),
|
.map((d) => d.trim())
|
||||||
);
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
const requireAdmin = async (ctx: AuthCtx): Promise<boolean> => {
|
const requireAdmin = async (ctx: AuthCtx): Promise<boolean> => {
|
||||||
let verifiedDid: string;
|
let verifiedDid: string;
|
||||||
try {
|
try {
|
||||||
verifiedDid = await authenticateRequest(
|
verifiedDid = await authenticator(
|
||||||
ctx.request.headers.get("Authorization"),
|
ctx.request.headers.get("Authorization"),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -59,7 +72,7 @@ const requireAdmin = async (ctx: AuthCtx): Promise<boolean> => {
|
|||||||
ctx.response.body = { error: "Unauthorized" };
|
ctx.response.body = { error: "Unauthorized" };
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!ADMIN_DIDS.has(verifiedDid)) {
|
if (!adminDids().has(verifiedDid)) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Admin only" };
|
ctx.response.body = { error: "Admin only" };
|
||||||
return false;
|
return false;
|
||||||
@@ -260,27 +273,34 @@ router.delete("/:did/webhooks/:id", async (ctx) => {
|
|||||||
// ctx.response.status = 204;
|
// ctx.response.status = 204;
|
||||||
// })
|
// })
|
||||||
|
|
||||||
const app = new Application();
|
export const createApp = (): Application => {
|
||||||
|
const app = new Application();
|
||||||
|
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set(
|
||||||
"Access-Control-Allow-Methods",
|
"Access-Control-Allow-Methods",
|
||||||
"GET, POST, PUT, DELETE, OPTIONS",
|
"GET, POST, PUT, DELETE, OPTIONS",
|
||||||
);
|
);
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set(
|
||||||
"Access-Control-Allow-Headers",
|
"Access-Control-Allow-Headers",
|
||||||
"Content-Type, Authorization",
|
"Content-Type, Authorization",
|
||||||
);
|
);
|
||||||
if (ctx.request.method === "OPTIONS") {
|
if (ctx.request.method === "OPTIONS") {
|
||||||
ctx.response.status = 204;
|
ctx.response.status = 204;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
app.use(router.allowedMethods());
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
log("[server] listening on port 8080");
|
return app;
|
||||||
app.listen({ port: 8080 });
|
};
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const app = createApp();
|
||||||
|
log("[server] listening on port 8080");
|
||||||
|
app.listen({ port: 8080 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface DidDocument {
|
|||||||
service?: { id: string; serviceEndpoint: string }[];
|
service?: { id: string; serviceEndpoint: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeJwtPayload(token: string): JwtPayload {
|
export function decodeJwtPayload(token: string): JwtPayload {
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
||||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
import { Database } from "@db/sqlite";
|
import { Database } from "@db/sqlite";
|
||||||
import type { Note } from "./note.ts";
|
import type { Note } from "./note.ts";
|
||||||
|
|
||||||
export const db = new Database(Deno.env.get("SQLITE_PATH") ?? "notes.db");
|
let _db: Database | null = null;
|
||||||
|
|
||||||
try {
|
const openDefaultDb = (): Database => {
|
||||||
db.exec("PRAGMA busy_timeout=10000");
|
const handle = new Database(Deno.env.get("SQLITE_PATH") ?? "notes.db");
|
||||||
db.exec("PRAGMA journal_mode=WAL");
|
try {
|
||||||
const [row] = db.prepare("PRAGMA journal_mode").all<
|
handle.exec("PRAGMA busy_timeout=10000");
|
||||||
{ journal_mode: string }
|
handle.exec("PRAGMA journal_mode=WAL");
|
||||||
>();
|
const [row] = handle.prepare("PRAGMA journal_mode").all<
|
||||||
console.log(`[db] journal_mode=${row.journal_mode}, busy_timeout=10000`);
|
{ journal_mode: string }
|
||||||
} catch (e) {
|
>();
|
||||||
console.error("[db] failed to set PRAGMAs:", e);
|
console.log(`[db] journal_mode=${row.journal_mode}, busy_timeout=10000`);
|
||||||
}
|
} catch (e) {
|
||||||
|
console.error("[db] failed to set PRAGMAs:", e);
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDb = (): Database => _db ??= openDefaultDb();
|
||||||
|
|
||||||
|
export const _setDbForTest = (handle: Database): void => {
|
||||||
|
_db = handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _resetDbForTest = (): void => {
|
||||||
|
_db = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeDb = (): void => {
|
||||||
|
if (_db) {
|
||||||
|
_db.close();
|
||||||
|
_db = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
type NoteRow = {
|
type NoteRow = {
|
||||||
did: string;
|
did: string;
|
||||||
@@ -23,6 +44,7 @@ type NoteRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getNotes = (cursor?: string, limit = 20) => {
|
export const getNotes = (cursor?: string, limit = 20) => {
|
||||||
|
const db = getDb();
|
||||||
const notes = cursor
|
const notes = cursor
|
||||||
? db.prepare(
|
? db.prepare(
|
||||||
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
||||||
@@ -38,6 +60,7 @@ export const getNotes = (cursor?: string, limit = 20) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
|
export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
|
||||||
|
const db = getDb();
|
||||||
const notes = cursor
|
const notes = cursor
|
||||||
? db.prepare(
|
? db.prepare(
|
||||||
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
||||||
@@ -54,6 +77,7 @@ export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
|
|||||||
|
|
||||||
export const getNotesByDids = (dids: string[], cursor?: string, limit = 20) => {
|
export const getNotesByDids = (dids: string[], cursor?: string, limit = 20) => {
|
||||||
if (dids.length === 0) return { notes: [] };
|
if (dids.length === 0) return { notes: [] };
|
||||||
|
const db = getDb();
|
||||||
const placeholders = dids.map(() => "?").join(", ");
|
const placeholders = dids.map(() => "?").join(", ");
|
||||||
const notes = cursor
|
const notes = cursor
|
||||||
? db.prepare(
|
? db.prepare(
|
||||||
@@ -70,18 +94,18 @@ export const getNotesByDids = (dids: string[], cursor?: string, limit = 20) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteNote = ({ did, rkey }: { did: string; rkey: string }) => {
|
export const deleteNote = ({ did, rkey }: { did: string; rkey: string }) => {
|
||||||
db.exec("DELETE FROM note WHERE did = ? AND rkey = ?", did, rkey);
|
getDb().exec("DELETE FROM note WHERE did = ? AND rkey = ?", did, rkey);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCursor = (): string | undefined => {
|
export const getCursor = (): string | undefined => {
|
||||||
const row = db.prepare(
|
const row = getDb().prepare(
|
||||||
"SELECT value FROM state WHERE key = 'cursor'",
|
"SELECT value FROM state WHERE key = 'cursor'",
|
||||||
).get<{ value: string }>();
|
).get<{ value: string }>();
|
||||||
return row?.value;
|
return row?.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveCursor = (cursor: number) => {
|
export const saveCursor = (cursor: number) => {
|
||||||
db.exec(
|
getDb().exec(
|
||||||
"INSERT OR REPLACE INTO state (key, value) VALUES ('cursor', ?)",
|
"INSERT OR REPLACE INTO state (key, value) VALUES ('cursor', ?)",
|
||||||
String(cursor),
|
String(cursor),
|
||||||
);
|
);
|
||||||
@@ -101,6 +125,7 @@ type WebhookSubscriptionRow = {
|
|||||||
export const addWebhookSubscription = (
|
export const addWebhookSubscription = (
|
||||||
{ did, method, url, token, verb }: Omit<WebhookSubscriptionRow, "id">,
|
{ did, method, url, token, verb }: Omit<WebhookSubscriptionRow, "id">,
|
||||||
): WebhookSubscriptionRow => {
|
): WebhookSubscriptionRow => {
|
||||||
|
const db = getDb();
|
||||||
db.exec(
|
db.exec(
|
||||||
"INSERT INTO webhook_subscription (did, method, url, token, verb) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO webhook_subscription (did, method, url, token, verb) VALUES (?, ?, ?, ?, ?)",
|
||||||
did,
|
did,
|
||||||
@@ -116,13 +141,13 @@ export const addWebhookSubscription = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWebhooksByDid = (did: string): void => {
|
export const deleteWebhooksByDid = (did: string): void => {
|
||||||
db.exec("DELETE FROM webhook_subscription WHERE did = ?", did);
|
getDb().exec("DELETE FROM webhook_subscription WHERE did = ?", did);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWebhookById = (
|
export const deleteWebhookById = (
|
||||||
{ did, id }: { did: string; id: number },
|
{ did, id }: { did: string; id: number },
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const result = db.prepare(
|
const result = getDb().prepare(
|
||||||
"DELETE FROM webhook_subscription WHERE did = ? AND id = ?",
|
"DELETE FROM webhook_subscription WHERE did = ? AND id = ?",
|
||||||
).run(did, id);
|
).run(did, id);
|
||||||
return result > 0;
|
return result > 0;
|
||||||
@@ -131,13 +156,13 @@ export const deleteWebhookById = (
|
|||||||
export const listWebhooksByDid = (
|
export const listWebhooksByDid = (
|
||||||
did: string,
|
did: string,
|
||||||
): Omit<WebhookSubscriptionRow, "token">[] => {
|
): Omit<WebhookSubscriptionRow, "token">[] => {
|
||||||
return db.prepare(
|
return getDb().prepare(
|
||||||
"SELECT id, did, method, url, verb FROM webhook_subscription WHERE did = ? ORDER BY id DESC",
|
"SELECT id, did, method, url, verb FROM webhook_subscription WHERE did = ? ORDER BY id DESC",
|
||||||
).all<Omit<WebhookSubscriptionRow, "token">>(did);
|
).all<Omit<WebhookSubscriptionRow, "token">>(did);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listAllWebhooks = (): Omit<WebhookSubscriptionRow, "token">[] => {
|
export const listAllWebhooks = (): Omit<WebhookSubscriptionRow, "token">[] => {
|
||||||
return db.prepare(
|
return getDb().prepare(
|
||||||
"SELECT id, did, method, url, verb FROM webhook_subscription ORDER BY did, id",
|
"SELECT id, did, method, url, verb FROM webhook_subscription ORDER BY did, id",
|
||||||
).all<Omit<WebhookSubscriptionRow, "token">>();
|
).all<Omit<WebhookSubscriptionRow, "token">>();
|
||||||
};
|
};
|
||||||
@@ -146,14 +171,20 @@ export const getWebhooksByDidAndVerb = (
|
|||||||
did: string,
|
did: string,
|
||||||
verb: WebhookVerb,
|
verb: WebhookVerb,
|
||||||
): WebhookSubscriptionRow[] => {
|
): WebhookSubscriptionRow[] => {
|
||||||
return db.prepare(
|
return getDb().prepare(
|
||||||
"SELECT id, did, method, url, token, verb FROM webhook_subscription WHERE did = ? AND verb = ? ORDER BY id DESC LIMIT 10",
|
"SELECT id, did, method, url, token, verb FROM webhook_subscription WHERE did = ? AND verb = ? ORDER BY id DESC LIMIT 10",
|
||||||
).all<WebhookSubscriptionRow>(did, verb);
|
).all<WebhookSubscriptionRow>(did, verb);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listDistinctNoteDids = (): string[] => {
|
||||||
|
return getDb().prepare(
|
||||||
|
"SELECT DISTINCT did FROM note ORDER BY did",
|
||||||
|
).all<{ did: string }>().map((row) => row.did);
|
||||||
|
};
|
||||||
|
|
||||||
export const upsertNote = (note: Note) => {
|
export const upsertNote = (note: Note) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
db.exec(
|
getDb().exec(
|
||||||
`
|
`
|
||||||
INSERT INTO note (
|
INSERT INTO note (
|
||||||
title,
|
title,
|
||||||
|
|||||||
66
src/jetstream/bulk.ts
Normal file
66
src/jetstream/bulk.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { log } from "../log.ts";
|
||||||
|
import type { GetSubsByVerb, WebhookTarget } from "./webhooks.ts";
|
||||||
|
|
||||||
|
export const BULK_CREATE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
export type BulkDeps = {
|
||||||
|
getSubs: GetSubsByVerb;
|
||||||
|
dispatch: (
|
||||||
|
webhooks: WebhookTarget[],
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
label: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
debounceMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BulkBuffer = {
|
||||||
|
records: Record<string, unknown>[];
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkBuffers = new Map<string, BulkBuffer>();
|
||||||
|
|
||||||
|
export const flushBulkCreate = async (
|
||||||
|
did: string,
|
||||||
|
deps: BulkDeps,
|
||||||
|
): Promise<void> => {
|
||||||
|
const buffer = bulkBuffers.get(did);
|
||||||
|
if (!buffer) return;
|
||||||
|
bulkBuffers.delete(did);
|
||||||
|
const webhooks = deps.getSubs(did, "bulk-create");
|
||||||
|
log(`[jetstream] bulk-create ${did}: ${buffer.records.length} record(s)`);
|
||||||
|
await deps.dispatch(
|
||||||
|
webhooks,
|
||||||
|
{ event: "bulk-create", did, records: buffer.records },
|
||||||
|
`bulk-create ${did}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buffered records are not persisted: if jetstream restarts mid-window, those
|
||||||
|
// `bulk-create` notifications are lost. Subscribers reconcile on cold start
|
||||||
|
// because the underlying notes are already saved to the `note` table.
|
||||||
|
export const queueBulkCreate = (
|
||||||
|
did: string,
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
deps: BulkDeps,
|
||||||
|
): void => {
|
||||||
|
const ms = deps.debounceMs ?? BULK_CREATE_DEBOUNCE_MS;
|
||||||
|
const existing = bulkBuffers.get(did);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.records.push(record);
|
||||||
|
existing.timer = setTimeout(() => flushBulkCreate(did, deps), ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bulkBuffers.set(did, {
|
||||||
|
records: [record],
|
||||||
|
timer: setTimeout(() => flushBulkCreate(did, deps), ms),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _resetBulkBuffersForTest = (): void => {
|
||||||
|
for (const buffer of bulkBuffers.values()) {
|
||||||
|
clearTimeout(buffer.timer);
|
||||||
|
}
|
||||||
|
bulkBuffers.clear();
|
||||||
|
};
|
||||||
49
src/jetstream/webhooks.ts
Normal file
49
src/jetstream/webhooks.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { WebhookVerb } from "../data/db.ts";
|
||||||
|
import { log } from "../log.ts";
|
||||||
|
|
||||||
|
export type WebhookTarget = {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dispatchAll = async (
|
||||||
|
webhooks: WebhookTarget[],
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
label: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (webhooks.length === 0) return;
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
webhooks.map(({ method, url, token }) => {
|
||||||
|
const hasBody = method !== "GET" && method !== "HEAD";
|
||||||
|
return fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: hasBody ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
log(`[jetstream] ${label} webhook error:`, result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSubsByVerb = (
|
||||||
|
did: string,
|
||||||
|
verb: WebhookVerb,
|
||||||
|
) => WebhookTarget[];
|
||||||
|
|
||||||
|
export const fireWebhooks = async (
|
||||||
|
did: string,
|
||||||
|
verb: WebhookVerb,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
getSubs: GetSubsByVerb,
|
||||||
|
): Promise<void> => {
|
||||||
|
const webhooks = getSubs(did, verb);
|
||||||
|
await dispatchAll(webhooks, payload, `${verb} ${did}`);
|
||||||
|
};
|
||||||
83
src/migrations/apply.ts
Normal file
83
src/migrations/apply.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Database } from "@db/sqlite";
|
||||||
|
|
||||||
|
export const applyMigrations = (db: Database): void => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS note (
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
publishedAt DATETIME,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
rkey TEXT NOT NULL,
|
||||||
|
discoverable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (did, rkey)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(
|
||||||
|
`ALTER TABLE note ADD COLUMN listed INTEGER NOT NULL DEFAULT 1;`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(
|
||||||
|
`ALTER TABLE note ADD COLUMN language STRING;`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE note RENAME COLUMN listed TO discoverable;`);
|
||||||
|
} catch {
|
||||||
|
// Column already renamed or doesn't exist — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_subscription (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_subscription_did
|
||||||
|
ON webhook_subscription(did);
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE webhook_subscription ADD COLUMN token TEXT;`);
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(
|
||||||
|
`ALTER TABLE webhook_subscription ADD COLUMN verb TEXT NOT NULL DEFAULT 'create';`,
|
||||||
|
);
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO webhook_subscription (did, method, url, token, verb)
|
||||||
|
SELECT did, method, url, token, 'delete'
|
||||||
|
FROM webhook_subscription
|
||||||
|
WHERE verb = 'create';
|
||||||
|
`);
|
||||||
|
} catch {
|
||||||
|
// Column already exists — backfill already happened on a previous run.
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_subscription_did_verb
|
||||||
|
ON webhook_subscription(did, verb);
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -1,83 +1,6 @@
|
|||||||
import { db } from "../data/db.ts";
|
import { Database } from "@db/sqlite";
|
||||||
|
import { applyMigrations } from "./apply.ts";
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS note (
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
publishedAt DATETIME,
|
|
||||||
createdAt DATETIME NOT NULL,
|
|
||||||
did TEXT NOT NULL,
|
|
||||||
rkey TEXT NOT NULL,
|
|
||||||
discoverable INTEGER NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (did, rkey)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.exec(
|
|
||||||
`ALTER TABLE note ADD COLUMN listed INTEGER NOT NULL DEFAULT 1;`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Column already exists — no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.exec(
|
|
||||||
`ALTER TABLE note ADD COLUMN language STRING;`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Column already exists — no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.exec(`ALTER TABLE note RENAME COLUMN listed TO discoverable;`);
|
|
||||||
} catch {
|
|
||||||
// Column already renamed or doesn't exist — no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS state (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS webhook_subscription (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
did TEXT NOT NULL,
|
|
||||||
method TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_subscription_did
|
|
||||||
ON webhook_subscription(did);
|
|
||||||
`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.exec(`ALTER TABLE webhook_subscription ADD COLUMN token TEXT;`);
|
|
||||||
} catch {
|
|
||||||
// Column already exists — no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.exec(
|
|
||||||
`ALTER TABLE webhook_subscription ADD COLUMN verb TEXT NOT NULL DEFAULT 'create';`,
|
|
||||||
);
|
|
||||||
db.exec(`
|
|
||||||
INSERT INTO webhook_subscription (did, method, url, token, verb)
|
|
||||||
SELECT did, method, url, token, 'delete'
|
|
||||||
FROM webhook_subscription
|
|
||||||
WHERE verb = 'create';
|
|
||||||
`);
|
|
||||||
} catch {
|
|
||||||
// Column already exists — backfill already happened on a previous run.
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_subscription_did_verb
|
|
||||||
ON webhook_subscription(did, verb);
|
|
||||||
`);
|
|
||||||
|
|
||||||
|
const db = new Database(Deno.env.get("SQLITE_PATH") ?? "notes.db");
|
||||||
|
applyMigrations(db);
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import type { API } from "@opensearch-project/opensearch";
|
import type { API } from "@opensearch-project/opensearch";
|
||||||
import { log } from "../log.ts";
|
import { log } from "../log.ts";
|
||||||
|
|
||||||
const OPENSEARCH_URL = Deno.env.get("OPENSEARCH_URL");
|
|
||||||
const OPENSEARCH_USERNAME = Deno.env.get("OPENSEARCH_USERNAME");
|
|
||||||
const OPENSEARCH_PASSWORD = Deno.env.get("OPENSEARCH_PASSWORD");
|
|
||||||
const OPENSEARCH_INDEX = Deno.env.get("OPENSEARCH_INDEX") ?? "notes";
|
|
||||||
|
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
|
|
||||||
const enabled = (): boolean => {
|
const openSearchUrl = (): string | undefined => Deno.env.get("OPENSEARCH_URL");
|
||||||
if (!OPENSEARCH_URL) return false;
|
const openSearchIndex = (): string =>
|
||||||
return true;
|
Deno.env.get("OPENSEARCH_INDEX") ?? "notes";
|
||||||
};
|
|
||||||
|
const enabled = (): boolean => Boolean(openSearchUrl());
|
||||||
|
|
||||||
const authHeaders = (): Record<string, string> => {
|
const authHeaders = (): Record<string, string> => {
|
||||||
if (OPENSEARCH_USERNAME && OPENSEARCH_PASSWORD) {
|
const username = Deno.env.get("OPENSEARCH_USERNAME");
|
||||||
const creds = btoa(`${OPENSEARCH_USERNAME}:${OPENSEARCH_PASSWORD}`);
|
const password = Deno.env.get("OPENSEARCH_PASSWORD");
|
||||||
|
if (username && password) {
|
||||||
|
const creds = btoa(`${username}:${password}`);
|
||||||
return { Authorization: `Basic ${creds}` };
|
return { Authorization: `Basic ${creds}` };
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
@@ -29,7 +27,7 @@ const request = async (
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const url = `${OPENSEARCH_URL}${path}`;
|
const url = `${openSearchUrl()}${path}`;
|
||||||
const headers: Record<string, string> = { ...authHeaders() };
|
const headers: Record<string, string> = { ...authHeaders() };
|
||||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
@@ -59,11 +57,12 @@ const indexMappings = {
|
|||||||
|
|
||||||
export const ensureIndex = async (): Promise<void> => {
|
export const ensureIndex = async (): Promise<void> => {
|
||||||
if (!enabled()) return;
|
if (!enabled()) return;
|
||||||
const head = await request("HEAD", `/${OPENSEARCH_INDEX}`);
|
const index = openSearchIndex();
|
||||||
|
const head = await request("HEAD", `/${index}`);
|
||||||
if (head.status === 200) return;
|
if (head.status === 200) return;
|
||||||
const res = await request("PUT", `/${OPENSEARCH_INDEX}`, indexMappings);
|
const res = await request("PUT", `/${index}`, indexMappings);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
log(`[opensearch] created index ${OPENSEARCH_INDEX}`);
|
log(`[opensearch] created index ${index}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -96,7 +95,7 @@ export const indexNote = async (note: IndexedNote): Promise<void> => {
|
|||||||
};
|
};
|
||||||
const res = await request(
|
const res = await request(
|
||||||
"PUT",
|
"PUT",
|
||||||
`/${OPENSEARCH_INDEX}/_doc/${docId(note.did, note.rkey)}`,
|
`/${openSearchIndex()}/_doc/${docId(note.did, note.rkey)}`,
|
||||||
doc,
|
doc,
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -114,7 +113,7 @@ export const removeNote = async (
|
|||||||
if (!enabled()) return;
|
if (!enabled()) return;
|
||||||
const res = await request(
|
const res = await request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
`/${OPENSEARCH_INDEX}/_doc/${docId(did, rkey)}`,
|
`/${openSearchIndex()}/_doc/${docId(did, rkey)}`,
|
||||||
);
|
);
|
||||||
if (!res.ok && res.status !== 404) {
|
if (!res.ok && res.status !== 404) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -139,7 +138,7 @@ export type SearchOptions = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeCursor = (cursor: string): [number, string] | undefined => {
|
export const decodeCursor = (cursor: string): [number, string] | undefined => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(atob(cursor));
|
const parsed = JSON.parse(atob(cursor));
|
||||||
if (
|
if (
|
||||||
@@ -157,10 +156,10 @@ const decodeCursor = (cursor: string): [number, string] | undefined => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodeCursor = (score: number, rkey: string): string =>
|
export const encodeCursor = (score: number, rkey: string): string =>
|
||||||
btoa(JSON.stringify([score, rkey]));
|
btoa(JSON.stringify([score, rkey]));
|
||||||
|
|
||||||
const firstSnippet = (
|
export const firstSnippet = (
|
||||||
highlight: Record<string, string[]> | undefined,
|
highlight: Record<string, string[]> | undefined,
|
||||||
source: { content?: string; title?: string },
|
source: { content?: string; title?: string },
|
||||||
): string => {
|
): string => {
|
||||||
@@ -219,7 +218,7 @@ export const searchNotes = async (
|
|||||||
...(searchAfter ? { search_after: searchAfter } : {}),
|
...(searchAfter ? { search_after: searchAfter } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await request("POST", `/${OPENSEARCH_INDEX}/_search`, body);
|
const res = await request("POST", `/${openSearchIndex()}/_search`, body);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(`Search failed: ${res.status} ${text}`);
|
throw new Error(`Search failed: ${res.status} ${text}`);
|
||||||
|
|||||||
49
tests/_helpers/app.ts
Normal file
49
tests/_helpers/app.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Application } from "@oak/oak";
|
||||||
|
|
||||||
|
export type RequestOptions = {
|
||||||
|
method?: string;
|
||||||
|
path: string;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestResponse = {
|
||||||
|
status: number;
|
||||||
|
headers: Headers;
|
||||||
|
text: string;
|
||||||
|
json: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestApp = async (
|
||||||
|
app: Application,
|
||||||
|
opts: RequestOptions,
|
||||||
|
): Promise<TestResponse> => {
|
||||||
|
const method = opts.method ?? "GET";
|
||||||
|
const url = `http://test.local${opts.path}`;
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: opts.headers,
|
||||||
|
};
|
||||||
|
if (opts.body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||||
|
init.body = typeof opts.body === "string"
|
||||||
|
? opts.body
|
||||||
|
: JSON.stringify(opts.body);
|
||||||
|
const headers = new Headers(opts.headers);
|
||||||
|
if (!headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
init.headers = headers;
|
||||||
|
}
|
||||||
|
const req = new Request(url, init);
|
||||||
|
const res = await app.handle(req);
|
||||||
|
if (!res) {
|
||||||
|
throw new Error("app.handle returned undefined");
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
headers: res.headers,
|
||||||
|
text,
|
||||||
|
json: () => JSON.parse(text),
|
||||||
|
};
|
||||||
|
};
|
||||||
18
tests/_helpers/auth.ts
Normal file
18
tests/_helpers/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
_resetAuthenticatorForTest,
|
||||||
|
_setAuthenticatorForTest,
|
||||||
|
} from "../../server.ts";
|
||||||
|
|
||||||
|
export type AuthStub = { restore: () => void };
|
||||||
|
|
||||||
|
export const stubAuthenticate = (didOrError: string | Error): AuthStub => {
|
||||||
|
_setAuthenticatorForTest(() => {
|
||||||
|
if (didOrError instanceof Error) {
|
||||||
|
return Promise.reject(didOrError);
|
||||||
|
}
|
||||||
|
return Promise.resolve(didOrError);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
restore: () => _resetAuthenticatorForTest(),
|
||||||
|
};
|
||||||
|
};
|
||||||
60
tests/_helpers/db.ts
Normal file
60
tests/_helpers/db.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Database } from "@db/sqlite";
|
||||||
|
import {
|
||||||
|
_resetDbForTest,
|
||||||
|
_setDbForTest,
|
||||||
|
addWebhookSubscription,
|
||||||
|
upsertNote,
|
||||||
|
type WebhookVerb,
|
||||||
|
} from "../../src/data/db.ts";
|
||||||
|
import { applyMigrations } from "../../src/migrations/apply.ts";
|
||||||
|
import type { Note } from "../../src/data/note.ts";
|
||||||
|
|
||||||
|
export const withTestDb = (
|
||||||
|
fn: (db: Database) => Promise<void> | void,
|
||||||
|
): () => Promise<void> => {
|
||||||
|
return async () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
applyMigrations(db);
|
||||||
|
_setDbForTest(db);
|
||||||
|
try {
|
||||||
|
await fn(db);
|
||||||
|
} finally {
|
||||||
|
_resetDbForTest();
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let seedCounter = 0;
|
||||||
|
|
||||||
|
export const seedNote = (overrides: Partial<Note> = {}): Note => {
|
||||||
|
seedCounter++;
|
||||||
|
const note: Note = {
|
||||||
|
did: overrides.did ?? "did:plc:test",
|
||||||
|
rkey: overrides.rkey ?? `seed${seedCounter.toString().padStart(6, "0")}`,
|
||||||
|
title: overrides.title ?? "Test title",
|
||||||
|
publishedAt: overrides.publishedAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
discoverable: overrides.discoverable,
|
||||||
|
language: overrides.language,
|
||||||
|
content: overrides.content,
|
||||||
|
};
|
||||||
|
upsertNote(note);
|
||||||
|
return note;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedWebhook = (overrides: {
|
||||||
|
did?: string;
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
verb?: WebhookVerb;
|
||||||
|
} = {}) => {
|
||||||
|
return addWebhookSubscription({
|
||||||
|
did: overrides.did ?? "did:plc:test",
|
||||||
|
method: overrides.method ?? "POST",
|
||||||
|
url: overrides.url ?? "https://example.com/hook",
|
||||||
|
token: overrides.token,
|
||||||
|
verb: overrides.verb ?? "create",
|
||||||
|
});
|
||||||
|
};
|
||||||
57
tests/_helpers/fetch_stub.ts
Normal file
57
tests/_helpers/fetch_stub.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export type FetchHandler = (
|
||||||
|
req: Request,
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
export type CapturedRequest = {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: Headers;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchStub = {
|
||||||
|
calls: CapturedRequest[];
|
||||||
|
restore: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installFetchStub = (handler: FetchHandler): FetchStub => {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
const calls: CapturedRequest[] = [];
|
||||||
|
let restored = false;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const req = new Request(input as RequestInfo, init);
|
||||||
|
const body = req.method === "GET" || req.method === "HEAD"
|
||||||
|
? ""
|
||||||
|
: await req.clone().text();
|
||||||
|
calls.push({
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
headers: new Headers(req.headers),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return await handler(req);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
restore: () => {
|
||||||
|
if (restored) throw new Error("FetchStub.restore() called twice");
|
||||||
|
restored = true;
|
||||||
|
globalThis.fetch = original;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonResponse = (
|
||||||
|
body: unknown,
|
||||||
|
init: ResponseInit = {},
|
||||||
|
): Response => {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: init.status ?? 200,
|
||||||
|
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const errorResponse = (status: number, text = "error"): Response =>
|
||||||
|
new Response(text, { status });
|
||||||
118
tests/auth_verify_test.ts
Normal file
118
tests/auth_verify_test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
|
||||||
|
import { authenticateRequest, decodeJwtPayload } from "../src/auth/verify.ts";
|
||||||
|
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||||
|
|
||||||
|
const makeJwt = (payload: Record<string, unknown>): string => {
|
||||||
|
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const body = btoa(JSON.stringify(payload));
|
||||||
|
return `${header}.${body}.sig`;
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("decodeJwtPayload parses sub from a 3-segment JWT", () => {
|
||||||
|
const token = makeJwt({ sub: "did:plc:abc", iat: 1 });
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
assertEquals(payload.sub, "did:plc:abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("decodeJwtPayload throws on malformed token", () => {
|
||||||
|
assertThrows(() => decodeJwtPayload("not.a.valid.jwt"));
|
||||||
|
assertThrows(() => decodeJwtPayload("onlytwo.parts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("authenticateRequest rejects missing or non-Bearer Authorization", async () => {
|
||||||
|
await assertRejects(() => authenticateRequest(null));
|
||||||
|
await assertRejects(() => authenticateRequest(""));
|
||||||
|
await assertRejects(() => authenticateRequest("Basic abc"));
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"authenticateRequest resolves PDS then verifies session and returns did",
|
||||||
|
async () => {
|
||||||
|
const did = "did:plc:user";
|
||||||
|
const stub = installFetchStub((req) => {
|
||||||
|
if (req.url === `https://plc.directory/${did}`) {
|
||||||
|
return jsonResponse({
|
||||||
|
service: [{
|
||||||
|
id: "#atproto_pds",
|
||||||
|
serviceEndpoint: "https://pds.example.com",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
req.url ===
|
||||||
|
"https://pds.example.com/xrpc/com.atproto.server.getSession"
|
||||||
|
) {
|
||||||
|
return jsonResponse({ did });
|
||||||
|
}
|
||||||
|
return jsonResponse({ error: "unexpected" }, { status: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authenticateRequest(
|
||||||
|
`Bearer ${makeJwt({ sub: did })}`,
|
||||||
|
);
|
||||||
|
assertEquals(result, did);
|
||||||
|
const sessionCall = stub.calls.find((c) => c.url.endsWith("getSession"));
|
||||||
|
assertEquals(
|
||||||
|
sessionCall?.headers.get("Authorization")?.startsWith("Bearer "),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"authenticateRequest throws when plc.directory or getSession is non-2xx",
|
||||||
|
async () => {
|
||||||
|
const did = "did:plc:user";
|
||||||
|
|
||||||
|
const plcFailStub = installFetchStub(() =>
|
||||||
|
new Response("not found", { status: 404 })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await assertRejects(() =>
|
||||||
|
authenticateRequest(`Bearer ${makeJwt({ sub: did })}`)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
plcFailStub.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionFailStub = installFetchStub((req) => {
|
||||||
|
if (req.url.startsWith("https://plc.directory/")) {
|
||||||
|
return jsonResponse({
|
||||||
|
service: [{
|
||||||
|
id: "#atproto_pds",
|
||||||
|
serviceEndpoint: "https://pds.example.com",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("nope", { status: 401 });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await assertRejects(() =>
|
||||||
|
authenticateRequest(`Bearer ${makeJwt({ sub: did })}`)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sessionFailStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"authenticateRequest throws when DID document has no atproto_pds service",
|
||||||
|
async () => {
|
||||||
|
const did = "did:plc:user";
|
||||||
|
const stub = installFetchStub(() =>
|
||||||
|
jsonResponse({ service: [{ id: "#other", serviceEndpoint: "x" }] })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await assertRejects(() =>
|
||||||
|
authenticateRequest(`Bearer ${makeJwt({ sub: did })}`)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
141
tests/db_test.ts
Normal file
141
tests/db_test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { assertEquals, assertFalse } from "@std/assert";
|
||||||
|
import {
|
||||||
|
addWebhookSubscription,
|
||||||
|
deleteWebhookById,
|
||||||
|
getNotes,
|
||||||
|
getNotesByDids,
|
||||||
|
getWebhooksByDidAndVerb,
|
||||||
|
listWebhooksByDid,
|
||||||
|
upsertNote,
|
||||||
|
} from "../src/data/db.ts";
|
||||||
|
import { seedNote, seedWebhook, withTestDb } from "./_helpers/db.ts";
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"upsertNote inserts then updates on (did, rkey) conflict",
|
||||||
|
withTestDb(() => {
|
||||||
|
upsertNote({
|
||||||
|
did: "did:plc:user",
|
||||||
|
rkey: "rkey1",
|
||||||
|
title: "First",
|
||||||
|
publishedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
upsertNote({
|
||||||
|
did: "did:plc:user",
|
||||||
|
rkey: "rkey1",
|
||||||
|
title: "Second",
|
||||||
|
publishedAt: "2026-02-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { notes } = getNotes(undefined, 10);
|
||||||
|
assertEquals(notes.length, 1);
|
||||||
|
assertEquals(notes[0].title, "Second");
|
||||||
|
assertEquals(notes[0].publishedAt, "2026-02-01T00:00:00.000Z");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"upsertNote treats undefined discoverable as 1 and false as 0",
|
||||||
|
withTestDb(() => {
|
||||||
|
upsertNote({
|
||||||
|
did: "did:plc:user",
|
||||||
|
rkey: "default",
|
||||||
|
title: "Default",
|
||||||
|
publishedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
upsertNote({
|
||||||
|
did: "did:plc:user",
|
||||||
|
rkey: "hidden",
|
||||||
|
title: "Hidden",
|
||||||
|
publishedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
discoverable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { notes } = getNotes(undefined, 10);
|
||||||
|
const rkeys = notes.map((n) => n.rkey);
|
||||||
|
assertEquals(rkeys, ["default"]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"getNotes paginates rkey desc and returns cursor only when page is full",
|
||||||
|
withTestDb(() => {
|
||||||
|
for (const rkey of ["a", "b", "c", "d", "e"]) {
|
||||||
|
seedNote({ rkey });
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPage = getNotes(undefined, 3);
|
||||||
|
assertEquals(firstPage.notes.map((n) => n.rkey), ["e", "d", "c"]);
|
||||||
|
assertEquals(firstPage.cursor, "c");
|
||||||
|
|
||||||
|
const secondPage = getNotes(firstPage.cursor, 3);
|
||||||
|
assertEquals(secondPage.notes.map((n) => n.rkey), ["b", "a"]);
|
||||||
|
assertEquals(secondPage.cursor, undefined);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"getNotesByDids returns empty without running SQL on empty did list",
|
||||||
|
withTestDb(() => {
|
||||||
|
seedNote({ did: "did:plc:alice", rkey: "a" });
|
||||||
|
const result = getNotesByDids([], undefined, 10);
|
||||||
|
assertEquals(result, { notes: [] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"webhook CRUD: add omits token, list orders desc, delete by id reports hit",
|
||||||
|
withTestDb(() => {
|
||||||
|
const created = addWebhookSubscription({
|
||||||
|
did: "did:plc:user",
|
||||||
|
method: "POST",
|
||||||
|
url: "https://example.com/a",
|
||||||
|
token: "secret",
|
||||||
|
verb: "create",
|
||||||
|
});
|
||||||
|
assertEquals(created.token, undefined);
|
||||||
|
assertEquals(created.url, "https://example.com/a");
|
||||||
|
|
||||||
|
seedWebhook({
|
||||||
|
did: "did:plc:user",
|
||||||
|
url: "https://example.com/b",
|
||||||
|
verb: "create",
|
||||||
|
});
|
||||||
|
seedWebhook({
|
||||||
|
did: "did:plc:user",
|
||||||
|
url: "https://example.com/c",
|
||||||
|
verb: "delete",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = listWebhooksByDid("did:plc:user");
|
||||||
|
assertEquals(list.length, 3);
|
||||||
|
const ids = list.map((w) => w.id);
|
||||||
|
assertEquals(ids, [...ids].sort((a, b) => b - a));
|
||||||
|
|
||||||
|
assertFalse(deleteWebhookById({ did: "did:plc:user", id: 9999 }));
|
||||||
|
const deleted = deleteWebhookById({ did: "did:plc:user", id: created.id });
|
||||||
|
assertEquals(deleted, true);
|
||||||
|
assertEquals(listWebhooksByDid("did:plc:user").length, 2);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"getWebhooksByDidAndVerb filters by verb and caps at 10",
|
||||||
|
withTestDb(() => {
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
seedWebhook({ url: `https://example.com/c${i}`, verb: "create" });
|
||||||
|
}
|
||||||
|
seedWebhook({ url: "https://example.com/d", verb: "delete" });
|
||||||
|
|
||||||
|
const creates = getWebhooksByDidAndVerb("did:plc:test", "create");
|
||||||
|
assertEquals(creates.length, 10);
|
||||||
|
for (const row of creates) assertEquals(row.verb, "create");
|
||||||
|
|
||||||
|
const deletes = getWebhooksByDidAndVerb("did:plc:test", "delete");
|
||||||
|
assertEquals(deletes.length, 1);
|
||||||
|
assertEquals(deletes[0].verb, "delete");
|
||||||
|
}),
|
||||||
|
);
|
||||||
147
tests/jetstream_bulk_test.ts
Normal file
147
tests/jetstream_bulk_test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { FakeTime } from "@std/testing/time";
|
||||||
|
import {
|
||||||
|
_resetBulkBuffersForTest,
|
||||||
|
type BulkDeps,
|
||||||
|
queueBulkCreate,
|
||||||
|
} from "../src/jetstream/bulk.ts";
|
||||||
|
import type { WebhookTarget } from "../src/jetstream/webhooks.ts";
|
||||||
|
import type { WebhookVerb } from "../src/data/db.ts";
|
||||||
|
|
||||||
|
type DispatchCall = {
|
||||||
|
webhooks: WebhookTarget[];
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushMicrotasks = async (): Promise<void> => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeDeps = (
|
||||||
|
webhooks: WebhookTarget[] = [],
|
||||||
|
): {
|
||||||
|
deps: BulkDeps;
|
||||||
|
getSubsCalls: { did: string; verb: WebhookVerb }[];
|
||||||
|
dispatchCalls: DispatchCall[];
|
||||||
|
} => {
|
||||||
|
const getSubsCalls: { did: string; verb: WebhookVerb }[] = [];
|
||||||
|
const dispatchCalls: DispatchCall[] = [];
|
||||||
|
const deps: BulkDeps = {
|
||||||
|
getSubs: (did, verb) => {
|
||||||
|
getSubsCalls.push({ did, verb });
|
||||||
|
return webhooks;
|
||||||
|
},
|
||||||
|
dispatch: (w, payload, label) => {
|
||||||
|
dispatchCalls.push({ webhooks: w, payload, label });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
debounceMs: 500,
|
||||||
|
};
|
||||||
|
return { deps, getSubsCalls, dispatchCalls };
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("queueBulkCreate accumulates records and flushes once after debounce", async () => {
|
||||||
|
const time = new FakeTime();
|
||||||
|
try {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
const { deps, dispatchCalls } = makeDeps();
|
||||||
|
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "r1" }, deps);
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "r2" }, deps);
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "r3" }, deps);
|
||||||
|
|
||||||
|
assertEquals(dispatchCalls.length, 0);
|
||||||
|
|
||||||
|
await time.tickAsync(500);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assertEquals(dispatchCalls.length, 1);
|
||||||
|
const payload = dispatchCalls[0].payload as {
|
||||||
|
records: { rkey: string }[];
|
||||||
|
};
|
||||||
|
assertEquals(payload.records.map((r) => r.rkey), ["r1", "r2", "r3"]);
|
||||||
|
} finally {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
time.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("queueBulkCreate restarts the timer on each push (debounce)", async () => {
|
||||||
|
const time = new FakeTime();
|
||||||
|
try {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
const { deps, dispatchCalls } = makeDeps();
|
||||||
|
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "r1" }, deps);
|
||||||
|
await time.tickAsync(400);
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "r2" }, deps);
|
||||||
|
await time.tickAsync(400);
|
||||||
|
|
||||||
|
assertEquals(dispatchCalls.length, 0);
|
||||||
|
|
||||||
|
await time.tickAsync(100);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assertEquals(dispatchCalls.length, 1);
|
||||||
|
} finally {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
time.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("flushBulkCreate uses bulk-create verb and the standard payload shape", async () => {
|
||||||
|
const time = new FakeTime();
|
||||||
|
try {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
const targets: WebhookTarget[] = [
|
||||||
|
{ method: "POST", url: "https://hook.example/bulk" },
|
||||||
|
];
|
||||||
|
const { deps, getSubsCalls, dispatchCalls } = makeDeps(targets);
|
||||||
|
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "r1", title: "T" }, deps);
|
||||||
|
await time.tickAsync(500);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assertEquals(getSubsCalls, [{ did: "did:plc:a", verb: "bulk-create" }]);
|
||||||
|
assertEquals(dispatchCalls.length, 1);
|
||||||
|
assertEquals(dispatchCalls[0].webhooks, targets);
|
||||||
|
assertEquals(dispatchCalls[0].label, "bulk-create did:plc:a");
|
||||||
|
assertEquals(dispatchCalls[0].payload, {
|
||||||
|
event: "bulk-create",
|
||||||
|
did: "did:plc:a",
|
||||||
|
records: [{ rkey: "r1", title: "T" }],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
time.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("bulkBuffers are cleared after flush and isolated per did", async () => {
|
||||||
|
const time = new FakeTime();
|
||||||
|
try {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
const { deps, dispatchCalls } = makeDeps();
|
||||||
|
|
||||||
|
queueBulkCreate("did:plc:a", { rkey: "a1" }, deps);
|
||||||
|
queueBulkCreate("did:plc:b", { rkey: "b1" }, deps);
|
||||||
|
await time.tickAsync(500);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assertEquals(dispatchCalls.length, 2);
|
||||||
|
const dids = dispatchCalls.map((c) => (c.payload as { did: string }).did)
|
||||||
|
.sort();
|
||||||
|
assertEquals(dids, ["did:plc:a", "did:plc:b"]);
|
||||||
|
|
||||||
|
// Another flush window should not re-dispatch the same records.
|
||||||
|
await time.tickAsync(500);
|
||||||
|
await flushMicrotasks();
|
||||||
|
assertEquals(dispatchCalls.length, 2);
|
||||||
|
} finally {
|
||||||
|
_resetBulkBuffersForTest();
|
||||||
|
time.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
94
tests/jetstream_webhooks_test.ts
Normal file
94
tests/jetstream_webhooks_test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { dispatchAll } from "../src/jetstream/webhooks.ts";
|
||||||
|
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||||
|
|
||||||
|
Deno.test("dispatchAll is a noop on empty webhook list", async () => {
|
||||||
|
const stub = installFetchStub(() =>
|
||||||
|
jsonResponse({ should: "not be called" })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await dispatchAll([], { event: "create" }, "label");
|
||||||
|
assertEquals(stub.calls.length, 0);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"dispatchAll sends Authorization Bearer only when token is present",
|
||||||
|
async () => {
|
||||||
|
const stub = installFetchStub(() => new Response(null, { status: 204 }));
|
||||||
|
try {
|
||||||
|
await dispatchAll(
|
||||||
|
[
|
||||||
|
{ method: "POST", url: "https://a.example/hook" },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
url: "https://b.example/hook",
|
||||||
|
token: "shh",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ event: "create" },
|
||||||
|
"label",
|
||||||
|
);
|
||||||
|
assertEquals(stub.calls.length, 2);
|
||||||
|
const [first, second] = stub.calls;
|
||||||
|
assertEquals(first.headers.get("Authorization"), null);
|
||||||
|
assertEquals(second.headers.get("Authorization"), "Bearer shh");
|
||||||
|
assertEquals(first.headers.get("Content-Type"), "application/json");
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"dispatchAll omits body and Content-Type for GET and HEAD",
|
||||||
|
async () => {
|
||||||
|
const stub = installFetchStub(() => new Response(null, { status: 204 }));
|
||||||
|
try {
|
||||||
|
await dispatchAll(
|
||||||
|
[
|
||||||
|
{ method: "GET", url: "https://a.example/ping" },
|
||||||
|
{ method: "HEAD", url: "https://b.example/ping" },
|
||||||
|
],
|
||||||
|
{ event: "create", data: "ignored" },
|
||||||
|
"label",
|
||||||
|
);
|
||||||
|
assertEquals(stub.calls.length, 2);
|
||||||
|
for (const call of stub.calls) {
|
||||||
|
assertEquals(call.body, "");
|
||||||
|
assertEquals(call.headers.get("Content-Type"), null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"dispatchAll uses Promise.allSettled — one rejection does not abort others",
|
||||||
|
async () => {
|
||||||
|
const stub = installFetchStub((req) => {
|
||||||
|
if (req.url === "https://reject.example/hook") {
|
||||||
|
return Promise.reject(new Error("connection refused"));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await dispatchAll(
|
||||||
|
[
|
||||||
|
{ method: "POST", url: "https://reject.example/hook" },
|
||||||
|
{ method: "POST", url: "https://ok.example/hook" },
|
||||||
|
],
|
||||||
|
{ event: "create" },
|
||||||
|
"label",
|
||||||
|
);
|
||||||
|
assertEquals(stub.calls.length, 2);
|
||||||
|
const urls = stub.calls.map((c) => c.url);
|
||||||
|
assertEquals(urls.includes("https://ok.example/hook"), true);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
115
tests/migrations_test.ts
Normal file
115
tests/migrations_test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { Database } from "@db/sqlite";
|
||||||
|
import { applyMigrations } from "../src/migrations/apply.ts";
|
||||||
|
|
||||||
|
type TableInfoRow = { name: string };
|
||||||
|
type ColumnInfoRow = { name: string };
|
||||||
|
|
||||||
|
const tableNames = (db: Database): string[] =>
|
||||||
|
db.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
|
||||||
|
).all<TableInfoRow>().map((r) => r.name);
|
||||||
|
|
||||||
|
const columnNames = (db: Database, table: string): string[] =>
|
||||||
|
db.prepare(`PRAGMA table_info(${table})`)
|
||||||
|
.all<ColumnInfoRow>()
|
||||||
|
.map((r) => r.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const indexNames = (db: Database): string[] =>
|
||||||
|
db.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||||
|
).all<TableInfoRow>().map((r) => r.name);
|
||||||
|
|
||||||
|
Deno.test("applyMigrations creates expected tables, columns, and indices", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
applyMigrations(db);
|
||||||
|
|
||||||
|
const tables = tableNames(db);
|
||||||
|
assertEquals(
|
||||||
|
tables.includes("note") &&
|
||||||
|
tables.includes("state") &&
|
||||||
|
tables.includes("webhook_subscription"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteCols = columnNames(db, "note");
|
||||||
|
for (
|
||||||
|
const col of [
|
||||||
|
"title",
|
||||||
|
"publishedAt",
|
||||||
|
"createdAt",
|
||||||
|
"did",
|
||||||
|
"rkey",
|
||||||
|
"discoverable",
|
||||||
|
"language",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertEquals(noteCols.includes(col), true, `note.${col} missing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookCols = columnNames(db, "webhook_subscription");
|
||||||
|
for (const col of ["id", "did", "method", "url", "token", "verb"]) {
|
||||||
|
assertEquals(
|
||||||
|
webhookCols.includes(col),
|
||||||
|
true,
|
||||||
|
`webhook_subscription.${col} missing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indices = indexNames(db);
|
||||||
|
assertEquals(indices.includes("idx_webhook_subscription_did"), true);
|
||||||
|
assertEquals(indices.includes("idx_webhook_subscription_did_verb"), true);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("applyMigrations is idempotent", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
applyMigrations(db);
|
||||||
|
db.exec(
|
||||||
|
"INSERT INTO webhook_subscription (did, method, url, verb) VALUES ('did:plc:x', 'POST', 'https://e.com', 'create')",
|
||||||
|
);
|
||||||
|
applyMigrations(db);
|
||||||
|
applyMigrations(db);
|
||||||
|
|
||||||
|
const [{ count }] = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS count FROM webhook_subscription WHERE did = 'did:plc:x' AND verb = 'create'",
|
||||||
|
).all<{ count: number }>();
|
||||||
|
assertEquals(count, 1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"applyMigrations upgrades a legacy schema missing language and discoverable",
|
||||||
|
() => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE note (
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
publishedAt DATETIME,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
rkey TEXT NOT NULL,
|
||||||
|
listed INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (did, rkey)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
applyMigrations(db);
|
||||||
|
|
||||||
|
const cols = columnNames(db, "note");
|
||||||
|
assertEquals(cols.includes("discoverable"), true);
|
||||||
|
assertEquals(cols.includes("language"), true);
|
||||||
|
assertEquals(cols.includes("listed"), false);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
204
tests/opensearch_test.ts
Normal file
204
tests/opensearch_test.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { assertEquals, assertRejects } from "@std/assert";
|
||||||
|
import {
|
||||||
|
decodeCursor,
|
||||||
|
encodeCursor,
|
||||||
|
firstSnippet,
|
||||||
|
searchNotes,
|
||||||
|
} from "../src/search/opensearch.ts";
|
||||||
|
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||||
|
|
||||||
|
const withOpenSearchEnv = (fn: () => Promise<void> | void) => {
|
||||||
|
return async () => {
|
||||||
|
const previousUrl = Deno.env.get("OPENSEARCH_URL");
|
||||||
|
const previousIndex = Deno.env.get("OPENSEARCH_INDEX");
|
||||||
|
Deno.env.set("OPENSEARCH_URL", "http://opensearch.test");
|
||||||
|
Deno.env.set("OPENSEARCH_INDEX", "notes");
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
if (previousUrl === undefined) Deno.env.delete("OPENSEARCH_URL");
|
||||||
|
else Deno.env.set("OPENSEARCH_URL", previousUrl);
|
||||||
|
if (previousIndex === undefined) Deno.env.delete("OPENSEARCH_INDEX");
|
||||||
|
else Deno.env.set("OPENSEARCH_INDEX", previousIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("encodeCursor and decodeCursor round-trip", () => {
|
||||||
|
const cursor = encodeCursor(3.14, "abc123");
|
||||||
|
assertEquals(decodeCursor(cursor), [3.14, "abc123"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("decodeCursor returns undefined on garbage input", () => {
|
||||||
|
assertEquals(decodeCursor("not-base64-at-all!!!"), undefined);
|
||||||
|
assertEquals(decodeCursor(btoa("not-json")), undefined);
|
||||||
|
assertEquals(decodeCursor(btoa(JSON.stringify([1]))), undefined);
|
||||||
|
assertEquals(
|
||||||
|
decodeCursor(btoa(JSON.stringify(["wrong", "type"]))),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("firstSnippet prefers highlight.content > title > source content > title fallback", () => {
|
||||||
|
assertEquals(
|
||||||
|
firstSnippet(
|
||||||
|
{ content: ["hl-content"], title: ["hl-title"] },
|
||||||
|
{ title: "src-title", content: "src-content" },
|
||||||
|
),
|
||||||
|
"hl-content",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
firstSnippet({ title: ["hl-title"] }, {
|
||||||
|
title: "src-title",
|
||||||
|
content: "src-content",
|
||||||
|
}),
|
||||||
|
"hl-title",
|
||||||
|
);
|
||||||
|
const longContent = "a".repeat(500);
|
||||||
|
assertEquals(
|
||||||
|
firstSnippet(undefined, { content: longContent, title: "ignored" }).length,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assertEquals(firstSnippet(undefined, { title: "only-title" }), "only-title");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"searchNotes returns empty when OPENSEARCH_URL is unset",
|
||||||
|
async () => {
|
||||||
|
const previousUrl = Deno.env.get("OPENSEARCH_URL");
|
||||||
|
Deno.env.delete("OPENSEARCH_URL");
|
||||||
|
try {
|
||||||
|
const stub = installFetchStub(() =>
|
||||||
|
jsonResponse({ should: "not be called" })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await searchNotes({
|
||||||
|
q: "hello",
|
||||||
|
discoverableOnly: true,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
assertEquals(result, { results: [] });
|
||||||
|
assertEquals(stub.calls.length, 0);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (previousUrl !== undefined) {
|
||||||
|
Deno.env.set("OPENSEARCH_URL", previousUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"searchNotes builds multi_match body with filters and cursor",
|
||||||
|
withOpenSearchEnv(async () => {
|
||||||
|
const stub = installFetchStub(() =>
|
||||||
|
jsonResponse({
|
||||||
|
hits: {
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
_source: {
|
||||||
|
did: "did:plc:a",
|
||||||
|
rkey: "rk-a",
|
||||||
|
title: "T",
|
||||||
|
content: "c",
|
||||||
|
},
|
||||||
|
_score: 1.5,
|
||||||
|
highlight: { content: ["snippet"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_source: {
|
||||||
|
did: "did:plc:b",
|
||||||
|
rkey: "rk-b",
|
||||||
|
title: "U",
|
||||||
|
content: "d",
|
||||||
|
},
|
||||||
|
_score: 1.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await searchNotes({
|
||||||
|
q: "hello world",
|
||||||
|
discoverableOnly: true,
|
||||||
|
did: "did:plc:a",
|
||||||
|
limit: 2,
|
||||||
|
cursor: encodeCursor(2.0, "rk-prev"),
|
||||||
|
});
|
||||||
|
assertEquals(result.results.length, 2);
|
||||||
|
assertEquals(result.results[0].snippet, "snippet");
|
||||||
|
assertEquals(result.results[1].snippet, "d");
|
||||||
|
assertEquals(result.cursor, encodeCursor(1.0, "rk-b"));
|
||||||
|
|
||||||
|
assertEquals(stub.calls.length, 1);
|
||||||
|
const sent = JSON.parse(stub.calls[0].body) as Record<string, unknown>;
|
||||||
|
assertEquals(sent.size, 2);
|
||||||
|
assertEquals(sent.search_after, [2.0, "rk-prev"]);
|
||||||
|
const query = sent.query as {
|
||||||
|
bool: { must: unknown[]; filter: unknown[] };
|
||||||
|
};
|
||||||
|
const multi =
|
||||||
|
(query.bool.must[0] as { multi_match: Record<string, unknown> })
|
||||||
|
.multi_match;
|
||||||
|
assertEquals(multi.query, "hello world");
|
||||||
|
assertEquals(multi.fields, ["title^2", "content"]);
|
||||||
|
assertEquals(multi.fuzziness, "AUTO");
|
||||||
|
assertEquals(query.bool.filter, [
|
||||||
|
{ term: { discoverable: true } },
|
||||||
|
{ term: { did: "did:plc:a" } },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"searchNotes returns cursor only when the page is full",
|
||||||
|
withOpenSearchEnv(async () => {
|
||||||
|
const stub = installFetchStub(() =>
|
||||||
|
jsonResponse({
|
||||||
|
hits: {
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
_source: { did: "did:plc:a", rkey: "rk", title: "T" },
|
||||||
|
_score: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await searchNotes({
|
||||||
|
q: "hi",
|
||||||
|
discoverableOnly: false,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
assertEquals(result.results.length, 1);
|
||||||
|
assertEquals(result.cursor, undefined);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"searchNotes throws on non-ok response",
|
||||||
|
withOpenSearchEnv(async () => {
|
||||||
|
const stub = installFetchStub(() => new Response("boom", { status: 500 }));
|
||||||
|
try {
|
||||||
|
await assertRejects(() =>
|
||||||
|
searchNotes({
|
||||||
|
q: "hi",
|
||||||
|
discoverableOnly: false,
|
||||||
|
limit: 5,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
287
tests/server_test.ts
Normal file
287
tests/server_test.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { createApp } from "../server.ts";
|
||||||
|
import { addWebhookSubscription, listWebhooksByDid } from "../src/data/db.ts";
|
||||||
|
import { seedNote, withTestDb } from "./_helpers/db.ts";
|
||||||
|
import { requestApp } from "./_helpers/app.ts";
|
||||||
|
import { stubAuthenticate } from "./_helpers/auth.ts";
|
||||||
|
import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts";
|
||||||
|
|
||||||
|
const withAdminEnv = (
|
||||||
|
dids: string,
|
||||||
|
fn: () => Promise<void> | void,
|
||||||
|
): () => Promise<void> => {
|
||||||
|
return async () => {
|
||||||
|
const previous = Deno.env.get("ADMIN_DIDS");
|
||||||
|
Deno.env.set("ADMIN_DIDS", dids);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) Deno.env.delete("ADMIN_DIDS");
|
||||||
|
else Deno.env.set("ADMIN_DIDS", previous);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const withOpenSearchEnv = (fn: () => Promise<void> | void) => {
|
||||||
|
return async () => {
|
||||||
|
const previous = Deno.env.get("OPENSEARCH_URL");
|
||||||
|
Deno.env.set("OPENSEARCH_URL", "http://opensearch.test");
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) Deno.env.delete("OPENSEARCH_URL");
|
||||||
|
else Deno.env.set("OPENSEARCH_URL", previous);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"GET /health returns 200 with status ok",
|
||||||
|
withTestDb(async () => {
|
||||||
|
const res = await requestApp(createApp(), { path: "/health" });
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(res.json(), { status: "ok" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"GET /notes paginates rkey desc and honors cursor + limit",
|
||||||
|
withTestDb(async () => {
|
||||||
|
for (const rkey of ["a", "b", "c", "d", "e"]) {
|
||||||
|
seedNote({ rkey });
|
||||||
|
}
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const first = await requestApp(app, { path: "/notes?limit=3" });
|
||||||
|
assertEquals(first.status, 200);
|
||||||
|
const firstBody = first.json() as {
|
||||||
|
notes: { rkey: string }[];
|
||||||
|
cursor?: string;
|
||||||
|
};
|
||||||
|
assertEquals(firstBody.notes.map((n) => n.rkey), ["e", "d", "c"]);
|
||||||
|
assertEquals(firstBody.cursor, "c");
|
||||||
|
|
||||||
|
const second = await requestApp(app, {
|
||||||
|
path: `/notes?limit=3&cursor=${firstBody.cursor}`,
|
||||||
|
});
|
||||||
|
const secondBody = second.json() as { notes: { rkey: string }[] };
|
||||||
|
assertEquals(secondBody.notes.map((n) => n.rkey), ["b", "a"]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"POST /notes/feed rejects empty dids and accepts a valid list",
|
||||||
|
withTestDb(async () => {
|
||||||
|
seedNote({ did: "did:plc:alice", rkey: "rk1" });
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const empty = await requestApp(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/notes/feed",
|
||||||
|
body: { dids: [] },
|
||||||
|
});
|
||||||
|
assertEquals(empty.status, 400);
|
||||||
|
|
||||||
|
const notArray = await requestApp(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/notes/feed",
|
||||||
|
body: { dids: "did:plc:alice" },
|
||||||
|
});
|
||||||
|
assertEquals(notArray.status, 400);
|
||||||
|
|
||||||
|
const ok = await requestApp(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/notes/feed",
|
||||||
|
body: { dids: ["did:plc:alice"] },
|
||||||
|
});
|
||||||
|
assertEquals(ok.status, 200);
|
||||||
|
const body = ok.json() as { notes: { rkey: string }[] };
|
||||||
|
assertEquals(body.notes.length, 1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"POST /:did/webhooks: 401 without auth, 403 on DID mismatch, 201 inserts create+delete by default",
|
||||||
|
withTestDb(async () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const noAuth = await requestApp(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/did:plc:alice/webhooks",
|
||||||
|
body: { method: "POST", url: "https://hook.example/x" },
|
||||||
|
});
|
||||||
|
assertEquals(noAuth.status, 401);
|
||||||
|
|
||||||
|
const mismatch = stubAuthenticate("did:plc:bob");
|
||||||
|
try {
|
||||||
|
const res = await requestApp(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/did:plc:alice/webhooks",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
body: { method: "POST", url: "https://hook.example/x" },
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 403);
|
||||||
|
} finally {
|
||||||
|
mismatch.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const owned = stubAuthenticate("did:plc:alice");
|
||||||
|
try {
|
||||||
|
const res = await requestApp(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/did:plc:alice/webhooks",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
body: { method: "POST", url: "https://hook.example/x" },
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 201);
|
||||||
|
const created = res.json() as { verb: string }[];
|
||||||
|
const verbs = created.map((w) => w.verb).sort();
|
||||||
|
assertEquals(verbs, ["create", "delete"]);
|
||||||
|
} finally {
|
||||||
|
owned.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"POST /:did/webhooks rejects unknown verb with 400",
|
||||||
|
withTestDb(async () => {
|
||||||
|
const auth = stubAuthenticate("did:plc:alice");
|
||||||
|
try {
|
||||||
|
const res = await requestApp(createApp(), {
|
||||||
|
method: "POST",
|
||||||
|
path: "/did:plc:alice/webhooks",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
body: {
|
||||||
|
method: "POST",
|
||||||
|
url: "https://hook.example/x",
|
||||||
|
verb: "destroy",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 400);
|
||||||
|
} finally {
|
||||||
|
auth.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"DELETE /:did/webhooks/:id: 400 on non-positive, 404 on unknown, 204 on success",
|
||||||
|
withTestDb(async () => {
|
||||||
|
const created = addWebhookSubscription({
|
||||||
|
did: "did:plc:alice",
|
||||||
|
method: "POST",
|
||||||
|
url: "https://hook.example/x",
|
||||||
|
verb: "create",
|
||||||
|
});
|
||||||
|
const auth = stubAuthenticate("did:plc:alice");
|
||||||
|
const app = createApp();
|
||||||
|
try {
|
||||||
|
const bad = await requestApp(app, {
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/did:plc:alice/webhooks/0",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
});
|
||||||
|
assertEquals(bad.status, 400);
|
||||||
|
|
||||||
|
const missing = await requestApp(app, {
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/did:plc:alice/webhooks/9999",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
});
|
||||||
|
assertEquals(missing.status, 404);
|
||||||
|
|
||||||
|
const ok = await requestApp(app, {
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/did:plc:alice/webhooks/${created.id}`,
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
});
|
||||||
|
assertEquals(ok.status, 204);
|
||||||
|
assertEquals(listWebhooksByDid("did:plc:alice").length, 0);
|
||||||
|
} finally {
|
||||||
|
auth.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"GET /admin/webhooks: 403 for non-admin, 200 when ADMIN_DIDS contains caller",
|
||||||
|
withAdminEnv(
|
||||||
|
"did:plc:admin",
|
||||||
|
withTestDb(async () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const nonAdmin = stubAuthenticate("did:plc:other");
|
||||||
|
try {
|
||||||
|
const res = await requestApp(app, {
|
||||||
|
path: "/admin/webhooks",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 403);
|
||||||
|
} finally {
|
||||||
|
nonAdmin.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = stubAuthenticate("did:plc:admin");
|
||||||
|
try {
|
||||||
|
const res = await requestApp(app, {
|
||||||
|
path: "/admin/webhooks",
|
||||||
|
headers: { Authorization: "Bearer fake" },
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(Array.isArray(res.json()), true);
|
||||||
|
} finally {
|
||||||
|
admin.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"GET /search: 400 on blank q, forwards discoverableOnly:true to opensearch",
|
||||||
|
withOpenSearchEnv(
|
||||||
|
withTestDb(async () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const blank = await requestApp(app, { path: "/search?q=" });
|
||||||
|
assertEquals(blank.status, 400);
|
||||||
|
|
||||||
|
const stub = installFetchStub(() => jsonResponse({ hits: { hits: [] } }));
|
||||||
|
try {
|
||||||
|
const res = await requestApp(app, { path: "/search?q=hello" });
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(stub.calls.length, 1);
|
||||||
|
const sent = JSON.parse(stub.calls[0].body) as {
|
||||||
|
query: { bool: { filter: Record<string, unknown>[] } };
|
||||||
|
};
|
||||||
|
assertEquals(sent.query.bool.filter, [
|
||||||
|
{ term: { discoverable: true } },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"OPTIONS preflight returns 204 with CORS headers",
|
||||||
|
withTestDb(async () => {
|
||||||
|
const res = await requestApp(createApp(), {
|
||||||
|
method: "OPTIONS",
|
||||||
|
path: "/notes",
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 204);
|
||||||
|
assertEquals(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||||
|
assertEquals(
|
||||||
|
res.headers.get("Access-Control-Allow-Methods")?.includes("POST"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
res.headers.get("Access-Control-Allow-Headers")?.includes(
|
||||||
|
"Authorization",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user