Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/daemon/src/collab/authorized-team-project-pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ const AUTHORIZED_PULL_TIMEOUT_MS = 30_000;
const RECEIPT_MAX_AGE_MS = 2_000;
const MANIFEST_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;

class AuthorizedTeamProjectPullReceiptExpiredError extends Error {
readonly code = 'AUTHORIZED_TEAM_PROJECT_PULL_RECEIPT_EXPIRED';
}

export function isAuthorizedTeamProjectPullReceiptExpired(
error: unknown,
): boolean {
return error instanceof AuthorizedTeamProjectPullReceiptExpiredError;
}

export interface AuthorizedTeamProjectPullReceipt {
schemaVersion: 1;
workspaceId: string;
Expand Down Expand Up @@ -162,11 +172,15 @@ export function validateAuthorizedTeamProjectPullReceipt(
!Number.isFinite(authorizedAt) ||
!Number.isFinite(expiresAt) ||
expiresAt <= authorizedAt ||
expiresAt - authorizedAt > RECEIPT_MAX_AGE_MS ||
nowMs > expiresAt
expiresAt - authorizedAt > RECEIPT_MAX_AGE_MS
) {
throw new Error('authorized pull receipt is stale');
}
if (nowMs >= expiresAt) {
throw new AuthorizedTeamProjectPullReceiptExpiredError(
'authorized pull receipt is stale',
);
}
}

export function isAuthorizedTeamProjectPullUnavailable(
Expand Down
11 changes: 11 additions & 0 deletions apps/daemon/src/collab/vela-workspace-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ export function createWorkspaceDirectoryAuthorityBroker(options: {
} = {}): {
read: () => Promise<WorkspaceDirectoryFetchResult>;
fresh: () => Promise<WorkspaceDirectoryFetchResult>;
refreshAfterMutation: () => Promise<WorkspaceDirectoryFetchResult>;
} {
const fetchDirectory =
options.fetchDirectory ?? (() => fetchVelaWorkspaceDirectory());
Expand Down Expand Up @@ -603,6 +604,16 @@ export function createWorkspaceDirectoryAuthorityBroker(options: {
return start(identity);
},
fresh: () => start(identityKey()),
refreshAfterMutation: async () => {
// A read that started before the remote mutation can still be in flight
// after the mutation commits. Drain it, then deliberately start another
// fetch so the settled lease is based on post-mutation authority.
const identity = identityKey();
const pending = inFlight.get(identity);
if (pending) await pending.catch(() => undefined);
cached.delete(identity);
return start(identityKey());
},
};
}

Expand Down
12 changes: 12 additions & 0 deletions apps/daemon/src/routes/collab-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export interface RegisterCollabContextRoutesDeps {
* must never be collapsed into a confirmed empty membership list.
*/
fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>;
/**
* Force-refresh the membership authority after an invite continuation is
* consumed. The consume mutates B before the daemon's settled directory
* lease expires; refreshing here prevents the accepted Workspace from being
* rejected by the next exact-scope request as a stale non-membership.
*/
refreshWorkspaceDirectoryAfterMutation?: () => Promise<WorkspaceDirectoryFetchResult>;
/**
* Collab realtime hop-2 — the workspace-scoped invalidation SSE seams. When
* both are provided the daemon registers `GET /api/workspace/events`; the route
Expand Down Expand Up @@ -272,6 +279,11 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
if (!nonce.trim()) return res.status(400).json({ error: 'missing_nonce' });
const outcome = await consumeInvite(nonce);
if (!outcome.ok) return res.status(outcome.status).json({ error: outcome.error });
// Consuming the one-time nonce has already committed the membership on B.
// Refresh the daemon's settled authority lease before the renderer makes
// its first exact-scope read. A refresh outage must not turn a successfully
// consumed, non-repeatable continuation into an HTTP failure.
await deps.refreshWorkspaceDirectoryAfterMutation?.().catch(() => undefined);
return res.json({ context: outcome.context, workspaceMemberId: outcome.workspaceMemberId });
});

Expand Down
Loading
Loading