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
7 changes: 7 additions & 0 deletions apps/server/src/auth/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type PairMachine = {
label: string;
repositories: MachineRepository[];
autoRun: boolean;
/** Whether teammates' documents may run their Planner here on Default machine. */
shared?: boolean;
/** The machine's own GitHub authorization; GitHub refresh tokens are single-use, never shared. */
grant?: GitHubTokenGrant;
};
Expand Down Expand Up @@ -96,6 +98,7 @@ export class Machines {
label: input.label,
repositories: input.repositories,
autoRun: input.autoRun,
...(input.shared ? { shared: true } : {}),
tokenHash: digest(token),
createdAt: now,
lastSeenAt: now,
Expand Down Expand Up @@ -146,6 +149,10 @@ export class Machines {
return this.#storage.machines.rename(id, userId, label);
}

share(id: string, userId: string, shared: boolean): Promise<boolean> {
return this.#storage.machines.share(id, userId, shared);
}

async revoke(id: string, userId: string): Promise<boolean> {
let revoked = await this.#storage.machines.revoke(id, userId, this.#clock());
if (revoked) {
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/experiments/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export type Connection = {
* Stored with the machine, so a reconnect keeps it.
*/
autoRun?: boolean;
/**
* Its owner shares the machine with teammates: Default machine may run the Planner of a
* document whose creator has no machine that can. Stored with the machine.
*/
shared?: boolean;
/** The chopin release that opened it, when it said; known only while it is connected. */
version?: string;
/**
Expand All @@ -53,6 +58,7 @@ export type Approval = {
owner: string;
login: string;
autoRun: boolean;
shared?: boolean;
repositoryId?: string;
};
type Pending = {
Expand Down Expand Up @@ -260,6 +266,7 @@ export class Connections {
owner: string;
login: string;
autoRun: boolean;
shared?: boolean;
version?: string;
protocol?: number;
},
Expand Down Expand Up @@ -289,6 +296,7 @@ export class Connections {
anyRepository,
expiresAt: this.now() + CONNECTION_MS,
autoRun: machine.autoRun,
...(machine.shared ? { shared: true } : {}),
...(machine.version ? { version: machine.version } : {}),
...(machine.protocol !== undefined ? { protocol: machine.protocol } : {}),
about,
Expand All @@ -300,6 +308,24 @@ export class Connections {
this.#tokens.set(fingerprint(token), { connectionId: connection.id });
return { connection, token };
}
/** Its owner shared a machine with teammates or stopped: its live connections follow at once. */
share(machineId: string, shared: boolean) {
for (let connection of this.machine(machineId)) {
if (shared) connection.shared = true;
else delete connection.shared;
}
}
/**
* Live connections of machines their owners share with teammates, other than `owner`'s, that
* serve a repository: each owner's favorite first, then the most recently heard from.
*/
shared(repositoryId: string, owner: string) {
return this.list(repositoryId).filter(value => value.shared && value.owner !== owner)
.sort((a, b) =>
Number(this.#favorites.get(b.owner) === b.machineId)
- Number(this.#favorites.get(a.owner) === a.machineId)
);
}
/** Live connections of one paired machine. */
machine(machineId: string) {
return [...this.#connections.values()].filter(value =>
Expand Down
31 changes: 23 additions & 8 deletions apps/server/src/experiments/pairing-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,17 @@ const MINT_WINDOW_MS = 10 * 60_000;
const MINTS_PER_WINDOW = 10;
const KEY = /^chopin_k_[A-Za-z0-9_-]{43}$/;

/** `autoRun`: whether the machine it pairs lets Chopin build and prototype automatically. */
type Key = { userId: string; sessionId: string; expiresAt: number; autoRun: boolean };
/**
* `autoRun`: whether the machine it pairs lets Chopin build and prototype automatically.
* `shared`: whether it runs teammates' Planners on Default machine.
*/
type Key = {
userId: string;
sessionId: string;
expiresAt: number;
autoRun: boolean;
shared: boolean;
};
type Held = { grant: GitHubTokenGrant; sessionId: string; issuedAt: number; until: number };

function digest(key: string) {
Expand Down Expand Up @@ -71,9 +80,10 @@ export class PairingKeys {

/**
* A new key for `userId`, minted in `sessionId`, replacing the one before it. The machine it
* pairs has automatic work on only with `autoRun`, chosen in the browser that minted it.
* pairs has automatic work on only with `autoRun`, and is shared with teammates only with
* `shared`, both chosen in the browser that minted it.
*/
mint(userId: string, sessionId: string, autoRun = false) {
mint(userId: string, sessionId: string, autoRun = false, shared = false) {
this.sweep();
let now = this.now();
let recent = (this.#mints.get(userId) ?? []).filter(time => time > now - MINT_WINDOW_MS);
Expand All @@ -84,7 +94,7 @@ export class PairingKeys {
let key = `chopin_k_${randomBytes(32).toString("base64url")}`;
let hash = digest(key);
let expiresAt = now + PAIRING_KEY_MS;
this.#keys.set(hash, { userId, sessionId, expiresAt, autoRun });
this.#keys.set(hash, { userId, sessionId, expiresAt, autoRun, shared });
this.#current.set(userId, hash);
// The lifetime too, so a browser whose clock differs from this one counts it right.
return { key, expiresAt, expiresIn: PAIRING_KEY_MS };
Expand All @@ -97,17 +107,22 @@ export class PairingKeys {
peek(key: string) {
let found = KEY.test(key) ? this.#keys.get(digest(key)) : undefined;
if (!found || found.expiresAt <= this.now()) fail("pairing-key-invalid");
return { userId: found.userId, grant: this.#grant(found), autoRun: found.autoRun };
return {
userId: found.userId,
grant: this.#grant(found),
autoRun: found.autoRun,
shared: found.shared,
};
}

/** Spend a key: refused unless it is still live, so of two at once only one gets through. */
take(key: string) {
let { userId, grant, autoRun } = this.peek(key);
let { userId, grant, autoRun, shared } = this.peek(key);
let hash = digest(key);
this.#keys.delete(hash);
if (this.#current.get(userId) === hash) this.#current.delete(userId);
this.#grants.delete(userId);
return { userId, grant, autoRun };
return { userId, grant, autoRun, shared };
}

/**
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/experiments/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,26 @@ test("a key minted with automatic work on pairs a machine with it on, as the pai
expect(await wrong.json()).toEqual({ error: "invalid-request" });
});

test("a key minted shared pairs a machine shared with teammates, and its owner can stop sharing it", async () => {
let { alice, request, authorizeKeys } = await setup();
authorizeKeys("alice");
let minted = await (await request("/api/connector/pairing-keys", { shared: true }, alice.cookie))
.json();
expect((await request("/api/connector/pairings", { ...CHECKOUT, key: minted.key })).status)
.toBe(200);
let listed = await (await request("/api/connector/machines", undefined, alice.cookie)).json();
expect(listed.machines).toMatchObject([{ label: "Laptop", shared: true, autoRun: false }]);
let id = listed.machines[0].id;
let stopped = await request(
`/api/connector/machines/${id}/shared`,
{ shared: false },
alice.cookie,
);
expect(await stopped.json()).toEqual({ shared: false });
listed = await (await request("/api/connector/machines", undefined, alice.cookie)).json();
expect(listed.machines).toMatchObject([{ shared: false }]);
});

test("two machines racing with one pairing key: exactly one pairs", async () => {
let { alice, request, mint, authorizeKeys } = await setup();
authorizeKeys("alice");
Expand Down
Loading
Loading