mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
flush hocuspocus changes on branch fork, merge
This commit is contained in:
parent
c62b791630
commit
18dd2cdc8b
|
|
@ -157,7 +157,7 @@ Component build/test/qa commands are listed in the nested CLAUDE.md files.
|
|||
## Cross-component contracts
|
||||
|
||||
- **Front door**: Caddy on host `:8080`. `/core/*` is path-stripped and proxied to core (`:8080`); `/auth-realtime/*` is path-stripped and proxied to auth-realtime (`:8081`, so `/auth-realtime/api/...` and `/auth-realtime/hocuspocus` publicly); everything else goes to the web SSR container. The frontend reaches the core API via `NUXT_PUBLIC_CORE_API_BASE_HTTP_URL` (`…:8080/core`) and auth/realtime via `NUXT_PUBLIC_AUTH_REALTIME_API_BASE_*_URL` (`…:8080/auth-realtime`).
|
||||
- **Trust boundary**: core has four surfaces. `/api/...` requires a session. `/api/x/...` (core) and `/api/internal/...` (auth-realtime) have no auth and must never be exposed by the reverse proxy (the Caddyfile blocks both at the front door) — they exist for service-to-service calls (auth-realtime ↔ core). `/api/apps/...` is public and sessionless: GitHub and Slack deliver webhooks and OAuth callbacks there, and every request proves itself with the provider's signature or an encrypted state parameter. `/api/mcp` is the MCP surface: OAuth 2.1 bearer tokens issued by auth-realtime (which is the authorization server via `@better-auth/mcp`), validated per request against auth-realtime's internal MCP session endpoint; the Caddyfile also routes the front-door `/.well-known/oauth-*` discovery documents and `/api/auth/*` OAuth endpoints to auth-realtime.
|
||||
- **Trust boundary**: core has four surfaces. `/api/...` requires a session. `/api/x/...` (core) and `/api/internal/...` (auth-realtime) have no auth and must never be exposed by the reverse proxy (the Caddyfile blocks both at the front door) — they exist for service-to-service calls (auth-realtime ↔ core). Three of core's `/api/x` routes are internal *and* session-authed: fork (`POST .../branches`), branch update (`PUT .../branches/{branchId}`) and merge (`PUT .../merge`) act for a person but are reachable only through auth-realtime, which first flushes the branch's pending Hocuspocus store so core reads what the editors show, forwards the caller's session for core to authorize, and resets the branch's connections after a protection change. `/api/apps/...` is public and sessionless: GitHub and Slack deliver webhooks and OAuth callbacks there, and every request proves itself with the provider's signature or an encrypted state parameter. `/api/mcp` is the MCP surface: OAuth 2.1 bearer tokens issued by auth-realtime (which is the authorization server via `@better-auth/mcp`), validated per request against auth-realtime's internal MCP session endpoint; the Caddyfile also routes the front-door `/.well-known/oauth-*` discovery documents and `/api/auth/*` OAuth endpoints to auth-realtime.
|
||||
- **Session validation**: core's auth middleware validates sessions by calling auth-realtime's `/api/auth/get-session`; auth-realtime owns the Better Auth schema.
|
||||
- **Yjs invariant**: the Hocuspocus `documentName` is `"<documentId>-<branchIdentifier>"`, split on the first `-`. Never seed or merge one Y.Doc from another with `Y.applyUpdate` — use `replaceYdocContent` (`server/auth-realtime/src/ydocument.ts`). Read the document-storage section of [server/CLAUDE.md](server/CLAUDE.md) before touching branch content anywhere.
|
||||
- **System writes**: a protected branch refuses every persist but core's own. Core marks the batches it originates with `edit.Client.Apply`'s `system` argument, which only a caller acting for core itself may set, auth-realtime opens the direct connection under that origin, and `onStoreDocument` marks the persist system only while every change in the window came from core. Protection itself is enforced where a write enters the document — `onAuthenticate` makes a connection to a protected branch read-only — because the persist carries whole-document state and could otherwise smuggle an edit it never authorised.
|
||||
|
|
|
|||
|
|
@ -25,9 +25,8 @@ export function branchSwitcher(page: Page): Locator {
|
|||
|
||||
// makeReviewable turns the open page into a reviewable one through the
|
||||
// navbar options menu, which creates the draft branch and protects main.
|
||||
// The page's current content must already be persisted if the draft is
|
||||
// expected to start from it: the draft is seeded from the stored
|
||||
// content, not the live document.
|
||||
// makeReviewable enables the review workflow from the navbar menu and
|
||||
// waits for the branch switcher that appears once the draft exists.
|
||||
export async function makeReviewable(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: t("editor.navbar.open-document-options") })
|
||||
|
|
|
|||
|
|
@ -68,6 +68,24 @@ const CORE_INTERNAL_ENDPOINTS = [
|
|||
path: "/core/api/x/documents/doc-e2e/branch/branch-e2e/",
|
||||
},
|
||||
{ name: "outgoing email", method: "POST", path: "/core/api/x/email" },
|
||||
// session-authed, but internal all the same: auth-realtime stores the
|
||||
// editors' pending changes before core reads the branch, and a client
|
||||
// reaching these directly would skip that.
|
||||
{
|
||||
name: "a branch merge",
|
||||
method: "PUT",
|
||||
path: "/core/api/x/documents/doc-e2e/merge",
|
||||
},
|
||||
{
|
||||
name: "a branch fork",
|
||||
method: "POST",
|
||||
path: "/core/api/x/documents/doc-e2e/branches",
|
||||
},
|
||||
{
|
||||
name: "a branch protection change",
|
||||
method: "PUT",
|
||||
path: "/core/api/x/documents/doc-e2e/branches/branch-e2e",
|
||||
},
|
||||
] as const
|
||||
|
||||
const AUTH_REALTIME_INTERNAL_ENDPOINTS = [
|
||||
|
|
|
|||
|
|
@ -228,6 +228,59 @@ test.describe("review workflow", () => {
|
|||
.toBe("Base text with the draft merged in")
|
||||
})
|
||||
|
||||
// the store behind the editor is debounced, and enabling the workflow
|
||||
// forks the page from what is stored: text typed just before has to be
|
||||
// stored first or the draft starts without it and main's own store of
|
||||
// it is refused once main is protected. No documentPersisted() here on
|
||||
// purpose — the toggle lands inside the debounce window.
|
||||
test("keeps text typed just before enabling review on both branches", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await signUpWithWorkspace(page, request)
|
||||
await createDocument(page)
|
||||
const url = page.url()
|
||||
await contentEditor(page).click()
|
||||
await page.keyboard.type("Typed right before review")
|
||||
await makeReviewable(page)
|
||||
|
||||
await visit(page, url)
|
||||
|
||||
await waitForEditor(page)
|
||||
await expect
|
||||
.poll(() => editorText(contentEditor(page)))
|
||||
.toBe("Typed right before review")
|
||||
await switchToBranch(page, "draft")
|
||||
await expect
|
||||
.poll(() => editorText(contentEditor(page)))
|
||||
.toBe("Typed right before review")
|
||||
})
|
||||
|
||||
// the merge reads the draft as stored, so text typed just before it
|
||||
// has to be stored first. No documentPersisted() before the merge on
|
||||
// purpose.
|
||||
test("keeps text typed just before the merge", async ({ page, request }) => {
|
||||
await signUpWithWorkspace(page, request)
|
||||
await createDocument(page)
|
||||
const url = page.url()
|
||||
await contentEditor(page).click()
|
||||
await page.keyboard.type("Base text")
|
||||
await makeReviewable(page)
|
||||
await switchToBranch(page, "draft")
|
||||
await expect.poll(() => editorText(contentEditor(page))).toBe("Base text")
|
||||
await contentEditor(page).click()
|
||||
await page.keyboard.press("End")
|
||||
await page.keyboard.type(" merged right away")
|
||||
await mergeDraftIntoMain(page)
|
||||
|
||||
await visit(page, url)
|
||||
|
||||
await waitForEditor(page)
|
||||
await expect
|
||||
.poll(() => editorText(contentEditor(page)))
|
||||
.toBe("Base text merged right away")
|
||||
})
|
||||
|
||||
test("keeps the merged content after a reload", async ({ page, request }) => {
|
||||
await signUpWithWorkspace(page, request)
|
||||
await createDocument(page)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ Every organization is seeded at initialization with a Prometheus data source nam
|
|||
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments on `onLoadDocument` in `server/auth-realtime/src/hocuspocus.ts` and on `applyMergeToOpenDocument` in `server/auth-realtime/src/routes.ts` before touching this area.
|
||||
- **The main branch is identified by its `"default"` flag, not by its name.** The tree join and `FetchMainBranchContent` key on the flag; `UpdateDocumentBranch` refuses to rename the default branch so the two never disagree. Anything that creates a document's first branch — `NewDocument`, `Duplicate` — must set `Default: true`, and forks must not.
|
||||
- **Maintainers accumulate; nothing removes them.** The `maintainers` field on a branch update is *who edited in that persist*, not the document's maintainer set — auth-realtime's store hook sends the current session's editors and system writes send none. `UpsertDocumentMaintainers` only ever adds, so a diff against the stored set would drop everyone not editing at that moment. Removing a maintainer needs a path of its own; there is none today.
|
||||
- Branch merging: `PUT /documents/:documentId/merge` is handled by auth-realtime, which proxies to core, then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
|
||||
- **Fork, branch update and merge go through auth-realtime.** The web client calls `POST /documents/:documentId/branches`, `PUT /documents/:documentId/branches/:branchId` and `PUT /documents/:documentId/merge` on auth-realtime, which flushes the affected branch's pending Hocuspocus store (both sides for a merge; the `-default` alias too for the default branch) and refuses the operation if that store fails, then proxies to core's session-authed group under `/api/x` with the caller's headers. Core reads the branch's stored row, so without the flush a fork copies stale content, a merge reads it, and a protect toggle makes the pending user store be refused. After core confirms a protection change, auth-realtime drops the socket of every client on the branch's document (`resetConnections` in `hocuspocus.ts`): read-only is decided per connection in `onAuthenticate`, so each client reconnects with the permission the branch now has. Hocuspocus's own `closeConnections` is not that: it sends a close *message* on the still-open multiplexed socket, and the provider leaves the document closed until the page reloads. A merge then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
|
||||
|
||||
## External resource lifecycle (files, hooks)
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ src/
|
|||
everything the service writes goes through
|
||||
headers.ts normalizing whatever shape a transport hands over
|
||||
auth.ts createAuth(deps) + the callbacks better-auth invokes
|
||||
hocuspocus.ts createHocuspocus(deps) / createDocumentHooks(deps)
|
||||
hocuspocus.ts createHocuspocus(deps) / createDocumentHooks(deps); the
|
||||
flushDocument the branch routes run before core reads a
|
||||
branch's stored row
|
||||
routes.ts createRoutes(deps) -> a Hono app
|
||||
operations.ts pure: applying the assistant's edit ops to a Y.Doc
|
||||
ydocument.ts pure: replacing a Y.Doc's content without CRDT merging
|
||||
|
|
|
|||
|
|
@ -230,10 +230,10 @@ describe("createCoreClient", () => {
|
|||
})
|
||||
|
||||
describe("mergeBranches", () => {
|
||||
// the merge is the one call that goes to core's session-authed
|
||||
// surface rather than /api/x, because core decides from the
|
||||
// forwarded headers whether the caller may merge
|
||||
it("puts to core's public merge route with the caller's headers", async ({
|
||||
// the route is internal so nothing but this service reaches it,
|
||||
// yet core still decides from the forwarded headers whether the
|
||||
// caller may merge
|
||||
it("puts to core's internal merge route with the caller's headers", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp({
|
||||
|
|
@ -256,7 +256,7 @@ describe("createCoreClient", () => {
|
|||
|
||||
expect(http.put).toHaveBeenCalledTimes(1)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/documents/doc-1/merge",
|
||||
"http://core:8080/api/x/documents/doc-1/merge",
|
||||
{
|
||||
fromBranchId: "branch-2",
|
||||
toBranchId: "branch-1",
|
||||
|
|
@ -280,4 +280,67 @@ describe("createCoreClient", () => {
|
|||
expect(result.status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBranch", () => {
|
||||
it("posts the body untouched to core's internal branches route with the caller's headers", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp({
|
||||
status: 200,
|
||||
data: { branchId: "branch-2" },
|
||||
})
|
||||
const headers = new AxiosHeaders()
|
||||
headers.set("cookie", "auth.session=abc")
|
||||
const body = {
|
||||
branch: "draft",
|
||||
sourceBranchId: "branch-1",
|
||||
}
|
||||
|
||||
const result = await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).createBranch("doc-1", { body }, { headers })
|
||||
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/documents/doc-1/branches",
|
||||
body,
|
||||
{ headers },
|
||||
)
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.data).toEqual({ branchId: "branch-2" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateBranch", () => {
|
||||
it("puts the body untouched to core's internal branch route with the caller's headers", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp({
|
||||
status: 200,
|
||||
data: { branchId: "branch-1" },
|
||||
})
|
||||
const headers = new AxiosHeaders()
|
||||
headers.set("cookie", "auth.session=abc")
|
||||
const body = { protected: true }
|
||||
|
||||
const result = await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).updateBranch(
|
||||
"doc-1",
|
||||
"branch-1",
|
||||
{ body },
|
||||
{ headers },
|
||||
)
|
||||
|
||||
expect(http.put).toHaveBeenCalledTimes(1)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/documents/doc-1/branches/branch-1",
|
||||
body,
|
||||
{ headers },
|
||||
)
|
||||
expect(result.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ export interface MergedDocument {
|
|||
icon: string
|
||||
}
|
||||
|
||||
// the branch requests this service forwards for the web client. Their
|
||||
// bodies are core's to validate, so they travel through untyped.
|
||||
interface BranchRequest {
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
headers?: AxiosHeaders
|
||||
}
|
||||
|
|
@ -105,6 +111,17 @@ export interface CoreClient {
|
|||
toBranchId: string,
|
||||
options: RequestOptions,
|
||||
): Promise<{ status: number; data: MergedDocument }>
|
||||
createBranch(
|
||||
documentId: string,
|
||||
request: BranchRequest,
|
||||
options: RequestOptions,
|
||||
): Promise<HttpResponse>
|
||||
updateBranch(
|
||||
documentId: string,
|
||||
branchId: string,
|
||||
request: BranchRequest,
|
||||
options: RequestOptions,
|
||||
): Promise<HttpResponse>
|
||||
}
|
||||
|
||||
export function createCoreClient(
|
||||
|
|
@ -167,9 +184,9 @@ export function createCoreClient(
|
|||
)
|
||||
},
|
||||
|
||||
// the merge itself goes through core's session-authed surface,
|
||||
// not /api/x: the caller's own headers are forwarded so core
|
||||
// decides whether they may merge.
|
||||
// the branch operations live on /api/x so that only this service
|
||||
// can reach them, but core still authorizes them from the
|
||||
// caller's own headers, which is why those are forwarded.
|
||||
async mergeBranches(
|
||||
documentId,
|
||||
fromBranchId,
|
||||
|
|
@ -177,7 +194,7 @@ export function createCoreClient(
|
|||
options,
|
||||
) {
|
||||
const response = await http.put(
|
||||
`${baseUrl}/api/documents/${documentId}/merge`,
|
||||
`${internal}/documents/${documentId}/merge`,
|
||||
{ fromBranchId, toBranchId },
|
||||
options,
|
||||
)
|
||||
|
|
@ -187,5 +204,21 @@ export function createCoreClient(
|
|||
data: response.data as MergedDocument,
|
||||
}
|
||||
},
|
||||
|
||||
async createBranch(documentId, request, options) {
|
||||
return http.post(
|
||||
`${internal}/documents/${documentId}/branches`,
|
||||
request.body,
|
||||
options,
|
||||
)
|
||||
},
|
||||
|
||||
async updateBranch(documentId, branchId, request, options) {
|
||||
return http.put(
|
||||
`${internal}/documents/${documentId}/branches/${branchId}`,
|
||||
request.body,
|
||||
options,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
createDocumentHooks,
|
||||
createHocuspocus,
|
||||
parseDocumentName,
|
||||
resetConnections,
|
||||
resolveBranchId,
|
||||
type AuthSession,
|
||||
} from "./hocuspocus.js"
|
||||
|
|
@ -874,6 +875,246 @@ describe("createDocumentHooks", () => {
|
|||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("flushDocument", () => {
|
||||
// a server holding one open document whose store may be pending
|
||||
// or running. executeNow stands in for hocuspocus running the
|
||||
// debounced store: it calls the hook the way the server would.
|
||||
function stubServer(
|
||||
hooks: ReturnType<typeof hooksWith>,
|
||||
document: ConnectedDocument | null,
|
||||
state: "pending" | "running" | "idle",
|
||||
) {
|
||||
const documents = new Map<string, ConnectedDocument>()
|
||||
if (document) {
|
||||
documents.set("doc1-branch1", document)
|
||||
}
|
||||
|
||||
const executeNow = vi.fn(() =>
|
||||
document
|
||||
? hooks.onStoreDocument({
|
||||
documentName:
|
||||
"doc1-branch1",
|
||||
document,
|
||||
})
|
||||
: undefined,
|
||||
)
|
||||
|
||||
return {
|
||||
server: {
|
||||
documents,
|
||||
debouncer: {
|
||||
isDebounced: () =>
|
||||
state === "pending",
|
||||
isCurrentlyExecuting: () =>
|
||||
state === "running",
|
||||
executeNow,
|
||||
},
|
||||
},
|
||||
executeNow,
|
||||
}
|
||||
}
|
||||
|
||||
it("runs the pending store now and resolves once core has it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
const { server, executeNow } = stubServer(
|
||||
hooks,
|
||||
seededDocument("Runbook"),
|
||||
"pending",
|
||||
)
|
||||
|
||||
await hooks.flushDocument(server, "doc1-branch1")
|
||||
|
||||
expect(executeNow).toHaveBeenCalledWith(
|
||||
"onStoreDocument-doc1-branch1",
|
||||
)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
|
||||
const [, , update] =
|
||||
core.storeBranchContent.mock.calls[0] ?? []
|
||||
expect(update?.name).toBe("Runbook")
|
||||
})
|
||||
|
||||
it("leaves a document that is not open alone", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
const { server, executeNow } = stubServer(
|
||||
hooks,
|
||||
null,
|
||||
"pending",
|
||||
)
|
||||
|
||||
await hooks.flushDocument(server, "doc1-branch1")
|
||||
|
||||
expect(executeNow).toHaveBeenCalledTimes(0)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("leaves a document with nothing pending alone", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
const { server, executeNow } = stubServer(
|
||||
hooks,
|
||||
seededDocument(),
|
||||
"idle",
|
||||
)
|
||||
|
||||
await hooks.flushDocument(server, "doc1-branch1")
|
||||
|
||||
expect(executeNow).toHaveBeenCalledTimes(0)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// the store hook swallows the failure to warn the editors; the
|
||||
// flush is the one caller that needs to see it.
|
||||
it("rejects with the store's failure and still warns the editors", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.storeBranchContent.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const hooks = hooksWith(core)
|
||||
const document = seededDocument()
|
||||
const { server } = stubServer(
|
||||
hooks,
|
||||
document,
|
||||
"pending",
|
||||
)
|
||||
|
||||
await expect(
|
||||
hooks.flushDocument(server, "doc1-branch1"),
|
||||
).rejects.toThrow("core unreachable")
|
||||
expect(
|
||||
document.broadcastStateless,
|
||||
).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
code: "hocuspocus.store_failed",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("waits for a store already in flight", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
let finish: () => void = () => undefined
|
||||
core.storeBranchContent.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
const hooks = hooksWith(core)
|
||||
const document = seededDocument()
|
||||
const { server, executeNow } = stubServer(
|
||||
hooks,
|
||||
document,
|
||||
"running",
|
||||
)
|
||||
const inFlight = hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document,
|
||||
})
|
||||
|
||||
let flushed = false
|
||||
const flush = hooks
|
||||
.flushDocument(server, "doc1-branch1")
|
||||
.then(() => {
|
||||
flushed = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(flushed).toBe(false)
|
||||
finish()
|
||||
await Promise.all([inFlight, flush])
|
||||
expect(flushed).toBe(true)
|
||||
expect(executeNow).toHaveBeenCalledTimes(0)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// a persist that failed before the flush was asked for is not
|
||||
// the flush's answer: nothing is pending, so nothing is awaited.
|
||||
it("does not report a failure that predates it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.storeBranchContent.mockRejectedValueOnce(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const hooks = hooksWith(core)
|
||||
const document = seededDocument()
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document,
|
||||
})
|
||||
const { server } = stubServer(hooks, document, "idle")
|
||||
|
||||
await expect(
|
||||
hooks.flushDocument(server, "doc1-branch1"),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetConnections", () => {
|
||||
function socket() {
|
||||
return { webSocket: { close: vi.fn() } }
|
||||
}
|
||||
|
||||
// the provider reopens a document after losing its socket, which is
|
||||
// what makes it authenticate again and pick up the branch's new
|
||||
// permission
|
||||
it("closes every client's socket with the reset code", ({ expect }) => {
|
||||
const first = socket()
|
||||
const second = socket()
|
||||
const server = {
|
||||
documents: new Map([
|
||||
[
|
||||
"doc1-branch1",
|
||||
{
|
||||
connections: new Map([
|
||||
[first, {}],
|
||||
[second, {}],
|
||||
]),
|
||||
},
|
||||
],
|
||||
]),
|
||||
}
|
||||
|
||||
resetConnections(server, "doc1-branch1")
|
||||
|
||||
expect(first.webSocket.close).toHaveBeenCalledWith(
|
||||
4205,
|
||||
"Reset Connection",
|
||||
)
|
||||
expect(second.webSocket.close).toHaveBeenCalledWith(
|
||||
4205,
|
||||
"Reset Connection",
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves other documents' sockets alone", ({ expect }) => {
|
||||
const other = socket()
|
||||
const server = {
|
||||
documents: new Map([
|
||||
[
|
||||
"doc1-branch2",
|
||||
{ connections: new Map([[other, {}]]) },
|
||||
],
|
||||
]),
|
||||
}
|
||||
|
||||
resetConnections(server, "doc1-branch1")
|
||||
|
||||
expect(other.webSocket.close).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createHocuspocus", () => {
|
||||
|
|
@ -881,14 +1122,14 @@ describe("createHocuspocus", () => {
|
|||
expect,
|
||||
}) => {
|
||||
const log = stubLog()
|
||||
const server = createHocuspocus({
|
||||
const { instance } = createHocuspocus({
|
||||
auth: stubAuth(),
|
||||
core: stubCore(),
|
||||
log,
|
||||
})
|
||||
// the extension keeps its sink in its own configuration; the
|
||||
// server exposes no other way to reach it.
|
||||
const logger = server.configuration.extensions[0] as {
|
||||
const logger = instance.configuration.extensions[0] as {
|
||||
configuration: { log: (...args: unknown[]) => void }
|
||||
}
|
||||
|
||||
|
|
@ -901,20 +1142,46 @@ describe("createHocuspocus", () => {
|
|||
})
|
||||
|
||||
it("wires the document hooks onto the server", ({ expect }) => {
|
||||
const server = createHocuspocus({
|
||||
const { instance } = createHocuspocus({
|
||||
auth: stubAuth(),
|
||||
core: stubCore(),
|
||||
log: stubLog(),
|
||||
})
|
||||
|
||||
expect(typeof server.configuration.onAuthenticate).toBe(
|
||||
expect(typeof instance.configuration.onAuthenticate).toBe(
|
||||
"function",
|
||||
)
|
||||
expect(typeof server.configuration.onLoadDocument).toBe(
|
||||
expect(typeof instance.configuration.onLoadDocument).toBe(
|
||||
"function",
|
||||
)
|
||||
expect(typeof server.configuration.onStoreDocument).toBe(
|
||||
expect(typeof instance.configuration.onStoreDocument).toBe(
|
||||
"function",
|
||||
)
|
||||
})
|
||||
|
||||
// the flush is bound to the server it came with: a document the
|
||||
// server does not hold cannot have a pending store to run.
|
||||
it("flushes against its own server", async ({ expect }) => {
|
||||
const core = stubCore()
|
||||
const { flushDocument } = createHocuspocus({
|
||||
auth: stubAuth(),
|
||||
core,
|
||||
log: stubLog(),
|
||||
})
|
||||
|
||||
await flushDocument("doc1-branch1")
|
||||
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
it("resets against its own server", ({ expect }) => {
|
||||
const { resetConnections } = createHocuspocus({
|
||||
auth: stubAuth(),
|
||||
core: stubCore(),
|
||||
log: stubLog(),
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
resetConnections("doc1-branch1")
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,6 +36,64 @@ interface ConnectedDocument extends Y.Doc {
|
|||
broadcastStateless(payload: string): void
|
||||
}
|
||||
|
||||
// the part of the hocuspocus server a flush reaches into: which documents
|
||||
// are open, and the debouncer that holds their pending stores.
|
||||
interface FlushableServer {
|
||||
documents: { get(documentName: string): unknown }
|
||||
debouncer: {
|
||||
isDebounced(id: string): boolean
|
||||
isCurrentlyExecuting(id: string): boolean
|
||||
executeNow(id: string): unknown
|
||||
}
|
||||
}
|
||||
|
||||
// the part a reset reaches into: the socket behind each of a document's
|
||||
// client connections.
|
||||
interface ResettableServer {
|
||||
documents: {
|
||||
get(documentName: string):
|
||||
| {
|
||||
connections: Map<
|
||||
{
|
||||
webSocket: {
|
||||
close(
|
||||
code?: number,
|
||||
reason?: string,
|
||||
): void
|
||||
}
|
||||
},
|
||||
unknown
|
||||
>
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
|
||||
// the close code hocuspocus itself uses when it resets a connection.
|
||||
const resetConnection = { code: 4205, reason: "Reset Connection" }
|
||||
|
||||
// resetConnections drops the socket of every client on a document, so each
|
||||
// reconnects and authenticates again. Hocuspocus's own closeConnections
|
||||
// only tells the provider the document is closed, and the provider leaves
|
||||
// it closed: it reopens a document after losing its socket, not after that
|
||||
// message. Direct connections have no socket and are unaffected.
|
||||
export function resetConnections(
|
||||
server: ResettableServer,
|
||||
documentName: string,
|
||||
): void {
|
||||
const document = server.documents.get(documentName)
|
||||
if (!document) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [connection] of document.connections) {
|
||||
connection.webSocket.close(
|
||||
resetConnection.code,
|
||||
resetConnection.reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// parseDocumentName splits a document name of the form
|
||||
// "documentId-branchIdentifier" into its component parts. The split is on
|
||||
// the first dash: xids carry none, but a branch identifier may.
|
||||
|
|
@ -161,6 +219,12 @@ export function createDocumentHooks({ auth, core }: DocumentHookDeps) {
|
|||
// only ever lent to changes seen arriving under it.
|
||||
const documentSystemOnly = new Map<string, boolean>()
|
||||
|
||||
// the most recent persist of each document, settled or not. A flush
|
||||
// awaits the one it triggered or found in flight, which is how a
|
||||
// failure onStoreDocument swallows for the editors' sake still
|
||||
// reaches the caller that needed the content stored.
|
||||
const lastPersist = new Map<string, Promise<void>>()
|
||||
|
||||
return {
|
||||
async onAuthenticate({
|
||||
connectionConfig,
|
||||
|
|
@ -346,7 +410,7 @@ export function createDocumentHooks({ auth, core }: DocumentHookDeps) {
|
|||
|
||||
documentSystemOnly.delete(data.documentName)
|
||||
|
||||
try {
|
||||
const persist = (async () => {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(data.documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
|
|
@ -376,6 +440,12 @@ export function createDocumentHooks({ auth, core }: DocumentHookDeps) {
|
|||
system,
|
||||
},
|
||||
)
|
||||
})()
|
||||
|
||||
lastPersist.set(data.documentName, persist)
|
||||
|
||||
try {
|
||||
await persist
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
|
||||
|
|
@ -387,14 +457,48 @@ export function createDocumentHooks({ auth, core }: DocumentHookDeps) {
|
|||
)
|
||||
}
|
||||
},
|
||||
|
||||
// flushDocument runs the document's pending store now and resolves
|
||||
// once core has it, or rejects with what the store failed on. A
|
||||
// document that is not open, or has nothing pending, is left
|
||||
// alone: only a persist this call triggered or found in flight is
|
||||
// awaited, so an old failure cannot block a later caller.
|
||||
flushDocument: async (
|
||||
server: FlushableServer,
|
||||
documentName: string,
|
||||
): Promise<void> => {
|
||||
if (!server.documents.get(documentName)) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = `onStoreDocument-${documentName}`
|
||||
|
||||
if (server.debouncer.isDebounced(id)) {
|
||||
// runs onStoreDocument under the document's save
|
||||
// mutex, after any persist already in flight.
|
||||
await server.debouncer.executeNow(id)
|
||||
} else if (!server.debouncer.isCurrentlyExecuting(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
await lastPersist.get(documentName)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface HocuspocusServer {
|
||||
instance: Hocuspocus
|
||||
flushDocument: (documentName: string) => Promise<void>
|
||||
resetConnections: (documentName: string) => void
|
||||
}
|
||||
|
||||
export function createHocuspocus({
|
||||
log,
|
||||
...hooks
|
||||
}: DocumentHookDeps & { log: ServiceLogger }): Hocuspocus {
|
||||
return new Hocuspocus({
|
||||
...deps
|
||||
}: DocumentHookDeps & { log: ServiceLogger }): HocuspocusServer {
|
||||
const { flushDocument, ...hooks } = createDocumentHooks(deps)
|
||||
|
||||
const instance = new Hocuspocus({
|
||||
// a line per connection, load, change and persist: a traffic
|
||||
// trace, so DEBUG.
|
||||
extensions: [
|
||||
|
|
@ -410,6 +514,15 @@ export function createHocuspocus({
|
|||
},
|
||||
}),
|
||||
],
|
||||
...createDocumentHooks(hooks),
|
||||
...hooks,
|
||||
})
|
||||
|
||||
return {
|
||||
instance,
|
||||
flushDocument: (documentName) =>
|
||||
flushDocument(instance, documentName),
|
||||
resetConnections: (documentName) => {
|
||||
resetConnections(instance, documentName)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ if (env.valkeyDsn) {
|
|||
|
||||
const auth = createAuth({ env, store, dialect, redis, core, log })
|
||||
|
||||
const hocuspocus = createHocuspocus({
|
||||
const {
|
||||
instance: hocuspocus,
|
||||
flushDocument,
|
||||
resetConnections,
|
||||
} = createHocuspocus({
|
||||
auth: { getSession: (input) => auth.api.getSession(input) },
|
||||
core,
|
||||
log,
|
||||
|
|
@ -50,7 +54,18 @@ const app = new Hono()
|
|||
// the adapter's own state.
|
||||
const nodeWs = createNodeWebSocket({ app })
|
||||
|
||||
app.route("/api", createRoutes({ env, auth, store, hocuspocus, core }))
|
||||
app.route(
|
||||
"/api",
|
||||
createRoutes({
|
||||
env,
|
||||
auth,
|
||||
store,
|
||||
hocuspocus,
|
||||
flushDocument,
|
||||
resetConnections,
|
||||
core,
|
||||
}),
|
||||
)
|
||||
|
||||
// the RFC 9728 protected-resource metadata for the MCP surface is served
|
||||
// by the mcp plugin's request hook, which matches root-anchored
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, vi } from "vitest"
|
||||
import { describe, it, vi, type Mock } from "vitest"
|
||||
import * as Y from "yjs"
|
||||
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from "jose"
|
||||
import { createRoutes, type DocumentRegistry } from "./routes.js"
|
||||
|
|
@ -64,21 +64,58 @@ function build(
|
|||
auth?: ReturnType<typeof stubAuth>
|
||||
store?: StubStore
|
||||
hocuspocus?: DocumentRegistry
|
||||
flushDocument?: Mock<(documentName: string) => Promise<void>>
|
||||
core?: StubCore
|
||||
} = {},
|
||||
) {
|
||||
const auth = overrides.auth ?? stubAuth()
|
||||
const core = overrides.core ?? stubCore()
|
||||
const store = overrides.store ?? stubStore()
|
||||
const flushDocument: Mock<(documentName: string) => Promise<void>> =
|
||||
overrides.flushDocument ?? vi.fn().mockResolvedValue(undefined)
|
||||
const resetConnections = vi.fn<(documentName: string) => void>()
|
||||
const app = createRoutes({
|
||||
env: overrides.env ?? ENV,
|
||||
auth,
|
||||
store,
|
||||
hocuspocus: overrides.hocuspocus ?? stubRegistry().registry,
|
||||
flushDocument,
|
||||
resetConnections,
|
||||
core,
|
||||
})
|
||||
|
||||
return { app, auth, core, store }
|
||||
return { app, auth, core, store, flushDocument, resetConnections }
|
||||
}
|
||||
|
||||
// the order in which the flushes and the core call happened, which is
|
||||
// what the branch routes are about: the flush has to come first.
|
||||
function callOrder(flushDocument: Mock, coreCall: Mock): string[] {
|
||||
return [
|
||||
...flushDocument.mock.invocationCallOrder.map(
|
||||
(order, i) =>
|
||||
`${order}:flush ${String(flushDocument.mock.calls[i]?.[0])}`,
|
||||
),
|
||||
...coreCall.mock.invocationCallOrder.map(
|
||||
(order) => `${order}:core`,
|
||||
),
|
||||
]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(a.split(":")[0]) -
|
||||
Number(b.split(":")[0]),
|
||||
)
|
||||
.map((entry) => entry.slice(entry.indexOf(":") + 1))
|
||||
}
|
||||
|
||||
function json(body: unknown, cookie?: string): RequestInit {
|
||||
return {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function seededDocument(text: string): Y.Doc {
|
||||
|
|
@ -216,7 +253,352 @@ describe("createRoutes", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("POST /documents/:documentId/branches", () => {
|
||||
it.for([
|
||||
{ name: "a body that is not JSON", input: "not json" },
|
||||
{
|
||||
name: "a body missing sourceBranchId",
|
||||
input: '{"branch":"draft"}',
|
||||
},
|
||||
{
|
||||
name: "a sourceBranchId that is not a string",
|
||||
input: '{"branch":"draft","sourceBranchId":1}',
|
||||
},
|
||||
])("rejects $name", async ({ input }, { expect }) => {
|
||||
const { app, core, flushDocument } = build()
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches",
|
||||
{
|
||||
...json(null),
|
||||
method: "POST",
|
||||
body: input,
|
||||
},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(flushDocument).toHaveBeenCalledTimes(0)
|
||||
expect(core.createBranch).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("flushes the source branch before core copies it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app, core, flushDocument } = build()
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches",
|
||||
{
|
||||
...json(
|
||||
{
|
||||
branch: "draft",
|
||||
sourceBranchId: "b1",
|
||||
},
|
||||
"auth.session=abc",
|
||||
),
|
||||
method: "POST",
|
||||
},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
branchId: "branch-2",
|
||||
})
|
||||
expect(
|
||||
callOrder(flushDocument, core.createBranch),
|
||||
).toEqual(["flush doc1-b1", "core"])
|
||||
const [documentId, request, options] =
|
||||
core.createBranch.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(request?.body).toEqual({
|
||||
branch: "draft",
|
||||
sourceBranchId: "b1",
|
||||
})
|
||||
expect(options?.headers?.get("cookie")).toBe(
|
||||
"auth.session=abc",
|
||||
)
|
||||
})
|
||||
|
||||
// the default branch can be open under its id and under the
|
||||
// "default" alias; a store pending under either is the same edits
|
||||
it("also flushes the default alias when forking the default branch", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([
|
||||
{
|
||||
branchId: "b1",
|
||||
default: true,
|
||||
protected: false,
|
||||
},
|
||||
])
|
||||
const { app, flushDocument } = build({ core })
|
||||
|
||||
await app.request("/documents/doc1/branches", {
|
||||
...json({
|
||||
branch: "draft",
|
||||
sourceBranchId: "b1",
|
||||
}),
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
expect(
|
||||
flushDocument.mock.calls.map((call) => call[0]),
|
||||
).toEqual(["doc1-b1", "doc1-default"])
|
||||
})
|
||||
|
||||
it("refuses the fork when the flush fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const flushDocument = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const { app, core } = build({ flushDocument })
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches",
|
||||
{
|
||||
...json({
|
||||
branch: "draft",
|
||||
sourceBranchId: "b1",
|
||||
}),
|
||||
method: "POST",
|
||||
},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "pending changes could not be stored",
|
||||
})
|
||||
expect(core.createBranch).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("answers with core's refusal", async ({ expect }) => {
|
||||
const core = stubCore()
|
||||
core.createBranch.mockRejectedValue({
|
||||
response: {
|
||||
status: 409,
|
||||
data: { error: "branch exists" },
|
||||
},
|
||||
})
|
||||
const { app } = build({ core })
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches",
|
||||
{
|
||||
...json({
|
||||
branch: "draft",
|
||||
sourceBranchId: "b1",
|
||||
}),
|
||||
method: "POST",
|
||||
},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "branch exists",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("PUT /documents/:documentId/branches/:branchId", () => {
|
||||
it("rejects a body that is not JSON", async ({ expect }) => {
|
||||
const { app, core, flushDocument } = build()
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches/b1",
|
||||
{
|
||||
...json(null),
|
||||
body: "not json",
|
||||
},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(flushDocument).toHaveBeenCalledTimes(0)
|
||||
expect(core.updateBranch).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// the store the editors have pending is a user write, and core
|
||||
// refuses those once the branch is protected: it has to land
|
||||
// before the flag is set
|
||||
it("flushes the branch before core changes it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app, core, flushDocument } = build()
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches/b1",
|
||||
json({ protected: true }, "auth.session=abc"),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(
|
||||
callOrder(flushDocument, core.updateBranch),
|
||||
).toEqual(["flush doc1-b1", "core"])
|
||||
const [documentId, branchId, request, options] =
|
||||
core.updateBranch.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(branchId).toBe("b1")
|
||||
expect(request?.body).toEqual({ protected: true })
|
||||
expect(options?.headers?.get("cookie")).toBe(
|
||||
"auth.session=abc",
|
||||
)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "protecting",
|
||||
before: false,
|
||||
body: { protected: true },
|
||||
},
|
||||
{
|
||||
name: "unprotecting",
|
||||
before: true,
|
||||
body: { protected: false },
|
||||
},
|
||||
])(
|
||||
"resets the branch's connections when $name it",
|
||||
async ({ before, body }, { expect }) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([
|
||||
{
|
||||
branchId: "b1",
|
||||
default: true,
|
||||
protected: before,
|
||||
},
|
||||
])
|
||||
const { app, resetConnections } = build({
|
||||
core,
|
||||
})
|
||||
|
||||
await app.request(
|
||||
"/documents/doc1/branches/b1",
|
||||
json(body),
|
||||
)
|
||||
|
||||
expect(
|
||||
resetConnections.mock.calls.map(
|
||||
(call) => call[0],
|
||||
),
|
||||
).toEqual(["doc1-b1", "doc1-default"])
|
||||
},
|
||||
)
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "a rename",
|
||||
body: { name: "release" },
|
||||
},
|
||||
{
|
||||
name: "a protection the branch already has",
|
||||
body: { protected: false },
|
||||
},
|
||||
])(
|
||||
"keeps the connections on $name",
|
||||
async ({ body }, { expect }) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([
|
||||
{
|
||||
branchId: "b1",
|
||||
default: false,
|
||||
protected: false,
|
||||
},
|
||||
])
|
||||
const { app, resetConnections } = build({
|
||||
core,
|
||||
})
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches/b1",
|
||||
json(body),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(resetConnections).toHaveBeenCalledTimes(
|
||||
0,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it("keeps the connections when core refuses the change", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.updateBranch.mockResolvedValue({
|
||||
status: 403,
|
||||
data: { error: "not permitted" },
|
||||
})
|
||||
const { app, resetConnections } = build({ core })
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches/b1",
|
||||
json({ protected: true }),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(resetConnections).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("refuses the change when the flush fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const flushDocument = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const { app, core, resetConnections } = build({
|
||||
flushDocument,
|
||||
})
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/branches/b1",
|
||||
json({ protected: true }),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(core.updateBranch).toHaveBeenCalledTimes(0)
|
||||
expect(resetConnections).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PUT /documents/:documentId/merge", () => {
|
||||
it("flushes the source and then the target before core merges", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app, core, flushDocument } = build()
|
||||
|
||||
await app.request(
|
||||
"/documents/doc1/merge",
|
||||
json({ fromBranchId: "b2", toBranchId: "b1" }),
|
||||
)
|
||||
|
||||
expect(
|
||||
callOrder(flushDocument, core.mergeBranches),
|
||||
).toEqual(["flush doc1-b2", "flush doc1-b1", "core"])
|
||||
})
|
||||
|
||||
it("refuses the merge when a flush fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const flushDocument = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const { app, core } = build({ flushDocument })
|
||||
|
||||
const res = await app.request(
|
||||
"/documents/doc1/merge",
|
||||
json({ fromBranchId: "b2", toBranchId: "b1" }),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(core.mergeBranches).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "a body that is not JSON", input: "not json" },
|
||||
{ name: "a body that is not an object", input: "null" },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
import { Hono } from "hono"
|
||||
import { Hono, type Context } from "hono"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { cors } from "hono/cors"
|
||||
import { createLocalJWKSet, jwtVerify } from "jose"
|
||||
import * as Y from "yjs"
|
||||
import type { Store } from "./db.js"
|
||||
import type { CoreClient, MergedDocument } from "./core.js"
|
||||
import type {
|
||||
BranchSummary,
|
||||
CoreClient,
|
||||
HttpResponse,
|
||||
MergedDocument,
|
||||
} from "./core.js"
|
||||
import type { Env } from "./env.js"
|
||||
import { authMethods } from "./auth.js"
|
||||
import { toAxiosHeaders } from "./headers.js"
|
||||
|
|
@ -44,6 +49,11 @@ export interface RouteDeps {
|
|||
auth: AuthHandler
|
||||
store: Store
|
||||
hocuspocus: DocumentRegistry
|
||||
// runs a document's pending store now; resolves once core has it.
|
||||
flushDocument: (documentName: string) => Promise<void>
|
||||
// drops every client's socket to a document so each reconnects and
|
||||
// authenticates again.
|
||||
resetConnections: (documentName: string) => void
|
||||
core: CoreClient
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +70,16 @@ function readString(source: unknown, key: string): string | undefined {
|
|||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function readBoolean(source: unknown, key: string): boolean | undefined {
|
||||
if (!source || typeof source !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const value = (source as Record<string, unknown>)[key]
|
||||
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
// an axios rejection carrying core's own response, which the merge route
|
||||
// forwards verbatim so the caller sees core's status and error body rather
|
||||
// than a generic 500.
|
||||
|
|
@ -91,10 +111,92 @@ export function createRoutes({
|
|||
auth,
|
||||
store,
|
||||
hocuspocus,
|
||||
flushDocument,
|
||||
resetConnections,
|
||||
core,
|
||||
}: RouteDeps): Hono {
|
||||
const app = new Hono()
|
||||
|
||||
// the names a branch's document can be open under: its id, and for
|
||||
// the default branch the alias a client may have connected with.
|
||||
function branchDocumentNames(
|
||||
documentId: string,
|
||||
branchId: string,
|
||||
branch: BranchSummary | undefined,
|
||||
): string[] {
|
||||
const names = [`${documentId}-${branchId}`]
|
||||
|
||||
if (branch?.default) {
|
||||
names.push(`${documentId}-default`)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// flushBranch stores what the editors hold for a branch before core
|
||||
// reads its row. Returns the branch as core lists it, so a caller can
|
||||
// tell what a change it is about to make actually changed.
|
||||
async function flushBranch(
|
||||
documentId: string,
|
||||
branchId: string,
|
||||
): Promise<BranchSummary | undefined> {
|
||||
const branches = await core.fetchBranches(documentId)
|
||||
const branch = branches.find((b) => b.branchId === branchId)
|
||||
|
||||
for (const name of branchDocumentNames(
|
||||
documentId,
|
||||
branchId,
|
||||
branch,
|
||||
)) {
|
||||
await flushDocument(name)
|
||||
}
|
||||
|
||||
return branch
|
||||
}
|
||||
|
||||
// flushed runs the flushes an operation needs before it may go to
|
||||
// core. A failure means core would read a row the editors are ahead
|
||||
// of, so the operation is refused instead.
|
||||
async function flushed<T>(
|
||||
c: Context,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T | Response> {
|
||||
try {
|
||||
return await run()
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
|
||||
return c.json(
|
||||
{
|
||||
error: "pending changes could not be stored",
|
||||
},
|
||||
500,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// calledCore answers with whatever core answered, including a refusal
|
||||
// carried on a rejection, so the caller sees core's verdict rather
|
||||
// than a generic failure.
|
||||
async function calledCore(
|
||||
c: Context,
|
||||
fallback: string,
|
||||
call: () => Promise<HttpResponse>,
|
||||
): Promise<HttpResponse | Response> {
|
||||
try {
|
||||
return await call()
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
|
||||
const upstream = upstreamResponse(error)
|
||||
if (upstream) {
|
||||
return c.json(upstream.data, upstream.status)
|
||||
}
|
||||
|
||||
return c.json({ error: fallback }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
|
|
@ -133,11 +235,132 @@ export function createRoutes({
|
|||
return c.json({ availableSlots: env.maxOrganizations - count })
|
||||
})
|
||||
|
||||
// auth is delegated: the caller's headers (including the session
|
||||
// cookie) are forwarded to core, whose middleware validates the
|
||||
// session and authorizes the merge. The in-memory Y.Doc mutation
|
||||
// below only runs when core responds 200, so an unauthenticated
|
||||
// caller changes nothing.
|
||||
// the branch operations below are forwarded to core rather than
|
||||
// handled here, and auth is delegated with them: the caller's headers
|
||||
// (including the session cookie) go along, and core's middleware
|
||||
// validates the session and authorizes the change. What this service
|
||||
// adds is the flush: core reads the branch's stored row, and the
|
||||
// editors are up to ten seconds ahead of it until the debounced store
|
||||
// runs.
|
||||
|
||||
// a fork copies the source branch's row.
|
||||
app.post("/documents/:documentId/branches", async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "invalid request body" }, 400)
|
||||
}
|
||||
|
||||
const sourceBranchId = readString(body, "sourceBranchId")
|
||||
if (!sourceBranchId) {
|
||||
return c.json(
|
||||
{ error: "sourceBranchId is required" },
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const source = await flushed(c, () =>
|
||||
flushBranch(documentId, sourceBranchId),
|
||||
)
|
||||
if (source instanceof Response) {
|
||||
return source
|
||||
}
|
||||
|
||||
const response = await calledCore(
|
||||
c,
|
||||
"failed to create branch",
|
||||
() =>
|
||||
core.createBranch(
|
||||
documentId,
|
||||
{ body },
|
||||
{
|
||||
headers: toAxiosHeaders(
|
||||
c.req.raw.headers,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
if (response instanceof Response) {
|
||||
return response
|
||||
}
|
||||
|
||||
return c.json(
|
||||
response.data,
|
||||
response.status as ContentfulStatusCode,
|
||||
)
|
||||
})
|
||||
|
||||
// protecting a branch makes core refuse every store but its own, so
|
||||
// the editors' pending one has to land first. Read-only is decided
|
||||
// per connection when it authenticates, so a change either way
|
||||
// resets the branch's connections: each client reconnects and comes
|
||||
// back with the permission the branch now has.
|
||||
app.put("/documents/:documentId/branches/:branchId", async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
const branchId = c.req.param("branchId")
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "invalid request body" }, 400)
|
||||
}
|
||||
|
||||
const before = await flushed(c, () =>
|
||||
flushBranch(documentId, branchId),
|
||||
)
|
||||
if (before instanceof Response) {
|
||||
return before
|
||||
}
|
||||
|
||||
const response = await calledCore(
|
||||
c,
|
||||
"failed to update branch",
|
||||
() =>
|
||||
core.updateBranch(
|
||||
documentId,
|
||||
branchId,
|
||||
{ body },
|
||||
{
|
||||
headers: toAxiosHeaders(
|
||||
c.req.raw.headers,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
if (response instanceof Response) {
|
||||
return response
|
||||
}
|
||||
|
||||
const protectedFlag = readBoolean(body, "protected")
|
||||
|
||||
if (
|
||||
response.status === 200 &&
|
||||
protectedFlag !== undefined &&
|
||||
protectedFlag !== before?.protected
|
||||
) {
|
||||
for (const name of branchDocumentNames(
|
||||
documentId,
|
||||
branchId,
|
||||
before,
|
||||
)) {
|
||||
resetConnections(name)
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(
|
||||
response.data,
|
||||
response.status as ContentfulStatusCode,
|
||||
)
|
||||
})
|
||||
|
||||
// a merge reads the source branch's row and rewrites the target's;
|
||||
// both are flushed so neither side is behind its editors. The
|
||||
// in-memory Y.Doc mutation below only runs when core responds 200,
|
||||
// so an unauthenticated caller changes nothing.
|
||||
app.put("/documents/:documentId/merge", async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
|
||||
|
|
@ -160,36 +383,42 @@ export function createRoutes({
|
|||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await core.mergeBranches(
|
||||
const sides = await flushed(c, async () => {
|
||||
await flushBranch(documentId, fromBranchId)
|
||||
await flushBranch(documentId, toBranchId)
|
||||
})
|
||||
if (sides instanceof Response) {
|
||||
return sides
|
||||
}
|
||||
|
||||
const response = await calledCore(c, "failed to merge", () =>
|
||||
core.mergeBranches(
|
||||
documentId,
|
||||
fromBranchId,
|
||||
toBranchId,
|
||||
{ headers: toAxiosHeaders(c.req.raw.headers) },
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
await applyMergeToOpenDocument(
|
||||
documentId,
|
||||
toBranchId,
|
||||
response.data,
|
||||
)
|
||||
}
|
||||
|
||||
return c.json(
|
||||
response.data,
|
||||
response.status as ContentfulStatusCode,
|
||||
)
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
|
||||
const upstream = upstreamResponse(error)
|
||||
if (upstream) {
|
||||
return c.json(upstream.data, upstream.status)
|
||||
}
|
||||
|
||||
return c.json({ error: "failed to merge" }, 500)
|
||||
{
|
||||
headers: toAxiosHeaders(
|
||||
c.req.raw.headers,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
if (response instanceof Response) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
await applyMergeToOpenDocument(
|
||||
documentId,
|
||||
toBranchId,
|
||||
response.data as MergedDocument,
|
||||
)
|
||||
}
|
||||
|
||||
return c.json(
|
||||
response.data,
|
||||
response.status as ContentfulStatusCode,
|
||||
)
|
||||
})
|
||||
|
||||
// mirrors the merge into the target branch's live Y.Doc, if one is
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ export function stubCore(): StubCore {
|
|||
icon: "lucide:file",
|
||||
},
|
||||
}),
|
||||
createBranch: vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { branchId: "branch-2" },
|
||||
}),
|
||||
updateBranch: vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { branchId: "branch-1" },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,8 @@ func (s *Server) httpRouter() chi.Router {
|
|||
|
||||
// Note that private routes are not protected by any
|
||||
// authentication middlewares (a reverse proxy like Caddy
|
||||
// should handle that).
|
||||
// should handle that), except the branch operations
|
||||
// auth-realtime forwards on a person's behalf.
|
||||
sr.Mount("/x", s.internalRouter())
|
||||
})
|
||||
|
||||
|
|
@ -137,6 +138,19 @@ func (s *Server) internalRouter() chi.Router {
|
|||
ssr.Get("/", s.handlers.document.FetchDocumentBranchByIDUnsafe)
|
||||
ssr.Put("/", s.handlers.document.UpdateDocumentBranchByIDUnsafe)
|
||||
})
|
||||
|
||||
// the branch operations that read or rewrite a branch's stored
|
||||
// content. They are internal so that only auth-realtime, which
|
||||
// first stores what the editors still hold, can start one — yet
|
||||
// they act for a person, so the session it forwards is required
|
||||
// and authorizes the change exactly as on the public surface.
|
||||
sr.Group(func(ssr chi.Router) {
|
||||
ssr.Use(auth.Middleware(s.log, s.opts.Auth, s.client))
|
||||
ssr.Use(s.handlers.document.RequireDocumentAccess)
|
||||
ssr.Put("/merge", s.handlers.document.MergeBranches)
|
||||
ssr.Post("/branches", s.handlers.document.CreateDocumentBranch)
|
||||
ssr.Put("/branches/{branchId}", s.handlers.document.UpdateDocumentBranch)
|
||||
})
|
||||
})
|
||||
|
||||
r.Post("/email", s.handlers.email.SendEmail)
|
||||
|
|
@ -276,12 +290,9 @@ func (s *Server) router() chi.Router {
|
|||
ssr.Get("/maintainers", s.handlers.document.FetchDocumentMaintainers)
|
||||
ssr.Delete("/", s.handlers.document.DeleteDocument)
|
||||
ssr.Post("/duplicate", s.handlers.document.DuplicateDocument)
|
||||
ssr.Put("/merge", s.handlers.document.MergeBranches)
|
||||
ssr.Route("/branches", func(sssr chi.Router) {
|
||||
sssr.Get("/", s.handlers.document.FetchDocumentBranches)
|
||||
sssr.Post("/", s.handlers.document.CreateDocumentBranch)
|
||||
sssr.Route("/{branchId}", func(ssssr chi.Router) {
|
||||
ssssr.Put("/", s.handlers.document.UpdateDocumentBranch)
|
||||
ssssr.Delete("/", s.handlers.document.DeleteDocumentBranch)
|
||||
ssssr.Put("/review-approve", s.handlers.document.UpdateBranchReviewApproval)
|
||||
ssssr.Post("/blocks/{blockUid}/run", s.handlers.block.RunBlock)
|
||||
|
|
|
|||
|
|
@ -82,4 +82,22 @@ func Test_Server_httpRouter(t *testing.T) {
|
|||
assert.True(t, routes["POST /api/x/organizations/{organizationId}/teardown"])
|
||||
assert.True(t, routes["PUT /api/x/documents/{documentId}/branch/{branchId}/"])
|
||||
assert.True(t, routes["POST /api/x/email"])
|
||||
|
||||
// the branch operations are reachable only through auth-realtime,
|
||||
// which stores the editors' pending changes before core reads the
|
||||
// branch: a public mount would let a client skip that.
|
||||
internalOnly := map[string]string{
|
||||
"PUT /api/x/documents/{documentId}/merge": "PUT /api/*/documents/{documentId}/merge",
|
||||
"POST /api/x/documents/{documentId}/branches": "POST /api/*/documents/{documentId}/branches/",
|
||||
"PUT /api/x/documents/{documentId}/branches/{branchId}": "PUT /api/*/documents/{documentId}/branches/{branchId}/",
|
||||
}
|
||||
|
||||
for internal, public := range internalOnly {
|
||||
assert.True(t, routes[internal], "%s must be mounted internally", internal)
|
||||
assert.False(t, routes[public], "%s must not be publicly routable", public)
|
||||
}
|
||||
|
||||
// the branch reads and the delete stay public alongside them.
|
||||
assert.True(t, routes["GET /api/*/documents/{documentId}/branches/"])
|
||||
assert.True(t, routes["DELETE /api/*/documents/{documentId}/branches/{branchId}/"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
])
|
||||
const putCalls = mockEndpoint(
|
||||
"PUT",
|
||||
`/api/documents/${SHORT_ID}/branches/${BRANCH_ID}`,
|
||||
`http://test.local/auth-realtime/api/documents/${SHORT_ID}/branches/${BRANCH_ID}`,
|
||||
() => null,
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeElem(SHORT_ID, "Doc A")])
|
||||
|
|
@ -832,7 +832,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
])
|
||||
const putCalls = mockEndpoint(
|
||||
"PUT",
|
||||
`/api/documents/${DOC_ID}/branches/${SHORT_ID}`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches/${SHORT_ID}`,
|
||||
() => null,
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")])
|
||||
|
|
@ -855,7 +855,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
const treeCalls = mockEndpoint("GET", TREE_URL, () => [])
|
||||
const put = mockDeferredEndpoint(
|
||||
"PUT",
|
||||
`/api/documents/${DOC_ID}/branches/${BRANCH_ID}`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches/${BRANCH_ID}`,
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")])
|
||||
const api = makeDocumentAPI()
|
||||
|
|
@ -883,7 +883,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
mockEndpoint("GET", TREE_URL, () => [])
|
||||
mockEndpoint(
|
||||
"PUT",
|
||||
`/api/documents/${DOC_ID}/branches/${BRANCH_ID}`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches/${BRANCH_ID}`,
|
||||
() => {
|
||||
throw createError({ statusCode: 500 })
|
||||
},
|
||||
|
|
@ -908,7 +908,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
mockEndpoint("GET", TREE_URL, () => [])
|
||||
const put = mockDeferredEndpoint(
|
||||
"PUT",
|
||||
`/api/documents/${DOC_ID}/branches/${BRANCH_ID}`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches/${BRANCH_ID}`,
|
||||
)
|
||||
seedQueryData(TREE_KEY, [makeElem(DOC_ID, "Doc A")])
|
||||
const api = makeDocumentAPI()
|
||||
|
|
@ -932,7 +932,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
it("bails out for a non-xid source branch", async ({ expect }) => {
|
||||
const postCalls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_ID}/branches`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches`,
|
||||
() => ({}),
|
||||
)
|
||||
seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")])
|
||||
|
|
@ -954,7 +954,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
}) => {
|
||||
const postCalls = mockEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_ID}/branches`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches`,
|
||||
() => ({ id: DOC_ID }),
|
||||
)
|
||||
seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")])
|
||||
|
|
@ -990,9 +990,13 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
it("falls back to empty metadata when the source branch is not cached", async ({
|
||||
expect,
|
||||
}) => {
|
||||
mockEndpoint("POST", `/api/documents/${DOC_ID}/branches`, () => ({
|
||||
id: DOC_ID,
|
||||
}))
|
||||
mockEndpoint(
|
||||
"POST",
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches`,
|
||||
() => ({
|
||||
id: DOC_ID,
|
||||
}),
|
||||
)
|
||||
seedQueryData(BRANCHES_KEY, [])
|
||||
const api = makeDocumentAPI()
|
||||
|
||||
|
|
@ -1011,9 +1015,13 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
})
|
||||
|
||||
it("rolls back the branches when the request fails", async ({ expect }) => {
|
||||
mockEndpoint("POST", `/api/documents/${DOC_ID}/branches`, () => {
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
mockEndpoint(
|
||||
"POST",
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches`,
|
||||
() => {
|
||||
throw createError({ statusCode: 500 })
|
||||
},
|
||||
)
|
||||
seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")])
|
||||
const api = makeDocumentAPI()
|
||||
|
||||
|
|
@ -1034,7 +1042,7 @@ describe("useDocumentAPI", { concurrent: false }, () => {
|
|||
}) => {
|
||||
const post = mockDeferredEndpoint(
|
||||
"POST",
|
||||
`/api/documents/${DOC_ID}/branches`,
|
||||
`http://test.local/auth-realtime/api/documents/${DOC_ID}/branches`,
|
||||
)
|
||||
seedQueryData(BRANCHES_KEY, [makeBranch(BRANCH_ID, "main")])
|
||||
const api = makeDocumentAPI()
|
||||
|
|
|
|||
|
|
@ -615,12 +615,17 @@ export default function () {
|
|||
return
|
||||
}
|
||||
|
||||
await $coreAPIClient(`/api/documents/${id}/branches/${branchId}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
protected: protectedMode,
|
||||
// auth-realtime stores what the editor still holds before core
|
||||
// changes the branch, and resets its connections afterwards.
|
||||
await $authRealtimeAPIClient(
|
||||
`/api/documents/${id}/branches/${branchId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: {
|
||||
protected: protectedMode,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
async onSuccess(_data, { id, branchId }) {
|
||||
if (!isXid(id) || !isXid(branchId)) {
|
||||
|
|
@ -693,7 +698,9 @@ export default function () {
|
|||
return
|
||||
}
|
||||
|
||||
return await $coreAPIClient<DocumentBranchCreateResponse>(
|
||||
// auth-realtime stores what the editor still holds before core
|
||||
// copies the source branch.
|
||||
return await $authRealtimeAPIClient<DocumentBranchCreateResponse>(
|
||||
`/api/documents/${docId}/branches`,
|
||||
{
|
||||
method: "POST",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user