@venturewild/workspace 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -65,8 +65,12 @@ import {
65
65
  createWorkspace as registryCreateWorkspace,
66
66
  touchWorkspace,
67
67
  removeWorkspace,
68
+ markShared,
69
+ setSyncProject,
70
+ seedDefault,
68
71
  workspaceIdForDir,
69
72
  } from './workspace-registry.mjs';
73
+ import { createWorkspaceRails } from './workspaces.mjs';
70
74
  import { matchCandidates } from './bazaar/mock-tickup.mjs';
71
75
  import { servePreviewFile, confineBuildDir } from './bazaar/preview-server.mjs';
72
76
  import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
@@ -334,6 +338,12 @@ export async function createServer(overrides = {}) {
334
338
  // cache. `canvas` above still owns the workspace-SHARED agent content (blocks +
335
339
  // agent theme). Person-scoped state stores are built lazily + cached per personKey.
336
340
  const canvasRails = overrides.canvasRails || createCanvasRails(config, config.account);
341
+ // Shared-workspace membership client (sharing slice — design §4). The account
342
+ // token is kept top-level on config (out of the broadcast `config.account`), so
343
+ // pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
344
+ // every call returns {ok:false, code:'not_configured'}) until the owner logs in.
345
+ const workspaceRails =
346
+ overrides.workspaceRails || createWorkspaceRails(config, { accountToken: config.accountToken });
337
347
  const canvasStores = new Map();
338
348
  function canvasFor(personKey) {
339
349
  let store = canvasStores.get(personKey);
@@ -1885,16 +1895,23 @@ export async function createServer(overrides = {}) {
1885
1895
  const src = c.get('session')?.source;
1886
1896
  return src === 'localhost' || src === 'loopback';
1887
1897
  }
1888
- function lobbyList() {
1889
- const reg = listWorkspaces(registryEnv);
1890
- const items = reg.map((w) => ({
1898
+ // The client-facing shape of a lobby card. Shared workspaces (sharing slice)
1899
+ // carry their rails identity + the local member's role so the lobby can badge
1900
+ // the card + gate owner-only actions (invite/un-share).
1901
+ function lobbyShape(w) {
1902
+ return {
1891
1903
  id: w.id,
1892
1904
  name: w.name,
1893
1905
  dir: w.dir,
1894
1906
  kind: w.kind,
1907
+ shared: w.kind === 'shared' && w.shared ? w.shared : undefined,
1895
1908
  lastOpenedAt: w.lastOpenedAt,
1896
1909
  isDefault: w.id === defaultWorkspace.id,
1897
- }));
1910
+ };
1911
+ }
1912
+ function lobbyList() {
1913
+ const reg = listWorkspaces(registryEnv);
1914
+ const items = reg.map(lobbyShape);
1898
1915
  // The boot/default workspace is computed (never persisted) — inject it if it
1899
1916
  // isn't already an explicit registry entry, so the lobby is never blank.
1900
1917
  if (!reg.some((w) => w.id === defaultWorkspace.id)) {
@@ -1910,10 +1927,41 @@ export async function createServer(overrides = {}) {
1910
1927
  return items.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
1911
1928
  }
1912
1929
 
1913
- app.get('/api/lobby/workspaces', (c) => {
1930
+ // The shared workspaces THIS account belongs to, from the rails (sharing slice).
1931
+ // Degrade-never: not logged in / rails down → []; the lobby still shows local
1932
+ // workspaces. This is how a workspace someone shared with you APPEARS in your
1933
+ // lobby — before you've joined it, it has no local folder yet.
1934
+ async function sharedFromRails() {
1935
+ if (!config.account?.email || !workspaceRails.capable) return [];
1936
+ const r = await workspaceRails.list();
1937
+ if (!r.ok || !Array.isArray(r.data?.workspaces)) return [];
1938
+ return r.data.workspaces; // [{ slug, name, role, owner_email }]
1939
+ }
1940
+
1941
+ // Merge the local registry with the rails membership: shared workspaces I belong
1942
+ // to but haven't given a local home yet surface as `joinable` cards (no dir,
1943
+ // can't be entered until joined). Ones I already host locally are deduped by slug.
1944
+ async function lobbyListMerged() {
1945
+ const local = lobbyList();
1946
+ const localSlugs = new Set(local.filter((w) => w.shared?.slug).map((w) => w.shared.slug));
1947
+ const joinable = (await sharedFromRails())
1948
+ .filter((w) => !localSlugs.has(w.slug))
1949
+ .map((w) => ({
1950
+ id: `shared:${w.slug}`, // synthetic — no local folder to re-root into yet
1951
+ name: w.name,
1952
+ slug: w.slug,
1953
+ kind: 'shared',
1954
+ joinable: true,
1955
+ shared: { slug: w.slug, ownerEmail: w.owner_email, role: w.role },
1956
+ lastOpenedAt: 0,
1957
+ }));
1958
+ return [...local, ...joinable];
1959
+ }
1960
+
1961
+ app.get('/api/lobby/workspaces', async (c) => {
1914
1962
  const forbidden = require(c, 'fileTree');
1915
1963
  if (forbidden) return forbidden;
1916
- return c.json({ workspaces: lobbyList(), defaultId: defaultWorkspace.id });
1964
+ return c.json({ workspaces: await lobbyListMerged(), defaultId: defaultWorkspace.id });
1917
1965
  });
1918
1966
 
1919
1967
  app.post('/api/lobby/workspaces', async (c) => {
@@ -1969,6 +2017,224 @@ export async function createServer(overrides = {}) {
1969
2017
  return c.json({ ok: true, removed });
1970
2018
  });
1971
2019
 
2020
+ // --- sharing slice (design §4): promote a local workspace to shared + invite ---
2021
+ // "Share" mints a first-class shared workspace on the bmo-sync rails (its own
2022
+ // slug + member set) and flips THIS install's registry entry to kind:'shared'.
2023
+ // The folder never moves (decision 7). HOST-only (you share the folder where it
2024
+ // lives) + owner-level (fileTree) + must be logged in (the rails identity is the
2025
+ // account's). The Decision-11 "this carries the files AND the agent's memory"
2026
+ // heads-up lives on the client; the server just mints + records.
2027
+
2028
+ // Resolve a lobby workspace id to its registry entry, seeding the computed
2029
+ // boot/default workspace on first touch so it can be shared like any other.
2030
+ function lobbyEntryFor(id) {
2031
+ let entry = getWorkspace(id, registryEnv);
2032
+ if (!entry && id === defaultWorkspace.id) {
2033
+ entry = seedDefault(
2034
+ { dir: defaultWorkspace.dir, name: defaultWorkspace.name },
2035
+ registryEnv,
2036
+ );
2037
+ }
2038
+ return entry;
2039
+ }
2040
+
2041
+ // Slice 3 "going live": start file-sync for a shared workspace by minting a
2042
+ // membership-gated pass on the rails (provisions the file-sync project on first
2043
+ // call) and pairing the LOCAL daemon to the folder, so it backs up to VW's
2044
+ // rails. Best-effort by design — a down daemon or a rails hiccup must NOT fail
2045
+ // Share (the membership identity is already minted); it returns a status the UI
2046
+ // can surface ("backing up…" / "we'll start syncing when the daemon is up").
2047
+ // Idempotent: a workspace already linked + already syncing is a no-op success.
2048
+ async function startWorkspaceSync(entry, slug) {
2049
+ try {
2050
+ const linked = entry.shared?.projectCode || null;
2051
+ if (linked) {
2052
+ // Already provisioned — if the daemon is already syncing it, nothing to do.
2053
+ const ws = await syncControl.listWorkspaces();
2054
+ if (ws.some((w) => w.workspaceId === linked || w.projectId === linked)) {
2055
+ return { synced: true, projectCode: linked };
2056
+ }
2057
+ }
2058
+ const cred = await workspaceRails.syncCredential(slug);
2059
+ if (!cred.ok || !cred.data?.invite_code) {
2060
+ return { synced: false, reason: cred.code || 'rails_unreachable', message: cred.message };
2061
+ }
2062
+ const projectCode = cred.data.project_code;
2063
+ try {
2064
+ await syncControl.pair(cred.data.invite_code, entry.dir);
2065
+ } catch (e) {
2066
+ // The daemon is down / unreachable — file-sync will start once it's up and
2067
+ // the owner re-shares (or a future watcher re-pairs). Membership stands.
2068
+ return {
2069
+ synced: false,
2070
+ reason: 'daemon_unreachable',
2071
+ message: String(e.message || e),
2072
+ projectCode,
2073
+ };
2074
+ }
2075
+ setSyncProject(entry.id, projectCode, registryEnv);
2076
+ return { synced: true, projectCode };
2077
+ } catch (e) {
2078
+ // Belt-and-braces: file-sync is best-effort — ANY failure here leaves the
2079
+ // workspace shared (membership minted) and just reports sync didn't start.
2080
+ return { synced: false, reason: 'sync_error', message: String(e?.message || e) };
2081
+ }
2082
+ }
2083
+
2084
+ app.post('/api/lobby/workspaces/:id/share', async (c) => {
2085
+ const forbidden = require(c, 'fileTree');
2086
+ if (forbidden) return forbidden;
2087
+ if (!isHostRequest(c)) {
2088
+ return c.json(
2089
+ {
2090
+ error: 'host_only',
2091
+ message: 'Share a workspace from the computer where its folder lives.',
2092
+ },
2093
+ 403,
2094
+ );
2095
+ }
2096
+ if (!config.account?.email || !workspaceRails.capable) {
2097
+ return c.json(
2098
+ {
2099
+ error: 'login_required',
2100
+ message: 'Sign in to VentureWild first — sharing gives the workspace an identity on the rails.',
2101
+ },
2102
+ 400,
2103
+ );
2104
+ }
2105
+ const id = c.req.param('id');
2106
+ const entry = lobbyEntryFor(id);
2107
+ if (!entry) return c.json({ error: 'no_such_workspace' }, 404);
2108
+ const wasShared = entry.kind === 'shared' && Boolean(entry.shared?.slug);
2109
+ // Mint the membership identity (idempotent: an already-shared workspace keeps
2110
+ // its slug). Then "go live" — start backing the folder up. The file-sync step
2111
+ // runs on BOTH a fresh share AND a re-share of an already-shared-but-not-yet-
2112
+ // synced workspace, so a Share that happened before the daemon was up heals.
2113
+ let shared = entry;
2114
+ if (!wasShared) {
2115
+ const r = await workspaceRails.create(entry.name);
2116
+ if (!r.ok) {
2117
+ // Surface the rails' code/message (slug_taken / unreachable / …) at a sane status.
2118
+ const status = r.status >= 400 && r.status < 600 ? r.status : 502;
2119
+ return c.json({ error: r.code || 'share_failed', message: r.message }, status);
2120
+ }
2121
+ shared = markShared(
2122
+ id,
2123
+ { slug: r.data.slug, ownerEmail: config.account.email, role: 'owner' },
2124
+ registryEnv,
2125
+ );
2126
+ auditAction(c, 'lobby.share', `id=${id} slug=${r.data.slug}`);
2127
+ }
2128
+ if (!shared?.shared?.slug) return c.json({ error: 'share_failed' }, 500);
2129
+ const sync = await startWorkspaceSync(shared, shared.shared.slug);
2130
+ auditAction(c, 'lobby.share.sync', `id=${id} slug=${shared.shared.slug} synced=${sync.synced}`);
2131
+ const finalEntry = getWorkspace(id, registryEnv) || shared;
2132
+ return c.json({ ok: true, alreadyShared: wasShared, workspace: lobbyShape(finalEntry), sync });
2133
+ });
2134
+
2135
+ app.post('/api/lobby/workspaces/:id/invite', async (c) => {
2136
+ const forbidden = require(c, 'fileTree');
2137
+ if (forbidden) return forbidden;
2138
+ if (!isHostRequest(c)) return c.json({ error: 'host_only' }, 403);
2139
+ if (!config.account?.email || !workspaceRails.capable) {
2140
+ return c.json({ error: 'login_required' }, 400);
2141
+ }
2142
+ const id = c.req.param('id');
2143
+ const entry = getWorkspace(id, registryEnv);
2144
+ if (!entry || entry.kind !== 'shared' || !entry.shared?.slug) {
2145
+ return c.json({ error: 'not_shared', message: 'Share this workspace before inviting people.' }, 400);
2146
+ }
2147
+ const body = await c.req.json().catch(() => ({}));
2148
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
2149
+ if (!email || !email.includes('@')) {
2150
+ return c.json({ error: 'invalid_email', message: 'Enter a teammate’s email address.' }, 400);
2151
+ }
2152
+ const r = await workspaceRails.addMember(entry.shared.slug, email);
2153
+ if (!r.ok) {
2154
+ const status = r.status >= 400 && r.status < 600 ? r.status : 502;
2155
+ return c.json({ error: r.code || 'invite_failed', message: r.message }, status);
2156
+ }
2157
+ // No account yet → the rails recorded a PENDING invite; they'll claim it by
2158
+ // signing in with Google + Join (no account-first requirement). Already-an-
2159
+ // account → active immediately.
2160
+ const pending = Boolean(r.data?.pending);
2161
+ auditAction(c, 'lobby.invite', `slug=${entry.shared.slug} email=${email} pending=${pending}`);
2162
+ return c.json({ ok: true, email, pending, accountId: r.data?.account_id || null });
2163
+ });
2164
+
2165
+ // Join a shared workspace someone invited you to (design §4 decision 9). The
2166
+ // invitee PICKS where it lives on their disk (a new folder under the Workspaces
2167
+ // home, or an existing one — same mechanic as create), then it's registered
2168
+ // locally + marked shared with this member's role. HOST-only (the folder lives
2169
+ // here) + login. Membership is verified against the rails list, so you can only
2170
+ // give a local home to a workspace you actually belong to. This binds the
2171
+ // IDENTITY (so the per-person canvas syncs) AND — Slice 3 "filling in" — starts
2172
+ // file-sync via the SAME `startWorkspaceSync` the owner uses: the joiner's daemon
2173
+ // pairs the chosen folder and pulls the current state, so it fills with the
2174
+ // owner's files + agent-brain instead of landing empty. Best-effort: a down
2175
+ // daemon still joins you (you land in the folder; it fills once sync starts).
2176
+ app.post('/api/lobby/workspaces/join', async (c) => {
2177
+ const forbidden = require(c, 'fileTree');
2178
+ if (forbidden) return forbidden;
2179
+ if (!isHostRequest(c)) {
2180
+ return c.json(
2181
+ { error: 'host_only', message: 'Join a shared workspace from the computer where you want its folder.' },
2182
+ 403,
2183
+ );
2184
+ }
2185
+ if (!config.account?.email || !workspaceRails.capable) {
2186
+ return c.json({ error: 'login_required' }, 400);
2187
+ }
2188
+ const body = await c.req.json().catch(() => ({}));
2189
+ const slug = typeof body.slug === 'string' ? body.slug.trim().toLowerCase() : '';
2190
+ if (!slug) return c.json({ error: 'slug_required' }, 400);
2191
+ // Claim membership on the rails: this BOTH verifies the caller belongs (an
2192
+ // active member OR a pending invite addressed to their verified email) AND
2193
+ // activates a pending invite (invited→active) — the invite-a-teammate-who-
2194
+ // has-no-account-yet payoff. 403 not_a_member if neither.
2195
+ const claimed = await workspaceRails.claim(slug);
2196
+ if (!claimed.ok) {
2197
+ if (claimed.code === 'not_a_member' || claimed.status === 403) {
2198
+ return c.json({ error: 'not_a_member', message: 'You’re not a member of that workspace.' }, 403);
2199
+ }
2200
+ const status = claimed.status >= 400 && claimed.status < 600 ? claimed.status : 502;
2201
+ return c.json({ error: claimed.code || 'join_failed', message: claimed.message }, status);
2202
+ }
2203
+ const ws = {
2204
+ name: claimed.data?.name || slug,
2205
+ role: claimed.data?.role || 'member',
2206
+ owner_email: claimed.data?.owner_email || '',
2207
+ };
2208
+ // Already have a local home for it? Return that (idempotent re-join), and
2209
+ // (re)start file-sync so a join that happened before the daemon was up heals.
2210
+ const existing = listWorkspaces(registryEnv).find((w) => w.shared?.slug === slug);
2211
+ if (existing) {
2212
+ touchWorkspace(existing.id, registryEnv);
2213
+ const sync = await startWorkspaceSync(existing, slug);
2214
+ const finalEntry = getWorkspace(existing.id, registryEnv) || existing;
2215
+ return c.json({ ok: true, alreadyJoined: true, workspace: lobbyShape(finalEntry), sync });
2216
+ }
2217
+ const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : ws.name;
2218
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : '';
2219
+ let entry;
2220
+ try {
2221
+ entry = registryCreateWorkspace({ name, dir: dir || undefined }, registryEnv);
2222
+ } catch (e) {
2223
+ return c.json({ error: 'join_failed', message: String(e.message || e) }, 400);
2224
+ }
2225
+ const updated = markShared(
2226
+ entry.id,
2227
+ { slug, ownerEmail: ws.owner_email, role: ws.role || 'member' },
2228
+ registryEnv,
2229
+ );
2230
+ auditAction(c, 'lobby.join', `id=${entry.id} slug=${slug} claimed=${Boolean(claimed.data?.claimed)}`);
2231
+ // Filling in: pair the joiner's daemon → it pulls the owner's files + brain.
2232
+ const sync = await startWorkspaceSync(updated, slug);
2233
+ auditAction(c, 'lobby.join.sync', `id=${entry.id} slug=${slug} synced=${sync.synced}`);
2234
+ const finalEntry = getWorkspace(entry.id, registryEnv) || updated;
2235
+ return c.json({ ok: true, workspace: lobbyShape(finalEntry), sync });
2236
+ });
2237
+
1972
2238
  app.get('/api/workspace/tree', async (c) => {
1973
2239
  if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1974
2240
  return c.json({ error: 'forbidden' }, 403);
@@ -10,9 +10,13 @@
10
10
  // Creating/opening a workspace is a HOST action (the folder lives here); a remote
11
11
  // browser only ENTERS existing ones — enforced at the route, not here.
12
12
  //
13
- // Local workspaces only (kind:'local'); shared workspaces arrive from the rails in
14
- // a later slice. `removeWorkspace` drops a folder from the LIST only — it never
15
- // deletes files.
13
+ // A workspace is `kind:'local'` until the owner explicitly SHARES it (sharing
14
+ // slice / design §4 decision 7): `markShared` promotes the entry to `kind:'shared'`
15
+ // and records a `shared:{slug,ownerEmail,role,sharedAt}` block — the slug is the
16
+ // workspace's first-class identity on the bmo-sync rails (membership/canvas), minted
17
+ // by `POST /api/workspaces/create`. The folder stays exactly where it is; promoting
18
+ // is a metadata flip on the LIST, never a move. `removeWorkspace` drops a folder
19
+ // from the LIST only — it never deletes files.
16
20
 
17
21
  import fs from 'node:fs';
18
22
  import path from 'node:path';
@@ -71,17 +75,40 @@ function readRaw(env) {
71
75
  return [];
72
76
  }
73
77
 
78
+ // A shared workspace carries a `shared` block (the rails identity + the local
79
+ // member's role). Only kept when the entry is actually `kind:'shared'` AND the
80
+ // block has a slug; anything else collapses back to a plain local entry so a
81
+ // half-written record can't masquerade as shared.
82
+ function sanitizeShared(s) {
83
+ if (!s || typeof s !== 'object' || typeof s.slug !== 'string' || !s.slug.trim()) return null;
84
+ const out = {
85
+ slug: s.slug.trim().toLowerCase(),
86
+ ownerEmail: typeof s.ownerEmail === 'string' ? s.ownerEmail : '',
87
+ role: s.role === 'owner' ? 'owner' : 'member',
88
+ sharedAt: Number(s.sharedAt) || 0,
89
+ };
90
+ // Slice 3: the file-sync project this workspace replicates through, recorded
91
+ // once the daemon is paired ("going live"). Absent until file-sync starts.
92
+ if (typeof s.projectCode === 'string' && s.projectCode.trim()) {
93
+ out.projectCode = s.projectCode.trim();
94
+ }
95
+ return out;
96
+ }
97
+
74
98
  function sanitize(e) {
75
99
  if (!e || typeof e !== 'object' || typeof e.dir !== 'string' || !e.dir) return null;
76
100
  const dir = path.resolve(e.dir);
77
- return {
101
+ const shared = sanitizeShared(e.shared);
102
+ const out = {
78
103
  id: typeof e.id === 'string' && e.id ? e.id : idForDir(dir),
79
104
  name: typeof e.name === 'string' && e.name.trim() ? e.name.trim() : nameForDir(dir),
80
105
  dir,
81
- kind: e.kind === 'shared' ? 'shared' : 'local',
106
+ kind: e.kind === 'shared' && shared ? 'shared' : 'local',
82
107
  createdAt: Number(e.createdAt) || nowMs(),
83
108
  lastOpenedAt: Number(e.lastOpenedAt) || 0,
84
109
  };
110
+ if (out.kind === 'shared') out.shared = shared;
111
+ return out;
85
112
  }
86
113
 
87
114
  function writeRaw(list, env) {
@@ -215,6 +242,49 @@ export function touchWorkspace(id, env = process.env) {
215
242
  return w;
216
243
  }
217
244
 
245
+ /**
246
+ * Promote a registered workspace to SHARED (sharing slice / design §4 decision 7).
247
+ * The workspace must already be in the registry (the boot/default workspace is
248
+ * seeded first by the caller). Records the rails identity (`slug`) + the local
249
+ * member's role; the folder is untouched. Idempotent: re-sharing updates the block.
250
+ * Returns the updated entry, or null if the id is unknown.
251
+ */
252
+ export function markShared(id, { slug, ownerEmail = '', role = 'owner' } = {}, env = process.env) {
253
+ if (!slug || typeof slug !== 'string') throw new Error('markShared requires a slug');
254
+ const list = readRaw(env);
255
+ const w = list.find((x) => x.id === id);
256
+ if (!w) return null;
257
+ w.kind = 'shared';
258
+ w.shared = sanitizeShared({
259
+ slug,
260
+ ownerEmail,
261
+ role,
262
+ sharedAt: w.shared?.sharedAt || nowMs(),
263
+ // Preserve a file-sync link already recorded (re-share/re-mark must not drop it).
264
+ projectCode: w.shared?.projectCode,
265
+ });
266
+ writeRaw(list, env);
267
+ return w;
268
+ }
269
+
270
+ /**
271
+ * Record the file-sync project a shared workspace replicates through, once the
272
+ * daemon has paired the folder (Slice 3 "going live"). The entry must already be
273
+ * `kind:'shared'`. Idempotent. Returns the updated entry, or null if the id is
274
+ * unknown / not shared.
275
+ */
276
+ export function setSyncProject(id, projectCode, env = process.env) {
277
+ if (!projectCode || typeof projectCode !== 'string') {
278
+ throw new Error('setSyncProject requires a projectCode');
279
+ }
280
+ const list = readRaw(env);
281
+ const w = list.find((x) => x.id === id);
282
+ if (!w || w.kind !== 'shared' || !w.shared) return null;
283
+ w.shared = sanitizeShared({ ...w.shared, projectCode });
284
+ writeRaw(list, env);
285
+ return w;
286
+ }
287
+
218
288
  /** Remove a workspace from the LIST only. NEVER deletes the folder or its files. */
219
289
  export function removeWorkspace(id, env = process.env) {
220
290
  const list = readRaw(env);
@@ -81,11 +81,35 @@ export class WorkspaceRails {
81
81
  return this._post('/api/workspaces/list', {});
82
82
  }
83
83
 
84
- /** Add a member by email (owner-only; the email must already have an account). */
84
+ /**
85
+ * Add a member by email (owner-only). If the email has no account yet the
86
+ * rails records a PENDING invite (`data.pending === true`) instead of an
87
+ * active membership; the person claims it by signing in + Join.
88
+ */
85
89
  addMember(slug, email) {
86
90
  return this._post('/api/workspaces/members/add', { slug, member_email: email });
87
91
  }
88
92
 
93
+ /**
94
+ * Claim membership at Join time. Activates a pending invite addressed to this
95
+ * account's own VERIFIED email; a no-op for an already-active member. Returns
96
+ * `{ok, data:{role, name, owner_email, claimed}}` or a 403 `not_a_member`.
97
+ */
98
+ claim(slug) {
99
+ return this._post('/api/workspaces/claim', { slug });
100
+ }
101
+
102
+ /**
103
+ * Mint a file-sync pass for a shared workspace this account belongs to
104
+ * (Slice 3). Returns `{ok, data:{project_code, invite_code, expires_at}}` — the
105
+ * daemon redeems `invite_code` to pair the folder and start replicating. The
106
+ * project is provisioned lazily server-side; the code is stable, the invite is
107
+ * one-shot (each call mints a fresh one). A non-member → 403 `not_a_member`.
108
+ */
109
+ syncCredential(slug) {
110
+ return this._post('/api/workspaces/sync-credential', { slug });
111
+ }
112
+
89
113
  /** Remove a member by email or accountId (owner-only; the owner can't be removed). */
90
114
  removeMember(slug, ref) {
91
115
  const body = { slug };