Skip to content

Commit 8d2c910

Browse files
wilhel1812OpenCode
andauthored
feat: scope deep links by username (#816)
Co-authored-by: OpenCode <opencode@linksim.local>
1 parent f738251 commit 8d2c910

13 files changed

Lines changed: 212 additions & 86 deletions

docs/deep-links.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ LinkSim supports shareable deep links that link directly to a specific simulatio
66

77
| Scenario | URL Format | Example |
88
|----------|------------|---------|
9-
| **Simulation only** | `/<simulation>` | `/Blefjell` |
10-
| **Single site** | `/<simulation>/<site>` | `/Blefjell/Fyrisjøen` |
11-
| **Multi-site** | `/<simulation>/<site1>+<site2>+<site3>` | `/Blefjell/Fyrisjøen+HOEG-ROUTER` |
12-
| **Link** | `/<simulation>/<site1>~<site2>` | `/Blefjell/Fyrisjøen~HOEG-ROUTER` |
9+
| **Simulation only** | `/<username>/<simulation>` | `/Alice/Blefjell` |
10+
| **Single site** | `/<username>/<simulation>/<site>` | `/Alice/Blefjell/Fyrisjøen` |
11+
| **Multi-site** | `/<username>/<simulation>/<site1>+<site2>+<site3>` | `/Alice/Blefjell/Fyrisjøen+HOEG-ROUTER` |
12+
| **Link** | `/<username>/<simulation>/<site1>~<site2>` | `/Alice/Blefjell/Fyrisjøen~HOEG-ROUTER` |
1313

1414
## Features
1515

@@ -19,8 +19,9 @@ LinkSim supports shareable deep links that link directly to a specific simulatio
1919
- No URL encoding required for special characters
2020

2121
### Case Handling
22-
- URLs preserve original case (e.g., `/Blefjell` not `/blefjell`)
22+
- URLs preserve original case (e.g., `/Alice/Blefjell` not `/alice/blefjell`)
2323
- Matching is case-insensitive using canonical slug comparison
24+
- Simulation names are resolved inside the owner username namespace, so different users can use the same Simulation name.
2425

2526
### Delimiters
2627
- **Multi-site selection**: `+` between site names
@@ -45,6 +46,8 @@ Old-style deep links using query parameters are still supported:
4546

4647
When accessed, these links will load the simulation but may not preserve site/link selection (legacy limitation).
4748

49+
The previous path-only format `/<simulation>` is no longer a valid deep link because path links now require an owner username segment.
50+
4851
## Generating Deep Links
4952

5053
Deep links are automatically generated when using the Share functionality in the app. The appropriate format is chosen based on current selection state.

functions/_lib/db.sharedSimulation.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,10 @@ class FakeDb {
133133
}
134134
if (sql.includes("SELECT id FROM simulations WHERE lower(name) = lower(?)")) {
135135
const name = String(bound[0] ?? "").trim().toLowerCase();
136-
const id = String(bound[1] ?? "");
136+
const ownerUserId = String(bound[1] ?? "");
137+
const id = String(bound[2] ?? "");
137138
for (const row of this.simulations.values()) {
138-
if (String(row.name ?? "").trim().toLowerCase() === name && row.id !== id) {
139+
if (String(row.name ?? "").trim().toLowerCase() === name && row.owner_user_id === ownerUserId && row.id !== id) {
139140
return { id: row.id };
140141
}
141142
}

functions/_lib/db.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,12 @@ export const updateUserProfile = async (
803803
if (!nextEmail) throw new Error("Email is required and must be valid.");
804804
if (nextAvatar === null) throw new Error("Profile picture must be a valid http(s) URL.");
805805

806+
const duplicateUser = await env.DB
807+
.prepare("SELECT id FROM users WHERE lower(username) = lower(?) AND id != ? LIMIT 1")
808+
.bind(nextName, userId)
809+
.first<{ id: string }>();
810+
if (duplicateUser?.id) throw new Error("Username is already in use.");
811+
806812
await env.DB.prepare(
807813
`UPDATE users
808814
SET username = ?,
@@ -1255,10 +1261,11 @@ const upsertOwnedResource = async (
12551261
`SELECT id
12561262
FROM simulations
12571263
WHERE lower(name) = lower(?)
1264+
AND owner_user_id = ?
12581265
AND id != ?
12591266
LIMIT 1`,
12601267
)
1261-
.bind(name, id)
1268+
.bind(name, ownerId, id)
12621269
.first<{ id: string }>();
12631270
if (duplicate?.id) {
12641271
return { ok: false, reason: "simulation_name_taken" };
@@ -1963,9 +1970,60 @@ export const resolveSimulationIdBySlug = async (
19631970
return null;
19641971
};
19651972

1973+
export const resolveUserIdByUsernameSegment = async (env: Env, username: string): Promise<string | null> => {
1974+
await ensureSchema(env);
1975+
const slug = slugifyName(username);
1976+
const canonicalKey = canonicalizeSimulationLookupKey(username);
1977+
if (!slug && !canonicalKey) return null;
1978+
const rows = await env.DB.prepare("SELECT id, username FROM users LIMIT 8000").all<{ id: string; username: string }>();
1979+
for (const row of rows.results) {
1980+
const name = row.username ?? "";
1981+
if (slug && slugifyName(name) === slug) return row.id;
1982+
if (canonicalKey && canonicalizeSimulationLookupKey(name) === canonicalKey) return row.id;
1983+
}
1984+
return null;
1985+
};
1986+
1987+
export const resolveSimulationIdByOwnerSlug = async (
1988+
env: Env,
1989+
username: string,
1990+
simulationSlug: string,
1991+
): Promise<string | null> => {
1992+
await ensureSchema(env);
1993+
const ownerId = await resolveUserIdByUsernameSegment(env, username);
1994+
if (!ownerId) return null;
1995+
const slug = slugifyName(simulationSlug);
1996+
const canonicalKey = canonicalizeSimulationLookupKey(simulationSlug);
1997+
if (!slug && !canonicalKey) return null;
1998+
const rows = await env.DB
1999+
.prepare("SELECT id, name, payload_json FROM simulations WHERE owner_user_id = ? LIMIT 8000")
2000+
.bind(ownerId)
2001+
.all<{ id: string; name: string; payload_json: string }>();
2002+
for (const row of rows.results) {
2003+
const nameSlug = slugifyName(row.name);
2004+
if (slug && nameSlug === slug) return row.id;
2005+
if (canonicalKey && canonicalizeSimulationLookupKey(row.name) === canonicalKey) return row.id;
2006+
try {
2007+
const payload = JSON.parse(row.payload_json) as { slug?: unknown; slugAliases?: unknown };
2008+
const payloadSlugRaw = typeof payload.slug === "string" ? payload.slug : "";
2009+
const payloadSlug = slugifyName(payloadSlugRaw);
2010+
if (slug && payloadSlug && payloadSlug === slug) return row.id;
2011+
if (canonicalKey && payloadSlugRaw && canonicalizeSimulationLookupKey(payloadSlugRaw) === canonicalKey) return row.id;
2012+
const aliases = Array.isArray(payload.slugAliases)
2013+
? payload.slugAliases.filter((alias): alias is string => typeof alias === "string" && alias.trim().length > 0)
2014+
: [];
2015+
if (slug && aliases.some((alias) => slugifyName(alias) === slug)) return row.id;
2016+
if (canonicalKey && aliases.some((alias) => canonicalizeSimulationLookupKey(alias) === canonicalKey)) return row.id;
2017+
} catch {
2018+
// ignore invalid payload rows
2019+
}
2020+
}
2021+
return null;
2022+
};
2023+
19662024
export const fetchPublicSimulationBundle = async (
19672025
env: Env,
1968-
options: { simulationId?: string; simulationSlug?: string; actorId?: string | null },
2026+
options: { simulationId?: string; username?: string; simulationSlug?: string; actorId?: string | null },
19692027
): Promise<
19702028
| { status: "missing" | "forbidden" }
19712029
| {
@@ -1978,7 +2036,9 @@ export const fetchPublicSimulationBundle = async (
19782036
await ensureSchema(env);
19792037
const resolvedId =
19802038
(options.simulationId && options.simulationId.trim()) ||
1981-
(options.simulationSlug ? await resolveSimulationIdBySlug(env, options.simulationSlug) : null);
2039+
(options.username && options.simulationSlug
2040+
? await resolveSimulationIdByOwnerSlug(env, options.username, options.simulationSlug)
2041+
: null);
19822042
if (!resolvedId) return { status: "missing" };
19832043

19842044
const simulationRow = await env.DB

functions/api/deep-link-status.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@ const {
55
ensureUserMock,
66
fetchUserProfileMock,
77
resolveSimulationAccessForUserMock,
8-
resolveSimulationIdBySlugMock,
8+
resolveSimulationIdByOwnerSlugMock,
99
} = vi.hoisted(() => ({
1010
verifyAuthMock: vi.fn(),
1111
ensureUserMock: vi.fn(),
1212
fetchUserProfileMock: vi.fn(),
1313
resolveSimulationAccessForUserMock: vi.fn(),
14-
resolveSimulationIdBySlugMock: vi.fn(),
14+
resolveSimulationIdByOwnerSlugMock: vi.fn(),
1515
}));
1616

1717
vi.mock("../_lib/auth", () => ({ verifyAuth: verifyAuthMock }));
1818
vi.mock("../_lib/db", () => ({
1919
ensureUser: ensureUserMock,
2020
fetchUserProfile: fetchUserProfileMock,
2121
resolveSimulationAccessForUser: resolveSimulationAccessForUserMock,
22-
resolveSimulationIdBySlug: resolveSimulationIdBySlugMock,
22+
resolveSimulationIdByOwnerSlug: resolveSimulationIdByOwnerSlugMock,
2323
}));
2424

2525
import { onRequestGet } from "./deep-link-status";
@@ -33,7 +33,7 @@ beforeEach(() => {
3333
ensureUserMock.mockResolvedValue(undefined);
3434
fetchUserProfileMock.mockResolvedValue({ id: "u1", isAdmin: false, isModerator: false, accountState: "approved" });
3535
resolveSimulationAccessForUserMock.mockResolvedValue("ok");
36-
resolveSimulationIdBySlugMock.mockResolvedValue(null);
36+
resolveSimulationIdByOwnerSlugMock.mockResolvedValue(null);
3737
});
3838

3939
describe("api/deep-link-status", () => {
@@ -56,10 +56,11 @@ describe("api/deep-link-status", () => {
5656
await expect(res.json()).resolves.toEqual({ status: "forbidden", simulationId: "sim-2", authenticated: true });
5757
});
5858

59-
it("resolves slug to simulation id", async () => {
60-
resolveSimulationIdBySlugMock.mockResolvedValueOnce("sim-abc");
61-
const res = await onRequestGet(mkCtx(new Request("https://example.test/api/deep-link-status?slug=my-sim")));
59+
it("resolves username-scoped slug to simulation id", async () => {
60+
resolveSimulationIdByOwnerSlugMock.mockResolvedValueOnce("sim-abc");
61+
const res = await onRequestGet(mkCtx(new Request("https://example.test/api/deep-link-status?username=Owner&slug=my-sim")));
6262
expect(res.status).toBe(200);
63+
expect(resolveSimulationIdByOwnerSlugMock).toHaveBeenCalledWith(expect.anything(), "Owner", "my-sim");
6364
await expect(res.json()).resolves.toEqual({ status: "ok", simulationId: "sim-abc", authenticated: true });
6465
});
6566
});

functions/api/deep-link-status.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { verifyAuth } from "../_lib/auth";
2-
import { ensureUser, fetchUserProfile, resolveSimulationAccessForUser, resolveSimulationIdBySlug } from "../_lib/db";
2+
import { ensureUser, fetchUserProfile, resolveSimulationAccessForUser, resolveSimulationIdByOwnerSlug } from "../_lib/db";
33
import { errorResponse, handleOptions, json, withCors } from "../_lib/http";
44
import type { Env } from "../_lib/types";
55

@@ -24,10 +24,11 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
2424
}
2525

2626
const url = new URL(request.url);
27+
const username = (url.searchParams.get("username") ?? "").trim();
2728
const simulationSlug = (url.searchParams.get("slug") ?? "").trim();
2829
let simulationId = (url.searchParams.get("sim") ?? "").trim();
29-
if (!simulationId && simulationSlug) {
30-
simulationId = (await resolveSimulationIdBySlug(env, simulationSlug)) ?? "";
30+
if (!simulationId && username && simulationSlug) {
31+
simulationId = (await resolveSimulationIdByOwnerSlug(env, username, simulationSlug)) ?? "";
3132
}
3233
if (!simulationId) {
3334
return withCors(request, json({ status: "missing", authenticated }));

functions/api/public-simulation.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ describe("api/public-simulation", () => {
6060
);
6161
});
6262

63+
it("passes username-scoped slug lookup parameters", async () => {
64+
await onRequestGet(mkCtx(new Request("https://example.test/api/public-simulation?username=Owner&slug=my-sim")));
65+
expect(fetchPublicSimulationBundleMock).toHaveBeenCalledWith(
66+
expect.anything(),
67+
expect.objectContaining({ username: "Owner", simulationSlug: "my-sim" }),
68+
);
69+
});
70+
6371
it("returns 403 when bundle status is forbidden", async () => {
6472
fetchPublicSimulationBundleMock.mockResolvedValue({ status: "forbidden" });
6573
const res = await onRequestGet(mkCtx(new Request("https://example.test/api/public-simulation?sim=sim-1")));

functions/api/public-simulation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
1111
try {
1212
const url = new URL(request.url);
1313
const simulationId = (url.searchParams.get("sim") ?? "").trim();
14+
const username = (url.searchParams.get("username") ?? "").trim();
1415
const simulationSlug = (url.searchParams.get("slug") ?? "").trim();
15-
if (!simulationId && !simulationSlug) {
16-
return withCors(request, json({ error: "Missing simulation id or slug" }, { status: 400, headers: NO_STORE_HEADERS }));
16+
if (!simulationId && (!username || !simulationSlug)) {
17+
return withCors(request, json({ error: "Missing simulation id or username-scoped slug" }, { status: 400, headers: NO_STORE_HEADERS }));
1718
}
1819

1920
const auth = await verifyAuth(request, env).catch(() => null);
2021
const actorId = auth?.userId ?? null;
2122

2223
const bundle = await fetchPublicSimulationBundle(env, {
2324
simulationId: simulationId || undefined,
25+
username: username || undefined,
2426
simulationSlug: simulationSlug || undefined,
2527
actorId,
2628
});

src/components/AppShell.deeplink.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const hoisted = vi.hoisted(() => {
1818
? payload.simulationPresets.map((preset) => ({
1919
id: preset.id,
2020
name: preset.name,
21+
ownerUserId: (preset as { ownerUserId?: string }).ownerUserId,
22+
createdByName: (preset as { createdByName?: string }).createdByName,
2123
visibility: "shared",
2224
snapshot: { sites: Array.isArray(preset.snapshot?.sites) ? preset.snapshot.sites : [] },
2325
}))
@@ -251,6 +253,8 @@ describe("AppShell deeplink cold-load flow", () => {
251253
{
252254
id: "sim-mmtk88wx-2didtk",
253255
name: "Høgevarde hyttefelt",
256+
ownerUserId: "user-1",
257+
createdByName: "Owner",
254258
visibility: "shared",
255259
snapshot: { sites: [] },
256260
},
@@ -281,7 +285,7 @@ describe("AppShell deeplink cold-load flow", () => {
281285
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0));
282286
vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));
283287

284-
window.history.replaceState(null, "", "/H%C3%B8gevarde-hyttefelt/Fyrisj%C3%B8vegen");
288+
window.history.replaceState(null, "", "/Owner/H%C3%B8gevarde-hyttefelt/Fyrisj%C3%B8vegen");
285289
});
286290

287291
it("builds direct auth-start navigation for explicit sign-in clicks", () => {

src/components/AppShell.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,12 @@ export function AppShell() {
433433

434434
const currentShareLink = useMemo(() => {
435435
if (!activeSimulation) return "";
436+
const ownerUserId = (activeSimulation as { ownerUserId?: string }).ownerUserId ?? "";
437+
const ownerUsername =
438+
ownerUserId && currentUser?.id === ownerUserId
439+
? currentUser.username
440+
: shareDirectory.find((user) => user.id === ownerUserId)?.username ||
441+
((activeSimulation as { createdByName?: string }).createdByName ?? currentUser?.username ?? "");
436442
const simulationSlug = activeSimulation.name;
437443
const selectedSites = selectedSiteIds
438444
.map((id) => sites.find((site) => site.id === id))
@@ -460,6 +466,7 @@ export function AppShell() {
460466
return buildDeepLinkUrl(
461467
{
462468
version: 2,
469+
username: ownerUsername,
463470
simulationId: activeSimulation.id,
464471
simulationSlug,
465472
...(selectedLinkSlugs ? { selectedLinkSlugs } : {}),
@@ -468,7 +475,7 @@ export function AppShell() {
468475
window.location.origin,
469476
"/",
470477
);
471-
}, [activeSimulation, selectedLink, selectedSiteIds, sites]);
478+
}, [activeSimulation, currentUser, selectedLink, selectedSiteIds, shareDirectory, sites]);
472479

473480
useEffect(() => {
474481
if (
@@ -495,7 +502,13 @@ export function AppShell() {
495502
return;
496503
}
497504
} else if (activeSimulation) {
498-
targetPath = buildDeepLinkPathname(activeSimulation.name, {
505+
const ownerUserId = (activeSimulation as { ownerUserId?: string }).ownerUserId ?? "";
506+
const ownerUsername =
507+
ownerUserId && currentUser?.id === ownerUserId
508+
? currentUser.username
509+
: shareDirectory.find((user) => user.id === ownerUserId)?.username ||
510+
((activeSimulation as { createdByName?: string }).createdByName ?? currentUser?.username ?? "");
511+
targetPath = buildDeepLinkPathname(ownerUsername, activeSimulation.name, {
499512
selectedSiteSlugs: selectedSiteIds
500513
.map((id) => sites.find((site) => site.id === id)?.name)
501514
.filter((name): name is string => Boolean(name)),
@@ -505,7 +518,7 @@ export function AppShell() {
505518
if (currentPath !== targetPath) {
506519
window.history.replaceState(null, "", targetPath);
507520
}
508-
}, [currentShareLink, activeSimulation, selectedSiteIds, sites, deepLinkParse.ok]);
521+
}, [currentShareLink, activeSimulation, currentUser, selectedSiteIds, shareDirectory, sites, deepLinkParse.ok]);
509522

510523
useEffect(() => {
511524
const root = document.documentElement;
@@ -719,10 +732,11 @@ export function AppShell() {
719732
return;
720733
}
721734
if (deepLinkParse.ok && !isLocalRuntime) {
722-
const deepLinkStatus = await fetchDeepLinkStatus({
723-
simulationId: deepLinkParse.payload.simulationId,
724-
simulationSlug: deepLinkParse.payload.simulationSlug,
725-
});
735+
const deepLinkStatus = await fetchDeepLinkStatus({
736+
simulationId: deepLinkParse.payload.simulationId,
737+
username: deepLinkParse.payload.username,
738+
simulationSlug: deepLinkParse.payload.simulationSlug,
739+
});
726740
if (!deepLinkStatus.authenticated) {
727741
if (!isCurrentRun()) return;
728742
authRecoveryActiveRef.current = false;
@@ -1173,6 +1187,14 @@ export function AppShell() {
11731187
.getState()
11741188
.simulationPresets.find((preset) => {
11751189
const presetSlugRaw = typeof (preset as { slug?: unknown }).slug === "string" ? String((preset as { slug?: unknown }).slug) : "";
1190+
const targetUsername = payload.username ? canonicalizeDeepLinkKey(payload.username) : "";
1191+
const ownerUserId = typeof (preset as { ownerUserId?: unknown }).ownerUserId === "string" ? String((preset as { ownerUserId?: unknown }).ownerUserId) : "";
1192+
const createdByName = typeof (preset as { createdByName?: unknown }).createdByName === "string" ? String((preset as { createdByName?: unknown }).createdByName) : "";
1193+
if (targetUsername) {
1194+
const ownerMatchesCurrent = ownerUserId && currentUser?.id === ownerUserId && canonicalizeDeepLinkKey(currentUser.username) === targetUsername;
1195+
const ownerMatchesCreatedBy = createdByName && canonicalizeDeepLinkKey(createdByName) === targetUsername;
1196+
if (!ownerMatchesCurrent && !ownerMatchesCreatedBy) return false;
1197+
}
11761198
const presetSlugValue = presetSlugRaw.trim() ? presetSlugRaw : preset.name;
11771199
const presetPretty = slugifyName(presetSlugValue);
11781200
const presetCanonical = canonicalizeDeepLinkKey(presetSlugValue);
@@ -1221,10 +1243,11 @@ export function AppShell() {
12211243

12221244
if (!exists && accessState === "readonly") {
12231245
try {
1224-
const publicBundle = await fetchPublicSimulationLibrary({
1225-
simulationId: resolvedSimulationId || undefined,
1226-
simulationSlug: payload.simulationSlug,
1227-
});
1246+
const publicBundle = await fetchPublicSimulationLibrary({
1247+
simulationId: resolvedSimulationId || undefined,
1248+
username: payload.username,
1249+
simulationSlug: payload.simulationSlug,
1250+
});
12281251
importLibraryData(
12291252
{
12301253
siteLibrary: publicBundle.siteLibrary as Parameters<typeof importLibraryData>[0]["siteLibrary"],
@@ -1250,6 +1273,7 @@ export function AppShell() {
12501273
try {
12511274
const status = await fetchDeepLinkStatus({
12521275
simulationId: resolvedSimulationId || undefined,
1276+
username: payload.username,
12531277
simulationSlug: payload.simulationSlug,
12541278
});
12551279
if (status.status === "forbidden") {
@@ -1391,6 +1415,7 @@ export function AppShell() {
13911415
})();
13921416
}, [
13931417
accessState,
1418+
currentUser,
13941419
deepLinkParse,
13951420
importLibraryData,
13961421
isInitializing,

0 commit comments

Comments
 (0)