Skip to content
Merged
309 changes: 194 additions & 115 deletions .github/extensions/replica-prototype/create-hub-client/index.mjs

Large diffs are not rendered by default.

927 changes: 927 additions & 0 deletions .github/extensions/replica-prototype/create-hub-client/index.mts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,67 @@ import { it } from "node:test"

import { createHubClient } from "./index.mjs"

it("keeps an approval readable when Hub omits its refresh token with null", async () => {
const workspace = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
await mkdir(workspace, { recursive: true })
execFileSync("git", ["init", "-b", "prototype/new", workspace], { stdio: "ignore" })
execFileSync("git", [
"-C",
workspace,
"remote",
"add",
"origin",
"git@github.com:team/prototype.git",
])
let pairings = 0
let polls = 0
const server = createServer(async (request, response) => {
response.setHeader("X-Replica-Canvas-Version", "1")
response.setHeader("Content-Type", "application/json")
if (request.url === "/api/canvas/pairings") {
pairings++
response.end(
JSON.stringify({
deviceCode: "device",
userCode: "CODE",
refreshToken: null,
verificationUrl: `http://127.0.0.1:${server.address().port}/canvas/pair/CODE`,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
}),
)
return
}
if (request.url === "/api/canvas/pairings/token") {
polls++
response.end(JSON.stringify({ status: "pending" }))
return
}
response.writeHead(404)
response.end(JSON.stringify({ error: "Unexpected request." }))
})
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
try {
const origin = `http://127.0.0.1:${server.address().port}`
const client = createHubClient(
{ repositoryPath: workspace },
{ origin, directory: path.join(workspace, "credentials") },
)
const pairing = await client.pair()
assert.deepEqual(pairing, {
verificationUrl: `${origin}/canvas/pair/CODE`,
userCode: "CODE",
})
assert.deepEqual(await client.poll(), { status: "pending" })
assert.deepEqual(await client.pair(), pairing)
assert.equal((await client.status()).state, "pairing")
assert.equal(pairings, 1)
assert.equal(polls, 1)
} finally {
await new Promise((resolve) => server.close(resolve))
await rm(workspace, { recursive: true, force: true })
}
})

it("shares approval, serializes refresh, recovers lost responses and stops after revocation", async () => {
const workspace = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
await mkdir(workspace, { recursive: true })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import { rm } from "node:fs/promises"
import path from "node:path"
import { it } from "node:test"

import { createPrivateStore } from "../private-store/index.mjs"
import { createHubClient } from "./index.mjs"

it("rejects malformed stored credentials before using a repository connection", async () => {
const directory = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
const origin = "https://hub.example.com"
const repository = "github/replica"
const file = `${createHash("sha256").update(`${origin}\n${repository}`).digest("hex")}.json`
const store = createPrivateStore(directory)
const client = createHubClient({ repositoryPath: directory }, { origin, directory })
const credential = {
repository,
origin,
token: "fixture-token",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}
try {
for (const value of [
true,
"fixture-token",
[],
{},
{ ...credential, repository: "github/other" },
{ ...credential, origin: "https://other.example.com" },
{ ...credential, token: 42 },
{ ...credential, expiresAt: "not a date" },
{ ...credential, refreshToken: {} },
{ ...credential, connectionId: {} },
{ ...credential, retryAfter: "tomorrow" },
{ ...credential, retryStatus: {} },
]) {
await store.write(file, value)
await assert.rejects(client.connectionKey(repository), /Invalid stored canvas credentials/)
}
await store.write(file, credential)
assert.equal(
await client.connectionKey(repository),
createHash("sha256").update(credential.token).digest("hex"),
)
await store.write(file, { ...credential, connectionId: 42 })
assert.equal(
await client.connectionKey(repository),
createHash("sha256").update("42").digest("hex"),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,16 @@ import { normalizeSelectedHubOrigin } from "../normalize-selected-hub-origin/ind
import { createHubOriginStore } from "../create-hub-origin-store/index.mjs"
import { discoverHub } from "../discover-hub/index.mjs"
import { readCanvasRepository } from "../read-canvas-repository/index.mjs"

export function createHubConnection(getContext, options = {}) {
let hubPromise
let hubRepository
const origins = createHubOriginStore(options)
const selectedOrigins = new Map()
const discoveries = new Map()

function invalidate() {
hubPromise = undefined
hubRepository = undefined
}

async function refreshDiscovery(force = false) {
const context = getContext()
const { repository } = await readCanvasRepository(context)
Expand All @@ -25,7 +22,6 @@ export function createHubConnection(getContext, options = {}) {
invalidate()
return discovery
}

async function hubClient() {
const context = getContext()
const { repository, legacyRepository } = await readCanvasRepository(context)
Expand All @@ -46,7 +42,7 @@ export function createHubConnection(getContext, options = {}) {
).then(async (origin) => {
let selected = origin || options.origin || process.env.REPLICA_HUB_ORIGIN
const discovery = discoveries.get(repository)
if (!selected && discovery?.state === "valid") {
if (!selected && repository && discovery?.state === "valid") {
selected = await origins.save(repository, discovery.origin, { ifAbsent: true })
}
const client = selected ? createHubClient(context, { ...options, origin: selected }) : null
Expand All @@ -64,7 +60,6 @@ export function createHubConnection(getContext, options = {}) {
throw error
}
}

async function selectHubOrigin(value) {
const context = getContext()
const { repository } = await readCanvasRepository(context)
Expand All @@ -84,6 +79,5 @@ export function createHubConnection(getContext, options = {}) {
hubRepository = repository
return { hubOrigin: origin }
}

return { hubClient, refreshDiscovery, selectHubOrigin, discoveries, invalidate }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createHubClient } from "../create-hub-client/index.mjs"
import { InvalidHubOrigin } from "../invalid-hub-origin/index.mjs"
import { normalizeSelectedHubOrigin } from "../normalize-selected-hub-origin/index.mjs"
import { createHubOriginStore } from "../create-hub-origin-store/index.mjs"
import { discoverHub } from "../discover-hub/index.mjs"
import { readCanvasRepository } from "../read-canvas-repository/index.mjs"
import type { HubClient, HubClientOptions, HubContext } from "../create-hub-client/index.mjs"
import type { HubDiscovery, HubDiscoveryOptions } from "../discover-hub/index.mjs"

export type HubConnectionOptions = HubClientOptions & HubDiscoveryOptions
export type HubConnection = ReturnType<typeof createHubConnection>

export function createHubConnection(
getContext: () => HubContext,
options: HubConnectionOptions = {},
) {
let hubPromise: Promise<HubClient | null> | undefined
let hubRepository: string | null | undefined
const origins = createHubOriginStore(options)
const selectedOrigins = new Map<string | null, string | null | undefined>()
const discoveries = new Map<string | null, HubDiscovery>()

function invalidate() {
hubPromise = undefined
hubRepository = undefined
}

async function refreshDiscovery(force = false) {
const context = getContext()
const { repository } = await readCanvasRepository(context)
const discovery = await discoverHub(context, { ...options, force })
discoveries.set(repository, discovery)
invalidate()
return discovery
}

async function hubClient(): Promise<HubClient | null> {
const context = getContext()
const { repository, legacyRepository } = await readCanvasRepository(context)
if (!discoveries.has(repository)) await refreshDiscovery()
if (!hubPromise || hubRepository !== repository) {
const pending: Promise<HubClient | null> = (
selectedOrigins.has(repository)
? Promise.resolve(selectedOrigins.get(repository))
: origins
.read(repository)
.then(
async (origin) =>
origin ||
(legacyRepository && legacyRepository !== repository
? await origins.read(legacyRepository)
: null),
)
).then(async (origin) => {
let selected = origin || options.origin || process.env.REPLICA_HUB_ORIGIN
const discovery = discoveries.get(repository)
if (!selected && repository && discovery?.state === "valid") {
selected = await origins.save(repository, discovery.origin, { ifAbsent: true })
}
const client = selected ? createHubClient(context, { ...options, origin: selected }) : null
if (hubPromise === pending) selectedOrigins.set(repository, selected)
return client
})
hubPromise = pending
hubRepository = repository
}
const pending = hubPromise
try {
return await pending
} catch (error) {
if (hubPromise === pending) hubPromise = undefined
throw error
}
}

async function selectHubOrigin(value: string) {
const context = getContext()
const { repository } = await readCanvasRepository(context)
if (!repository)
throw Object.assign(new Error("Open a repository with a GitHub origin first."), {
statusCode: 409,
})
let origin
try {
origin = await origins.save(repository, normalizeSelectedHubOrigin(value))
} catch (error) {
if (error instanceof InvalidHubOrigin) error.statusCode = 422
throw error
}
selectedOrigins.set(repository, origin)
hubPromise = Promise.resolve(createHubClient(context, { ...options, origin }))
hubRepository = repository
return { hubOrigin: origin }
}

return { hubClient, refreshDiscovery, selectHubOrigin, discoveries, invalidate }
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"

import { normalizeHubOrigin } from "../normalize-hub-origin/index.mjs"
import { createPrivateStore } from "../private-store/index.mjs"

export function createHubOriginStore(options = {}) {
const directory = options.directory || path.join(os.homedir(), ".config", "replica", "canvas")
const store = createPrivateStore(directory)

function file(repository) {
const key = createHash("sha256").update(repository).digest("hex")
return `${key}.origin.json`
}

return {
async read(repository) {
if (!repository) return null
Expand All @@ -22,7 +18,9 @@ export function createHubOriginStore(options = {}) {
if (
!selection ||
typeof selection !== "object" ||
!("repository" in selection) ||
selection.repository !== repository ||
!("origin" in selection) ||
typeof selection.origin !== "string"
) {
throw new Error("Invalid stored canvas Hub selection.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"

import { normalizeHubOrigin } from "../normalize-hub-origin/index.mjs"
import { createPrivateStore } from "../private-store/index.mjs"

export type HubOriginStoreOptions = { directory?: string }

export function createHubOriginStore(options: HubOriginStoreOptions = {}) {
const directory = options.directory || path.join(os.homedir(), ".config", "replica", "canvas")
const store = createPrivateStore(directory)

function file(repository: string) {
const key = createHash("sha256").update(repository).digest("hex")
return `${key}.origin.json`
}

return {
async read(repository: string | null | undefined) {
if (!repository) return null
const selection = await store.read(file(repository))
if (!selection) return null
if (
!selection ||
typeof selection !== "object" ||
!("repository" in selection) ||
selection.repository !== repository ||
!("origin" in selection) ||
typeof selection.origin !== "string"
) {
throw new Error("Invalid stored canvas Hub selection.")
}
return normalizeHubOrigin(selection.origin, "Stored Hub URL")
},
async save(repository: string, value: string, { ifAbsent = false } = {}): Promise<string> {
const origin = normalizeHubOrigin(value, "Hub URL")
return store.lock(file(repository), async () => {
const current = ifAbsent ? await this.read(repository) : null
if (current) return current
await store.write(file(repository), { repository, origin })
return origin
})
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ import { InvalidHubOrigin } from "../invalid-hub-origin/index.mjs"
import { createHubOriginStore } from "./index.mjs"

describe("createHubOriginStore()", () => {
it("rejects parsed selections without a matching repository and string origin", async () => {
const directory = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
const store = createHubOriginStore({ directory })
const repository = "github/replica"
try {
await store.save(repository, "https://hub.example.com")
const file = path.join(directory, (await readdir(directory))[0])
for (const value of [
true,
"https://hub.example.com",
[],
{},
{ repository, origin: 42 },
{ repository: "github/other", origin: "https://hub.example.com" },
]) {
await writeFile(file, JSON.stringify(value))
await assert.rejects(store.read(repository), /Invalid stored canvas Hub selection/)
}
} finally {
await rm(directory, { recursive: true, force: true })
}
})

it("keeps saved and stored origins strict outside manual selection", async () => {
const directory = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
const store = createHubOriginStore({ directory })
Expand Down
Loading
Loading