feat: add share link service, API routes, and short URL redirect

Create share.service.ts with token generation (128-bit base64url),
CRUD operations, validation, and visibility transition side effects.
Add share endpoints under /api/setups/:id/shares, shared access at
/api/shared/:token, and /s/:token short URL redirect.

Plan: 32-02 (Setup Sharing System - Share Link Backend)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:59:39 +02:00
parent 7a696f39a5
commit da159d10b8
6 changed files with 507 additions and 1 deletions

View File

@@ -26,6 +26,9 @@ import { setupRoutes } from "./routes/setups.ts";
import { tagRoutes } from "./routes/tags.ts";
import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts";
import { getSetupWithItemsById } from "./services/setup.service.ts";
import { validateShareToken } from "./services/share.service.ts";
import { withImageUrls } from "./services/storage.service.ts";
// Seed default data on startup
await seedDefaults();
@@ -163,6 +166,27 @@ app.use("/api/users/:id/profile", async (c, next) => {
return next();
});
// Shared setup access via token (no auth required)
app.get("/api/shared/:token", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const result = await validateShareToken(db, token);
if (!result) return c.json({ error: "Not found" }, 404);
const setup = await getSetupWithItemsById(db, result.setupId);
if (!setup) return c.json({ error: "Not found" }, 404);
const enrichedItems = await withImageUrls(setup.items);
return c.json({ ...setup, items: enrichedItems });
});
// Short share URL redirect (no auth required — before SPA catch-all)
app.get("/s/:token", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const result = await validateShareToken(db, token);
if (!result) return c.redirect("/", 302);
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
});
// Auth middleware for all data routes (userId must be available for per-user scoping)
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
@@ -178,6 +202,9 @@ app.use("/api/*", async (c, next) => {
// Skip public tags endpoint (GET /api/tags)
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
return next();
// Skip shared setup access (GET /api/shared/:token)
if (c.req.path.startsWith("/api/shared/") && c.req.method === "GET")
return next();
// Skip public discovery endpoints (GET /api/discovery/*)
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
return next();

View File

@@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
createSetupSchema,
createShareLinkSchema,
syncSetupItemsSchema,
updateClassificationSchema,
updateSetupSchema,
@@ -18,6 +19,11 @@ import {
updateItemClassification,
updateSetup,
} from "../services/setup.service.ts";
import {
createShareLink,
getShareLinks,
revokeShareLink,
} from "../services/share.service.ts";
import { withImageUrls } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } };
@@ -125,4 +131,43 @@ app.delete("/:id/items/:itemId", async (c) => {
return c.json({ success: true });
});
// Share Links
app.post(
"/:id/shares",
zValidator("json", createShareLinkSchema),
async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const setupId = parseId(c.req.param("id"));
if (!setupId) return c.json({ error: "Invalid setup ID" }, 400);
const data = c.req.valid("json");
const share = await createShareLink(db, userId, setupId, {
expiresInDays: data.expiresInDays,
});
if (!share) return c.json({ error: "Setup not found" }, 404);
return c.json(share, 201);
},
);
app.get("/:id/shares", async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const setupId = parseId(c.req.param("id"));
if (!setupId) return c.json({ error: "Invalid setup ID" }, 400);
const links = await getShareLinks(db, userId, setupId);
if (!links) return c.json({ error: "Setup not found" }, 404);
return c.json(links);
});
app.delete("/:id/shares/:shareId", async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const shareId = parseId(c.req.param("shareId"));
if (!shareId) return c.json({ error: "Invalid share ID" }, 400);
const share = await revokeShareLink(db, userId, shareId);
if (!share) return c.json({ error: "Share not found" }, 404);
return c.json(share);
});
export { app as setupRoutes };

View File

@@ -117,6 +117,57 @@ export async function getSetupWithItems(
return { ...setup, items: itemList };
}
/**
* Get setup with items by setup ID only (no user/visibility check).
* Used for share-token-authorized access where the token already authorizes viewing.
*/
export async function getSetupWithItemsById(db: Db, setupId: number) {
const [setup] = await db.select().from(setups).where(eq(setups.id, setupId));
if (!setup) return null;
const itemList = await db
.select({
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
)`.as("name"),
weightGrams: sql<number | null>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${items.weightGrams}
)`.as("weight_grams"),
priceCents: sql<number | null>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
${items.priceCents}
)`.as("price_cents"),
quantity: items.quantity,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: sql<string | null>`COALESCE(
${items.imageFilename},
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
)`.as("image_filename"),
globalItemId: items.globalItemId,
purchasePriceCents: items.purchasePriceCents,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
classification: setupItems.classification,
})
.from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList };
}
export async function updateSetup(
db: Db,
userId: number,
@@ -124,7 +175,7 @@ export async function updateSetup(
data: UpdateSetup,
) {
const [existing] = await db
.select({ id: setups.id })
.select({ id: setups.id, visibility: setups.visibility })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!existing) return null;
@@ -143,6 +194,25 @@ export async function updateSetup(
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)))
.returning();
// Handle visibility transition side effects for share links
if (
data.visibility !== undefined &&
data.visibility !== existing.visibility
) {
const { deactivateShareLinks, reactivateShareLinks } = await import(
"./share.service.ts"
);
if (data.visibility === "private" && existing.visibility !== "private") {
await deactivateShareLinks(db, setupId);
} else if (
data.visibility !== "private" &&
existing.visibility === "private"
) {
await reactivateShareLinks(db, setupId);
}
}
return row;
}

View File

@@ -0,0 +1,136 @@
import { randomBytes } from "node:crypto";
import { and, eq, gt, isNotNull, isNull, or } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { setups, shares } from "../../db/schema.ts";
type Db = typeof prodDb;
/**
* Create a share link for a setup.
* Returns null if the setup doesn't belong to the user.
*/
export async function createShareLink(
db: Db,
userId: number,
setupId: number,
options: { expiresInDays: number | null },
) {
// Verify ownership
const [setup] = await db
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
const token = randomBytes(16).toString("base64url");
const expiresAt = options.expiresInDays
? new Date(Date.now() + options.expiresInDays * 86400000)
: null;
const [row] = await db
.insert(shares)
.values({
setupId,
token,
permission: "read",
expiresAt,
})
.returning();
return row;
}
/**
* List all share links for a setup owned by the user.
* Returns links ordered by createdAt desc.
*/
export async function getShareLinks(db: Db, userId: number, setupId: number) {
// Verify ownership
const [setup] = await db
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
return db
.select()
.from(shares)
.where(eq(shares.setupId, setupId))
.orderBy(shares.createdAt);
}
/**
* Revoke a share link. Returns null if the share doesn't belong to the user's setup.
*/
export async function revokeShareLink(db: Db, userId: number, shareId: number) {
// Join shares with setups to verify ownership
const [existing] = await db
.select({ shareId: shares.id, setupId: shares.setupId })
.from(shares)
.innerJoin(setups, eq(shares.setupId, setups.id))
.where(and(eq(shares.id, shareId), eq(setups.userId, userId)));
if (!existing) return null;
const [row] = await db
.update(shares)
.set({ revokedAt: new Date() })
.where(eq(shares.id, shareId))
.returning();
return row;
}
/**
* Validate a share token. Returns { setupId, permission } if valid, null otherwise.
* Invalid = nonexistent, revoked, or expired.
*/
export async function validateShareToken(db: Db, token: string) {
const [row] = await db
.select({
setupId: shares.setupId,
permission: shares.permission,
expiresAt: shares.expiresAt,
})
.from(shares)
.where(and(eq(shares.token, token), isNull(shares.revokedAt)));
if (!row) return null;
// Check expiration
if (row.expiresAt && row.expiresAt < new Date()) return null;
return { setupId: row.setupId, permission: row.permission };
}
/**
* Deactivate all active share links for a setup.
* Called when visibility transitions to "private".
*/
export async function deactivateShareLinks(db: Db, setupId: number) {
await db
.update(shares)
.set({ revokedAt: new Date() })
.where(and(eq(shares.setupId, setupId), isNull(shares.revokedAt)));
}
/**
* Reactivate share links for a setup.
* Called when visibility transitions from "private" back to "link" or "public".
* Clears revokedAt on all non-expired shares for the setup.
*/
export async function reactivateShareLinks(db: Db, setupId: number) {
// Per D-03: clear revokedAt on all non-expired shares for the setup.
// This reactivates everything including manually revoked — acceptable UX
// since user explicitly chose to re-enable sharing.
await db
.update(shares)
.set({ revokedAt: null })
.where(
and(
eq(shares.setupId, setupId),
isNotNull(shares.revokedAt),
or(isNull(shares.expiresAt), gt(shares.expiresAt, new Date())),
),
);
}

View File

@@ -96,6 +96,12 @@ export const updateSetupSchema = z.object({
visibility: z.enum(["private", "link", "public"]).optional(),
});
export const createShareLinkSchema = z.object({
expiresInDays: z
.union([z.literal(7), z.literal(14), z.literal(30), z.null()])
.default(14),
});
export const syncSetupItemsSchema = z.object({
itemIds: z.array(z.number().int().positive()),
});

View File

@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm";
import * as schema from "../../src/db/schema.ts";
import {
createSetup,
updateSetup,
} from "../../src/server/services/setup.service.ts";
import {
createShareLink,
deactivateShareLinks,
getShareLinks,
reactivateShareLinks,
revokeShareLink,
validateShareToken,
} from "../../src/server/services/share.service.ts";
import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
describe("Share Service", () => {
let db: TestDb["db"];
let userId: number;
let setupId: number;
beforeEach(async () => {
const testData = await createTestDb();
db = testData.db;
userId = testData.userId;
const setup = await createSetup(db, userId, {
name: "Test Setup",
visibility: "link",
});
setupId = setup.id;
});
describe("createShareLink", () => {
it("creates a share link with token and expiration", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: 7,
});
expect(share).not.toBeNull();
expect(share!.token).toBeTruthy();
expect(share!.token.length).toBeGreaterThanOrEqual(20);
expect(share!.setupId).toBe(setupId);
expect(share!.permission).toBe("read");
expect(share!.expiresAt).not.toBeNull();
expect(share!.revokedAt).toBeNull();
});
it("creates a share link with no expiration when null", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: null,
});
expect(share).not.toBeNull();
expect(share!.expiresAt).toBeNull();
});
it("returns null for non-owned setup", async () => {
const otherUserId = await createSecondTestUser(db);
const share = await createShareLink(db, otherUserId, setupId, {
expiresInDays: 14,
});
expect(share).toBeNull();
});
it("generates unique URL-safe tokens", async () => {
const share1 = await createShareLink(db, userId, setupId, {
expiresInDays: 14,
});
const share2 = await createShareLink(db, userId, setupId, {
expiresInDays: 14,
});
expect(share1!.token).not.toBe(share2!.token);
// base64url should not contain +, /, or =
expect(share1!.token).not.toMatch(/[+/=]/);
});
});
describe("getShareLinks", () => {
it("returns all shares for a setup owned by the user", async () => {
await createShareLink(db, userId, setupId, { expiresInDays: 7 });
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
const links = await getShareLinks(db, userId, setupId);
expect(links).not.toBeNull();
expect(links!).toHaveLength(2);
});
it("returns null for non-owned setup", async () => {
const otherUserId = await createSecondTestUser(db);
const links = await getShareLinks(db, otherUserId, setupId);
expect(links).toBeNull();
});
});
describe("revokeShareLink", () => {
it("sets revokedAt on the share", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: 14,
});
const revoked = await revokeShareLink(db, userId, share!.id);
expect(revoked).not.toBeNull();
expect(revoked!.revokedAt).not.toBeNull();
});
it("returns null for non-owned share", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: 14,
});
const otherUserId = await createSecondTestUser(db);
const result = await revokeShareLink(db, otherUserId, share!.id);
expect(result).toBeNull();
});
});
describe("validateShareToken", () => {
it("returns setupId for valid token", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: 14,
});
const result = await validateShareToken(db, share!.token);
expect(result).not.toBeNull();
expect(result!.setupId).toBe(setupId);
expect(result!.permission).toBe("read");
});
it("returns null for expired token", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: 1,
});
// Manually set expiresAt to the past
await db
.update(schema.shares)
.set({ expiresAt: new Date(Date.now() - 86400000) })
.where(eq(schema.shares.id, share!.id));
const result = await validateShareToken(db, share!.token);
expect(result).toBeNull();
});
it("returns null for revoked token", async () => {
const share = await createShareLink(db, userId, setupId, {
expiresInDays: 14,
});
await revokeShareLink(db, userId, share!.id);
const result = await validateShareToken(db, share!.token);
expect(result).toBeNull();
});
it("returns null for nonexistent token", async () => {
const result = await validateShareToken(db, "nonexistent-token");
expect(result).toBeNull();
});
});
describe("deactivateShareLinks", () => {
it("sets revokedAt on all active links for a setup", async () => {
await createShareLink(db, userId, setupId, { expiresInDays: 7 });
await createShareLink(db, userId, setupId, { expiresInDays: null });
await deactivateShareLinks(db, setupId);
const links = await getShareLinks(db, userId, setupId);
for (const link of links!) {
expect(link.revokedAt).not.toBeNull();
}
});
});
describe("reactivateShareLinks", () => {
it("clears revokedAt on deactivated links", async () => {
await createShareLink(db, userId, setupId, { expiresInDays: null });
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
await deactivateShareLinks(db, setupId);
await reactivateShareLinks(db, setupId);
const links = await getShareLinks(db, userId, setupId);
for (const link of links!) {
expect(link.revokedAt).toBeNull();
}
});
});
describe("visibility transition side effects", () => {
it("deactivates links when visibility changes to private", async () => {
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
await updateSetup(db, userId, setupId, {
name: "Test Setup",
visibility: "private",
});
const links = await getShareLinks(db, userId, setupId);
for (const link of links!) {
expect(link.revokedAt).not.toBeNull();
}
});
it("reactivates links when visibility changes from private to link", async () => {
await createShareLink(db, userId, setupId, { expiresInDays: null });
// Deactivate by going private
await updateSetup(db, userId, setupId, {
name: "Test Setup",
visibility: "private",
});
// Reactivate by going back to link
await updateSetup(db, userId, setupId, {
name: "Test Setup",
visibility: "link",
});
const links = await getShareLinks(db, userId, setupId);
for (const link of links!) {
expect(link.revokedAt).toBeNull();
}
});
});
});