Skip to content

Commit

The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.

syntaqxcommitted Parentdb33362Browse files
14 files+340−220/14 viewed
+4−3
574574 2. Find the app, by name if you like.
575575 3. Select the pin on its tile. Select it again to unpin it.
576576
577−Your pins are yours alone, kept per workspace in this browser, so another
578−browser or device starts with none. There is no marketplace yet: it shows
579−as coming.
577+Your pins are yours alone and each workspace has its own. They are saved
578+to your account, so your dock is the same on every browser and device you
579+sign in on, in the order you pinned. There is no marketplace yet: it
580+shows as coming.
580581
581582 ### The sidebar
582583
+17−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { APPS, MAX_APP_PINS, appPinFromForm, appsFor, dockCookie, isPinnable, pinsIn, readDock, sidebarClosed, sidebarCookie, withPin, writeDock } from "./apps.ts";
4+import { APPS, MAX_APP_PINS, appPinFromForm, appsFor, dockCookie, isPinnable, pinsFrom, pinsIn, pinsToShow, readDock, sidebarClosed, sidebarCookie, withPin, writeDock } from "./apps.ts";
55
66 test("built-in apps are never pinned; the rest are", () => {
77 for (const app of APPS) assert.equal(isPinnable(app.key), !app.builtin, app.key);
3232 assert.ok(pinsIn(`acme:${many}`, "acme").length <= MAX_APP_PINS);
3333 });
3434
35+test("pins kept with the account keep their order and only real apps", () => {
36+ assert.deepEqual(pinsFrom(["usage", "projects", "today", "retired-app", "usage", "teams"]), ["usage", "projects", "teams"]);
37+ assert.deepEqual(pinsFrom([]), []);
38+ assert.ok(pinsFrom(Array.from({ length: 30 }, (_, n) => APPS[8 + (n % 10)]!.key)).length <= MAX_APP_PINS);
39+});
40+
41+test("the account's pins win over the cookie, which is the fallback", () => {
42+ const cookie = writeDock(null, "acme", ["projects", "usage"]);
43+ // Saved with the account, even with nothing pinned: the account's.
44+ assert.deepEqual(pinsToShow(["teams", "audit"], cookie, "acme"), ["teams", "audit"]);
45+ assert.deepEqual(pinsToShow([], cookie, "acme"), []);
46+ // Never saved, or identity could not be asked: this device's cookie.
47+ assert.deepEqual(pinsToShow(null, cookie, "acme"), ["projects", "usage"]);
48+ assert.deepEqual(pinsToShow(undefined, null, "acme"), []);
49+});
50+
3551 test("a pin form pins or unpins one app", () => {
3652 const form = (fields: Record<string, string>) => {
3753 const data = new FormData();
+29−4
88 * and the audit log. Each person pins the ones they want, and nobody sees
99 * an app they cannot use: Code's apps are for members with Code access.
1010 *
11− * Pins are each person's own, per workspace, and kept in a cookie
12− * (`g1t_dock`) so the page is drawn with the right dock from the server.
11+ * Pins are each person's own, per workspace, in the order they set, and
12+ * kept with their account by the identity service (`dock_pins`), so the
13+ * dock is the same on every device (lib/dock.server.ts). A cookie
14+ * (`g1t_dock`) keeps a copy on each device: the dock still draws from it
15+ * when identity cannot be reached, and pins from before they were kept
16+ * with the account carry over from it until the first change.
1317 * No Workers or React imports, so it can be tested under Node.
1418 */
1519
8589 return APPS.find((app) => app.key === key)!;
8690 }
8791
88−/** The cookie that keeps each person's pins, per workspace. */
92+/** The cookie that keeps this device's copy of each person's pins, per workspace. */
8993 export const DOCK_COOKIE = "g1t_dock";
9094
91−/** Most workspaces the cookie remembers pins for, and most pins in each. */
95+/**
96+ * Most workspaces the cookie remembers pins for, and most pins in each.
97+ * Identity keeps up to 24 (`MAX_DOCK_PINS`); this stays under it.
98+ */
9299 const MAX_WORKSPACES = 20;
93100 export const MAX_APP_PINS = 12;
94101
102+/**
103+ * Pins as identity keeps them, read against the apps there are: keys that
104+ * are not an app people pin (one since retired, say) are dropped, repeats
105+ * too, in the order they were set.
106+ */
107+export function pinsFrom(keys: readonly string[]): PinnableApp[] {
108+ return [...new Set(keys.filter(isPinnable))].slice(0, MAX_APP_PINS);
109+}
110+
111+/**
112+ * The pins to draw in the workspace `slug`: the account's when identity
113+ * has them (`stored`), else this device's cookie, for pins never saved to
114+ * the account or when identity could not be asked.
115+ */
116+export function pinsToShow(stored: readonly string[] | null | undefined, cookie: string | null | undefined, slug: string): PinnableApp[] {
117+ return stored ? pinsFrom(stored) : pinsIn(cookie, slug);
118+}
119+
95120 /** Every workspace's pins, from the cookie's value: `acme:projects.usage,beta:teams`. */
96121 export function readDock(value: string | null | undefined): Record<string, PinnableApp[]> {
97122 const dock: Record<string, PinnableApp[]> = {};
+36−0
1+/**
2+ * Each person's dock pins, kept with their account by identity
3+ * (`dock_pins` / `set_dock_pins`), so their dock follows them to every
4+ * device. lib/apps.ts says which apps can be pinned; identity only keeps
5+ * the list in order.
6+ */
7+import type { User } from "@g1t/contracts";
8+
9+import { type PinnableApp, pinsFrom } from "./apps";
10+import { identity } from "./services.server";
11+
12+/**
13+ * The pins `user` saved in the workspace `slug`, or null when they never
14+ * saved any there or identity could not be asked: the caller falls back
15+ * to the cookie (lib/apps.ts `pinsToShow`).
16+ */
17+export async function savedPins(user: User, slug: string): Promise<PinnableApp[] | null> {
18+ try {
19+ const stored = await identity.dockPins(user, slug);
20+ return stored ? pinsFrom(stored) : null;
21+ } catch (error) {
22+ console.error("dock: pins could not be read", error);
23+ return null;
24+ }
25+}
26+
27+/** Saves `pins` as `user`'s in `slug`, in their order; the pins as kept, or null when they could not be saved. */
28+export async function savePins(user: User, slug: string, pins: PinnableApp[]): Promise<PinnableApp[] | null> {
29+ try {
30+ const result = await identity.setDockPins(user, slug, pins);
31+ return result.ok ? pinsFrom(result.value) : null;
32+ } catch (error) {
33+ console.error("dock: pins could not be saved", error);
34+ return null;
35+ }
36+}
+12−4
3434 import { PageMain } from "./components/landmark";
3535 import { NotFound } from "./components/not-found";
3636 import { frameOf } from "./lib/chrome";
37−import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsIn, sidebarClosed } from "./lib/apps";
37+import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsToShow, sidebarClosed } from "./lib/apps";
38+import { savedPins } from "./lib/dock.server";
3839 import { StandaloneFrame } from "./components/standalone";
3940 import { billing, chat, inbox, projects, workspaceAgents } from "./lib/services.server";
4041 import { unreadTotals } from "./lib/chat";
9091 : visitorShell(params, context),
9192 registrationMode(),
9293 ]);
93− // The apps this person pinned to their dock here, from their cookie (lib/apps.ts).
94− if (shell.workspace) shell.pins = pinsIn(dock, shell.workspace.slug);
94+ // The apps this person pinned to their dock here: as their account keeps
95+ // them (read with the rest of the shell), else as this device's cookie
96+ // remembers them (lib/apps.ts).
97+ if (shell.workspace) shell.pins = pinsToShow(shell.pins, dock, shell.workspace.slug);
9598 // Where this g1t lives, for clone lines, agent setup and link previews.
9699 return {
97100 user,
168171 // something (lib/cache.server.ts).
169172 const kept = <T,>(what: string, load: () => Promise<T>) =>
170173 workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null);
171− const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts, chatUnread, teamAgents] = await Promise.all([
174+ const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts, chatUnread, teamAgents, dockPins] = await Promise.all([
172175 // Every project, for the palette and the count; the sidebar lists only
173176 // the person's pinned and recent ones (lib/pins.ts).
174177 workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null),
188191 workspace ? chatUnreadFor(workspace.slug, user) : Promise.resolve(null),
189192 // Home's Recent and the Agents sidebar, on every page: never waited on for long.
190193 workspace ? agentsFor(workspace.slug, user) : Promise.resolve(null),
194+ // The dock's pins, kept with the account so every device shows the same
195+ // dock; never kept here, so a pin shows the moment it is made. Null
196+ // falls back to the cookie (lib/dock.server.ts).
197+ workspace ? savedPins(user, workspace.slug) : Promise.resolve(null),
191198 ]);
192199 return {
193200 workspace,
225232 inbox: unread,
226233 chat: chatUnread,
227234 agents: teamAgents,
235+ pins: dockPins ?? undefined,
228236 // While g1t is free every charge is zero, so usage is shown at cost.
229237 // Usage at price, the one figure every page shows.
230238 monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : (usage.value.priceMicros ?? usage.value.spentMicros)) : null,
+17−9
22 * Apps: every app in the workspace that you can use, the ones pinned to
33 * your dock first. Built-in apps are always in the dock; the rest you pin
44 * or unpin here, from the launcher, or on a phone from More. Pins are
5− * yours alone, per workspace, kept in a cookie (lib/apps.ts) so the dock
6− * is drawn right from the server. The pin buttons post here.
5+ * yours alone, per workspace, kept with your account so every device
6+ * shows the same dock (lib/dock.server.ts), with a copy in a cookie for
7+ * when the account's cannot be read (lib/apps.ts). The pin buttons post
8+ * here.
79 */
810 import { Store } from "lucide-react";
911 import { data } from "react-router";
1214
1315 import type { Route } from "./+types/apps";
1416 import { AppTile, useAppPins } from "../../components/apps";
15−import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsIn, withPin, writeDock } from "../../lib/apps";
17+import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsToShow, withPin, writeDock } from "../../lib/apps";
18+import { savePins, savedPins } from "../../lib/dock.server";
1619 import { page } from "../../lib/meta";
1720 import { readCookie } from "../../lib/mission";
1821 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
2124 return page(args, { title: `Apps · ${params.owner} · g1t` });
2225 }
2326
24−export function loader({ params, context, request }: Route.LoaderArgs) {
27+export async function loader({ params, context, request }: Route.LoaderArgs) {
2528 const viewer = getViewer(context);
2629 const slug = params.owner.toLowerCase();
2730 const membership = viewer?.workspaces?.find((m) => m.slug === slug);
28− if (!membership) throw data(null, { status: 404 });
31+ if (!viewer || !membership) throw data(null, { status: 404 });
2932 return {
3033 slug,
3134 name: membership.name?.trim() || slug,
3235 code: hasCodeAccess(membership),
33− pins: pinsIn(readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
36+ pins: pinsToShow(await savedPins(viewer, slug), readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
3437 };
3538 }
3639
4144 if (!user.workspaces?.some((m) => m.slug === slug)) throw data(null, { status: 404 });
4245 const change = appPinFromForm(await request.formData());
4346 if (!change) return data({ error: "Nothing to do." }, { status: 400 });
44− const saved = readCookie(request.headers.get("cookie"), DOCK_COOKIE);
45− const pins = withPin(pinsIn(saved, slug), change.app, change.pinned);
47+ const cookie = readCookie(request.headers.get("cookie"), DOCK_COOKIE);
48+ // The change is made to the pins as the account keeps them; pins only
49+ // ever kept in this device's cookie are carried over by the first one.
50+ const current = pinsToShow(await savedPins(user, slug), cookie, slug);
51+ const pins = await savePins(user, slug, withPin(current, change.app, change.pinned));
52+ if (!pins) return data({ error: "Your pins could not be saved. Try again.", pins: current }, { status: 503 });
53+ // This device's copy, drawn from when the account's cannot be read.
4654 const secure = new URL(request.url).protocol === "https:";
47− return data({ error: null, pins }, { headers: { "Set-Cookie": dockCookie(writeDock(saved, slug, pins), secure) } });
55+ return data({ error: null, pins }, { headers: { "Set-Cookie": dockCookie(writeDock(cookie, slug, pins), secure) } });
4856 }
4957
5058 export default function Apps({ loaderData }: Route.ComponentProps) {
+35−0
10651065 pub avatar: Option<String>,
10661066 }
10671067
1068+// --- Dock pins -------------------------------------------------------------
1069+//
1070+// The apps a person pins to their dock in a workspace, kept with their
1071+// account so the dock follows them to every device. Each person's own:
1072+// nobody else reads or sets them.
1073+
1074+/// The most apps a person pins in one workspace.
1075+pub const MAX_DOCK_PINS: usize = 24;
1076+/// The most characters an app key takes.
1077+pub const MAX_DOCK_APP_KEY: usize = 32;
1078+
1079+/// `dock_pins`: the apps `user` pinned in the workspace `workspace` (a
1080+/// slug), in the order they set. Returns `Option<Vec<String>>`: null when
1081+/// they never saved any there, or are not one of its members.
1082+#[derive(Debug, Default, Serialize, Deserialize)]
1083+#[serde(rename_all = "camelCase")]
1084+pub struct DockPinsArgs {
1085+ pub user: User,
1086+ pub workspace: String,
1087+}
1088+
1089+/// `set_dock_pins`: replaces `user`'s pins in `workspace` with `apps`, in
1090+/// that order. Each key is lowercase letters, digits and hyphens, at most
1091+/// [`MAX_DOCK_APP_KEY`] characters; a repeat is dropped; at most
1092+/// [`MAX_DOCK_PINS`]. Which keys name real apps is the web app's to say.
1093+/// Returns `Outcome<Vec<String>>`: the pins as saved.
1094+#[derive(Debug, Default, Serialize, Deserialize)]
1095+#[serde(rename_all = "camelCase")]
1096+pub struct SetDockPinsArgs {
1097+ pub user: User,
1098+ pub workspace: String,
1099+ #[serde(default)]
1100+ pub apps: Vec<String>,
1101+}
1102+
10681103 /// `directory`: every account or every workspace, as their public pages
10691104 /// show them, a page at a time in name order. For services that index
10701105 /// them, such as search; nothing private is in it. Returns
+2−0
178178 updateProfile: (actor, fields) => call("update_profile", { actor, ...fields }),
179179 profileWorkspaces: (username, viewer, publicIn) =>
180180 call("profile_workspaces", { username, viewer, public: publicIn }),
181+ dockPins: (user, workspace) => call("dock_pins", { user, workspace }),
182+ setDockPins: (user, workspace, apps) => call("set_dock_pins", { user, workspace, apps }),
181183 listSshKeys: (user) => call("list_ssh_keys", { user }),
182184 addSshKey: (user, title, publicKey) =>
183185 call("add_ssh_key", { user, title, publicKey }),
+17−0
10341034 */
10351035 profileWorkspaces(username: string, viewer: Viewer, publicIn: string[]): Promise<ProfileWorkspace[]>;
10361036
1037+ /**
1038+ * The apps `user` pinned to their dock in the workspace `workspace` (a
1039+ * slug), in the order they set; null when they never saved any there or
1040+ * are not one of its members. Kept with the account, so every device
1041+ * shows the same dock.
1042+ */
1043+ dockPins(user: User, workspace: string): Promise<string[] | null>;
1044+ /**
1045+ * Replaces `user`'s dock pins in `workspace` with `apps`, in that order:
1046+ * keys of lowercase letters, digits and hyphens, repeats dropped, at
1047+ * most `MAX_DOCK_PINS`. Which keys are real apps is the caller's to check.
1048+ */
1049+ setDockPins(user: User, workspace: string, apps: string[]): Promise<Result<string[]>>;
1050+
10371051 listSshKeys(user: User): Promise<SshKey[]>;
10381052 /** Takes one line in OpenSSH public key format. */
10391053 addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>>;
10781092 /** What an agent's token may do: these operations, in this repository. */
10791093 export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding };
10801094
1095+/** The most apps a person pins in one workspace. Mirrors `MAX_DOCK_PINS` in `crates/contracts/src/identity.rs`. */
1096+export const MAX_DOCK_PINS = 24;
1097+
10811098 /** The most characters each profile field takes. Mirrors `crates/contracts/src/identity.rs`. */
10821099 export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40, timezone: 64 } as const;
10831100
+12−0
1+-- The apps each person pins to their dock, per workspace, so the dock is
2+-- the same on every device they sign in on. `apps` is a JSON array of app
3+-- keys (such as ["projects","usage"]) in the order the person set; the
4+-- web app checks the keys against its list of apps. A workspace with no
5+-- row has never had its pins saved. See src/dock.rs.
6+CREATE TABLE dock_pins (
7+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
8+ workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
9+ apps TEXT NOT NULL DEFAULT '[]',
10+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
11+ PRIMARY KEY (user_id, workspace_id)
12+);
+2−1
331331 "ssh_keys",
332332 "workspace_members",
333333 "team_members",
334+ "dock_pins",
334335 ] {
335336 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1));
336337 }
11781179 let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect();
11791180 assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users"));
11801181 assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1")));
1181− for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys"] {
1182+ for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys", "dock_pins"] {
11821183 assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}");
11831184 }
11841185 // The row goes last, and everything before it only while the
+4−0
718718 self.db
719719 .prepare("DELETE FROM workspace_members WHERE workspace_id = ?")
720720 .bind(&[id.into()])?,
721+ // Each member's dock pins in it (dock.rs).
722+ self.db
723+ .prepare("DELETE FROM dock_pins WHERE workspace_id = ?")
724+ .bind(&[id.into()])?,
721725 // Its teams, their people and the roles they gave (teams.rs).
722726 self.db
723727 .prepare("DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
+149−0
1+//! Dock pins: the apps each person pins to their dock, per workspace, kept
2+//! with their account so the dock is the same on every device.
3+//!
4+//! A row per person and workspace (migration 0044), keyed by the
5+//! workspace's id so a rename keeps it. Only the person reads or sets
6+//! their own, and only in a workspace they belong to. The keys are checked
7+//! for shape here; which apps exist is the web app's list (apps/web's
8+//! app/lib/apps.ts), so a new app needs no change to this service.
9+
10+use g1t_contracts::identity::*;
11+use g1t_contracts::time::SQL_NOW;
12+use g1t_contracts::{FailureCode, Outcome};
13+use serde::Deserialize;
14+use worker::Result;
15+
16+use crate::Identity;
17+use crate::security::is_person;
18+
19+/// The pins as they are kept: each key checked, repeats dropped, in the
20+/// order given. Refused whole when a key is malformed or there are too many.
21+pub fn check_pins(apps: &[String]) -> std::result::Result<Vec<String>, String> {
22+ let mut kept: Vec<String> = Vec::with_capacity(apps.len());
23+ for app in apps {
24+ let key = app.trim();
25+ let well_formed = !key.is_empty()
26+ && key.len() <= MAX_DOCK_APP_KEY
27+ && key.starts_with(|c: char| c.is_ascii_lowercase())
28+ && key.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
29+ if !well_formed {
30+ return Err("That is not an app you can pin.".to_owned());
31+ }
32+ if !kept.iter().any(|seen| seen == key) {
33+ kept.push(key.to_owned());
34+ }
35+ }
36+ if kept.len() > MAX_DOCK_PINS {
37+ return Err(format!("Pin at most {MAX_DOCK_PINS} apps."));
38+ }
39+ Ok(kept)
40+}
41+
42+/// The pins in a row, or none when the row cannot be read: a bad row is
43+/// treated as never saved rather than failing the page.
44+fn parse_pins(apps: &str) -> Option<Vec<String>> {
45+ let keys: Vec<String> = serde_json::from_str(apps).ok()?;
46+ check_pins(&keys).ok()
47+}
48+
49+#[derive(Deserialize)]
50+struct Row {
51+ apps: String,
52+}
53+
54+impl Identity {
55+ pub async fn dock_pins(&self, a: DockPinsArgs) -> Result<Option<Vec<String>>> {
56+ let slug = a.workspace.trim().to_lowercase();
57+ if !is_person(&a.user) || !a.user.is_member(&slug) {
58+ return Ok(None);
59+ }
60+ // One query: the workspace by slug, and only while they belong to it.
61+ let row = self
62+ .db
63+ .prepare(
64+ "SELECT d.apps FROM dock_pins d
65+ JOIN workspaces w ON w.id = d.workspace_id AND w.deleted_at IS NULL
66+ JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = d.user_id
67+ WHERE d.user_id = ? AND w.slug = ?",
68+ )
69+ .bind(&[a.user.id.as_str().into(), slug.into()])?
70+ .first::<Row>(None)
71+ .await?;
72+ Ok(row.and_then(|row| parse_pins(&row.apps)))
73+ }
74+
75+ pub async fn set_dock_pins(&self, a: SetDockPinsArgs) -> Result<Outcome<Vec<String>>> {
76+ let slug = a.workspace.trim().to_lowercase();
77+ if !is_person(&a.user) {
78+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has a dock."));
79+ }
80+ if !a.user.is_member(&slug) {
81+ return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
82+ }
83+ let apps = match check_pins(&a.apps) {
84+ Ok(apps) => apps,
85+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
86+ };
87+ let json = serde_json::to_string(&apps)?;
88+ // Written only where they are still a member, in one statement.
89+ let row = self
90+ .db
91+ .prepare(format!(
92+ "INSERT INTO dock_pins (user_id, workspace_id, apps, updated_at)
93+ SELECT ?1, w.id, ?2, {SQL_NOW} FROM workspaces w
94+ JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?1
95+ WHERE w.slug = ?3 AND w.deleted_at IS NULL
96+ ON CONFLICT (user_id, workspace_id) DO UPDATE SET apps = excluded.apps, updated_at = excluded.updated_at
97+ RETURNING apps"
98+ ))
99+ .bind(&[a.user.id.as_str().into(), json.as_str().into(), slug.into()])?
100+ .first::<Row>(None)
101+ .await?;
102+ Ok(match row {
103+ Some(_) => Outcome::Ok(apps),
104+ None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
105+ })
106+ }
107+}
108+
109+#[cfg(test)]
110+mod tests {
111+ use super::*;
112+
113+ fn keys(list: &[&str]) -> Vec<String> {
114+ list.iter().map(|key| (*key).to_owned()).collect()
115+ }
116+
117+ #[test]
118+ fn pins_keep_their_order_without_repeats() {
119+ assert_eq!(check_pins(&keys(&["usage", "projects", "usage", " teams "])).unwrap(), keys(&["usage", "projects", "teams"]));
120+ assert_eq!(check_pins(&[]).unwrap(), Vec::<String>::new());
121+ }
122+
123+ #[test]
124+ fn a_malformed_key_is_refused() {
125+ for bad in ["", "Projects", "1st", "a b", "../x", "pro_jects", "<script>", &"a".repeat(MAX_DOCK_APP_KEY + 1)] {
126+ assert!(check_pins(&keys(&["projects", bad])).is_err(), "{bad}");
127+ }
128+ assert!(check_pins(&keys(&["ai-gateway", "v2"])).is_ok());
129+ }
130+
131+ #[test]
132+ fn at_most_the_limit() {
133+ let many: Vec<String> = (0..MAX_DOCK_PINS).map(|n| format!("app-{n}")).collect();
134+ assert_eq!(check_pins(&many).unwrap().len(), MAX_DOCK_PINS);
135+ let too_many: Vec<String> = (0..=MAX_DOCK_PINS).map(|n| format!("app-{n}")).collect();
136+ assert!(check_pins(&too_many).is_err());
137+ // Repeats do not count against it.
138+ let repeated: Vec<String> = many.iter().chain(many.iter()).cloned().collect();
139+ assert_eq!(check_pins(&repeated).unwrap(), many);
140+ }
141+
142+ #[test]
143+ fn a_bad_row_reads_as_never_saved() {
144+ assert_eq!(parse_pins(r#"["projects","usage"]"#), Some(keys(&["projects", "usage"])));
145+ assert_eq!(parse_pins("[]"), Some(Vec::new()));
146+ assert_eq!(parse_pins("not json"), None);
147+ assert_eq!(parse_pins(r#"["BAD"]"#), None);
148+ }
149+}
+4−0
1313 mod deploy_keys;
1414 mod device;
1515 mod directory;
16+mod dock;
1617 mod email;
1718 mod emails;
1819 mod github;
990991 }
991992 "directory" => reply(&identity.directory(args(body)?).await?),
992993 "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?),
994+ // Each person's dock pins, per workspace; see dock.rs.
995+ "dock_pins" => reply(&identity.dock_pins(args(body)?).await?),
996+ "set_dock_pins" => reply(&identity.set_dock_pins(args(body)?).await?),
993997 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
994998 // Services only: who registered each key, for verifying commit
995999 // signatures (repos' signatures.rs).