diff --git a/doc/cloud-membership-sync-performance.md b/doc/cloud-membership-sync-performance.md new file mode 100644 index 00000000000..f0ba575aea7 --- /dev/null +++ b/doc/cloud-membership-sync-performance.md @@ -0,0 +1,70 @@ +# Cloud membership synchronization + +Trusted Cloud requests synchronize the user's profile, company membership and +default grants before they resolve authority. Synchronization writes are shared +only within one database client and process. Requests with different contexts +for the same user execute in order. An A, B, A sequence still applies all three +contexts. Other users can proceed independently. + +Successful identical contexts retain the existing five-second write debounce. +Failed or partial synchronization invalidates the prior success, so a retry can +repair it. Membership reads, owner-elevation settings and stale administrator +removal remain fresh on each request. This cache does not store actors. + +Guarded upserts preserve `updated_at` when profile fields, role and status do not +change. They still repair missing default grants. Access lists now sort these +rows by their last actual change. Profiles remain reachable beyond the first +200 memberships: lookup uses the existing company/principal index for an exact +principal ID, then at most one company-scoped alias query returning one row. +An exact ID takes precedence over another member's name, matching the UI's +stable-ID links. Name, email and normalized non-UUID ID aliases retain their +newest-membership-first ordering. A missing slug performs two queries, with no +membership paging. The fallback still scans company candidates; it does not +add an expression index or claim constant database CPU at every company size. + +## Reproduce the local comparison + +From the repository root, use a disposable PostgreSQL 17 container: + +```sh +pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-cloud-membership-sync.ts \ + --baseline-ref=db70c5acd92f50fa37fb4f0dea3da0e7685089a2 --docker-postgres=17 \ + > /tmp/cloud-membership-sync.json +``` + +The harness imports the actual resolvers, applies normal migrations, seeds +synthetic data and removes its container. It does not use `DATABASE_URL`. +Two warmup pairs precede five alternating measured pairs. Each pair uses a +fresh synchronization scope and 20 simultaneous requests for one unchanged +user. Row triggers count updates, including updates that preserve values. + +PostgreSQL 17.11 measured a median 51.07 ms before and 25.71 ms after. The ranges +were 47.21–63.25 ms and 24.17–29.32 ms. Membership statements fell from 20 to one, +and profile plus membership tuple updates fell from 40 to zero. All 20 complete +actor results matched on every sample. This measures local service work and +authority reads. It does not establish production request latency or remove +locks across replicas. + +## Safety checks + +Run the Cloud actor, Cloud transient database retry, company provisioning, +Cloud synchronization PostgreSQL and user profile route suites. They cover +coalescing, A/B/A transitions, failed partial writes, expiry, distinct users and +databases, grant repair, real role-change invalidation, imported-membership +revocation, owner-flag changes and cross-company denial. The profile regression +first returned 404 with the former 200-row lookup. The fixture now contains +601 memberships with timestamps differing within one millisecond. It checks +raw and normalized non-UUID IDs, a missing slug's two-statement bound, tenant +isolation, exact-ID priority and fallback ordering. Real PostgreSQL requests +compare all four alias fields against the route's own JavaScript canonical +slug output, including quotes, whitespace, punctuation, Unicode and email edge +cases. An exhaustive Unicode lowercase check verifies the two non-ASCII +characters that need explicit ASCII mappings in the SQL predicate, avoiding +database-locale differences. Whole-character replacements precede an +ASCII-only `translate`, preserving UTF-8 byte sequences on legacy `SQL_ASCII` +databases. A disposable `SQL_ASCII` database applies normal migrations and +exercises all four alias fields with en/em dashes, euro signs, neighboring +multibyte characters, dotted I and Kelvin signs. It also verifies that a euro +sign cannot incorrectly match the profile alias `k`. + +No schema migration, startup work or deployment is required by this change. diff --git a/scripts/benchmark-cloud-membership-sync.ts b/scripts/benchmark-cloud-membership-sync.ts new file mode 100644 index 00000000000..3dffc41fee7 --- /dev/null +++ b/scripts/benchmark-cloud-membership-sync.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + agents, applyPendingMigrations, authUsers, companyMemberships, createDb, startEmbeddedPostgresTestDatabase, type Db, +} from "../packages/db/src/index.js"; +import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "../server/src/middleware/auth.js"; + +// All databases, identities and probes are disposable. This never reads DATABASE_URL. +const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15); +const dockerPostgres = process.argv.includes("--docker-postgres=17"); +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +assert.ok(baselineRef, "Pass an explicit --baseline-ref="); +const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8", cwd: repoRoot }).trim(); +const treatmentRevision = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", cwd: repoRoot }).trim(); +const treatmentDiff = execFileSync("git", ["diff", "--", "server/src/middleware/auth.ts"], { encoding: "utf8", cwd: repoRoot }); +const keepAlive = setInterval(() => {}, 1_000); +let database: Awaited> | undefined; +let baselineDir: string | undefined; +const clients = new Set(); + +async function startDatabase() { + if (!dockerPostgres) return startEmbeddedPostgresTestDatabase("cloud-membership-benchmark-"); + const container = `paperclip-cloud-membership-${randomUUID()}`; + const docker = (...args: string[]) => execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + docker("run", "--detach", "--rm", "--name", container, "--label", "paperclip.benchmark=cloud-membership", + "--publish", "127.0.0.1::5432", "--env", "POSTGRES_USER=paperclip", "--env", "POSTGRES_PASSWORD=paperclip", + "--env", "POSTGRES_DB=paperclip", "postgres:17"); + const cleanup = async () => { docker("rm", "--force", container); }; + try { + let ready = false; + for (let attempt = 0; attempt < 100; attempt++) { + try { + docker("exec", container, "pg_isready", "-h", "127.0.0.1", "-U", "paperclip"); + ready = true; + break; + } catch { await new Promise(resolve => setTimeout(resolve, 100)); } + } + assert.ok(ready, "Disposable PostgreSQL did not become ready"); + const port = docker("port", container, "5432/tcp").split(":").at(-1); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, cleanup }; + } catch (error) { await cleanup(); throw error; } +} + +try { + database = await startDatabase(); + function freshDb() { + const db = createDb(database!.connectionString); + clients.add(db); + return db; + } + const db = freshDb(); + baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".cloud-auth-baseline-")); + const original = new URL("../server/src/middleware/auth.ts", import.meta.url); + const source = execFileSync("git", ["show", `${baselineRevision}:server/src/middleware/auth.ts`], { encoding: "utf8" }); + const rewritten = source.replace(/from "(\.{1,2}\/[^\"]+)"/g, + (_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, original)))}`); + const baselineFile = path.join(baselineDir, "auth.ts"); + await fs.writeFile(baselineFile, rewritten); + const baseline = (await import(pathToFileURL(baselineFile).href)).resolveCloudTenantActor as typeof resolveCloudTenantActor; + + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "synthetic-membership-benchmark"; + process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "synthetic", features: { enableOwnerInstanceAdmin: false }, plugins: { autoInstall: [] } }); + const userId = randomUUID(); + const headers = cloudActorHeaderSourceFromHeaders({ + "x-paperclip-cloud-tenant-token": "synthetic-membership-benchmark", + "x-paperclip-cloud-user-id": userId, + "x-paperclip-cloud-user-email": `${userId}@example.test`, + "x-paperclip-cloud-stack-id": `stack-${randomUUID()}`, + "x-paperclip-cloud-stack-role": "owner", + }); + const seeded = await baseline(db, headers); + const companyId = seeded!.companyIds![0]!; + await db.insert(agents).values({ companyId, name: "Synthetic observer", adapterType: "codex_local" }); + await db.$client.unsafe("CREATE TABLE cloud_sync_probe (relation text, operation text)"); + await db.$client.unsafe(`CREATE FUNCTION cloud_sync_probe() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN INSERT INTO cloud_sync_probe VALUES (TG_TABLE_NAME, TG_OP); RETURN NEW; END $$`); + await db.$client.unsafe('CREATE TRIGGER cloud_sync_probe AFTER INSERT OR UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION cloud_sync_probe()'); + await db.$client.unsafe("CREATE TRIGGER cloud_sync_probe AFTER INSERT OR UPDATE ON company_memberships FOR EACH ROW EXECUTE FUNCTION cloud_sync_probe()"); + const [version] = await db.$client.unsafe("SELECT version(), current_setting('max_connections') AS max_connections"); + + function observe(client: Db) { + const statements: Array<{ table: string; sql: string; params: unknown[]; returnedRows: number }> = []; + const observed = new Proxy(client, { get(target, key) { + if (key !== "insert") return Reflect.get(target, key); + return (...args: Parameters) => { + const builder = target.insert(...args); + const values = builder.values.bind(builder); + builder.values = ((...input: Parameters) => { + const query = values(...input); + const execute = query.execute.bind(query); + query.execute = (async () => { + const result = await execute(); + if (args[0] === authUsers || args[0] === companyMemberships) { + statements.push({ table: args[0] === authUsers ? "user" : "company_memberships", ...query.toSQL(), returnedRows: result.length }); + } + return result; + }) as typeof query.execute; + return query; + }) as typeof builder.values; + return builder; + }; + }}); + return { db: observed, statements }; + } + + const burstSize = 20; + async function sample(resolve: typeof resolveCloudTenantActor) { + const client = freshDb(); + const observed = observe(client); // same fixture; a fresh debounce scope + try { + await db.$client.unsafe("TRUNCATE cloud_sync_probe"); + const start = performance.now(); + const actors = await Promise.all(Array.from({ length: burstSize }, () => resolve(observed.db, headers))); + const durationMs = performance.now() - start; + for (const actor of actors) assert.deepEqual(actor, seeded); + const writes = await db.$client.unsafe("SELECT relation, operation, count(*)::int AS writes FROM cloud_sync_probe GROUP BY relation, operation ORDER BY relation, operation"); + return { durationMs, statements: observed.statements, writes: [...writes], equalActors: actors.length }; + } finally { + await client.$client.end({ timeout: 0 }); + clients.delete(client); + } + } + const results: { before: Awaited>[]; after: Awaited>[] } = { before: [], after: [] }; + // Alternate order; first two pairs are warmups, then five measured pairs. + for (let index = 0; index < 7; index += 1) { + const order = index % 2 === 0 ? ["before", "after"] as const : ["after", "before"] as const; + for (const variant of order) { + const result = await sample(variant === "before" ? baseline : resolveCloudTenantActor); + if (index >= 2) results[variant].push(result); + } + } + for (const before of results.before) { + assert.equal(before.statements.filter(statement => statement.table === "company_memberships").length, burstSize); + assert.deepEqual(before.writes, [{ relation: "company_memberships", operation: "UPDATE", writes: burstSize }, { relation: "user", operation: "UPDATE", writes: burstSize }]); + } + for (const after of results.after) { + assert.equal(after.statements.filter(statement => statement.table === "company_memberships").length, 1); + assert.deepEqual(after.writes, []); + assert.equal(after.statements.find(statement => statement.table === "company_memberships")!.returnedRows, 0); + } + function summarize(samples: typeof results.before) { + const timings = samples.map(sample => sample.durationMs).sort((left, right) => left - right); + return { medianMs: timings[2], rangeMs: [timings[0], timings.at(-1)], samplesMs: samples.map(sample => sample.durationMs), + membershipStatementsPerBurst: samples[0].statements.filter(statement => statement.table === "company_memberships").length, + tupleWritesPerBurst: samples[0].writes, equalActorsPerBurst: burstSize, statements: samples[0].statements }; + } + const before = summarize(results.before), after = summarize(results.after); + const treatmentSources = await Promise.all(["server/src/middleware/auth.ts", "server/src/middleware/cloud-tenant-sync.ts"].map(async file => ({ + file, sha256: createHash("sha256").update(await fs.readFile(new URL(`../${file}`, import.meta.url))).digest("hex"), + }))); + console.log(JSON.stringify({ baselineRevision, treatmentRevision, treatmentHasUncommittedChanges: treatmentDiff.length > 0, + treatmentSources, version, fixture: { users: 1, memberships: 1, agents: 1, burstSize, warmupPairs: 2, measuredPairs: 5 }, + scope: "Local real-service burst including SQL and authority reads; excludes HTTP/network. No production latency claim.", + before, after, speedup: before.medianMs / after.medianMs }, null, 2)); +} finally { + try { + await Promise.all([...clients].map(db => db.$client.end({ timeout: 0 }))); + await database?.cleanup(); + if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true }); + } finally { clearInterval(keepAlive); } +} diff --git a/server/src/__tests__/git-workspace-expiration.test.ts b/server/src/__tests__/git-workspace-expiration.test.ts index 80a1b510058..7adbecd36d8 100644 --- a/server/src/__tests__/git-workspace-expiration.test.ts +++ b/server/src/__tests__/git-workspace-expiration.test.ts @@ -831,7 +831,7 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { await fs.writeFile(path.join(f.cwd, ".git"), `gitdir: ${path.join(f.cwd, "missing-admin")}\n`); const result = await f.expire(); expect(result).toMatchObject({ expired: false, skipReason: "inspection_failed", - failure: { name: "Error", code: expect.any(String), at: expect.stringMatching(/^at /), stderr: expect.stringContaining("not a git repository") } }); + failure: { name: "Error", code: expect.any(String), at: expect.stringMatching(/^at /), stderr: expect.stringMatching(/not a git repository|gitfile does not point to a valid repository/) } }); expect(await exists(f.cwd)).toBe(true); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 86347bb40d5..069ecc5d221 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -36,32 +36,7 @@ export { retryIdempotentDatabaseOperation as retryOnTransientDbConnectionError, } from "../database-retry.js"; -const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000; -const CLOUD_TENANT_WRITE_DEBOUNCE_MAX = 1_000; -const cloudTenantWriteDebounces = new WeakMap>(); - -function cloudTenantWriteDebounceFor(db: Db) { - let debounce = cloudTenantWriteDebounces.get(db); - if (!debounce) { - debounce = new Map(); - cloudTenantWriteDebounces.set(db, debounce); - } - return debounce; -} - -function pruneCloudTenantWriteDebounce( - debounce: Map, - nowMs: number, -) { - for (const [subject, entry] of debounce) { - if (entry.syncedAt <= nowMs - CLOUD_TENANT_WRITE_DEBOUNCE_MS) debounce.delete(subject); - } - while (debounce.size > CLOUD_TENANT_WRITE_DEBOUNCE_MAX) { - const oldestSubject = debounce.keys().next().value; - if (!oldestSubject) break; - debounce.delete(oldestSubject); - } -} +import { syncCloudTenantWrites } from "./cloud-tenant-sync.js"; import { resolveOwnerInstanceAdmin } from "../services/cloud-tenant-authority.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; import { forbidden, unauthorized, unprocessable } from "../errors.js"; @@ -624,41 +599,16 @@ async function resolveCloudTenantActorOnce( ?.trim(); const companyId = cloudTenantCompanyId(stackId); const companyName = paperclipCompanyName || humanizeCloudStackSlug(stackId); - const now = new Date(); const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole; - const syncFingerprint = [userEmail, userName, stackId, stackRole, paperclipCompanyId ?? ""].join(":"); - const cloudTenantWriteDebounce = cloudTenantWriteDebounceFor(db); - pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, now.getTime()); - const previousSync = cloudTenantWriteDebounce.get(userId); - const shouldSync = previousSync?.fingerprint !== syncFingerprint - || previousSync.syncedAt <= now.getTime() - CLOUD_TENANT_WRITE_DEBOUNCE_MS; + const syncFingerprint = JSON.stringify([ + userEmail, userName, stackId, stackRole, paperclipCompanyId ?? "", paperclipCompanyName ?? "", + ]); let effectiveMembership: { companyId: string; membershipRole: string | null; status: string } = { companyId, membershipRole, status: "active", }; - if (shouldSync) await db - .insert(authUsers) - .values({ - id: userId, - name: userName, - email: userEmail, - emailVerified: true, - image: null, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: authUsers.id, - set: { - name: userName, - email: userEmail, - emailVerified: true, - updatedAt: now, - }, - }); - // Earlier cloud_tenant builds granted every tenant user `instance_admin`. // Stale rows from those deployments would still elevate this user through // the BetterAuth session path, board API keys, and the authorization @@ -668,67 +618,63 @@ async function resolveCloudTenantActorOnce( .delete(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))); - if (shouldSync) await insertCloudTenantCompany(db, { companyId, companyName, now }); - - if (shouldSync && paperclipCompanyName) { - await repairCloudTenantCompanyName(db, { - companyId, - paperclipCompanyId, - paperclipCompanyName, - now, - }); - } - - // Runs after the name repair so the prefix derives from the repaired name. - // The helper self-gates on the legacy markers, so it is a no-op once the - // company has been repaired or was claimed by a current build. - if (shouldSync) { - await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now }); - } - - effectiveMembership = shouldSync ? await db - .insert(companyMemberships) - .values({ - companyId, - principalType: "user", - principalId: userId, - status: "active", - membershipRole, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - companyMemberships.companyId, - companyMemberships.principalType, - companyMemberships.principalId, - ], - set: { - status: "active", - membershipRole, + await syncCloudTenantWrites(db, userId, syncFingerprint, async () => { + const now = new Date(); + await db + .insert(authUsers) + .values({ + id: userId, + name: userName, + email: userEmail, + emailVerified: true, + image: null, + createdAt: now, updatedAt: now, - }, - }) - .returning() - .then((rows) => rows[0] ?? { - companyId, - membershipRole, - status: "active", - }) : { companyId, membershipRole, status: "active" as const }; + }) + .onConflictDoUpdate({ + target: authUsers.id, + set: { name: userName, email: userEmail, emailVerified: true, updatedAt: now }, + setWhere: sql`${authUsers.name} is distinct from ${userName} + or ${authUsers.email} is distinct from ${userEmail} + or ${authUsers.emailVerified} is distinct from true`, + }); - // Without instance-admin elevation, cloud tenant users are authorized purely - // through company-scoped permission grants — seed the same role defaults the - // regular membership flows create. - if (shouldSync) await ensureHumanRoleDefaultGrants(db, { - companyId, - principalId: userId, - membershipRole: effectiveMembership.membershipRole ?? membershipRole, - grantedByUserId: null, + await insertCloudTenantCompany(db, { companyId, companyName, now }); + if (paperclipCompanyName) { + await repairCloudTenantCompanyName(db, { + companyId, + paperclipCompanyId, + paperclipCompanyName, + now, + }); + } + // Derive any legacy prefix from the repaired name, preserving the helper's + // exact legacy-marker guards and the operator's own edits. + await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now }); + + effectiveMembership = await db + .insert(companyMemberships) + .values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole, updatedAt: now }) + .onConflictDoUpdate({ + target: [companyMemberships.companyId, companyMemberships.principalType, companyMemberships.principalId], + set: { status: "active", membershipRole, updatedAt: now }, + setWhere: sql`${companyMemberships.status} is distinct from 'active' + or ${companyMemberships.membershipRole} is distinct from ${membershipRole}`, + }) + .returning({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status }) + // PostgreSQL returns no row for an unchanged ON CONFLICT ... WHERE. + // That condition itself proves the existing role/status match the input. + .then((rows) => rows[0] ?? { companyId, membershipRole, status: "active" }); + + // An unchanged membership can still lack default grants after a partial + // sync or an older deployment; keep this repair inside every actual sync. + await ensureHumanRoleDefaultGrants(db, { + companyId, + principalId: userId, + membershipRole: effectiveMembership.membershipRole ?? membershipRole, + grantedByUserId: null, + }); }); - if (shouldSync) { - cloudTenantWriteDebounce.delete(userId); - cloudTenantWriteDebounce.set(userId, { fingerprint: syncFingerprint, syncedAt: Date.now() }); - pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, Date.now()); - } // The stack's seeded company is only where Cloud provisioned this user. // Companies created afterwards on the instance (imports, in-app company diff --git a/server/src/middleware/cloud-tenant-actor.test.ts b/server/src/middleware/cloud-tenant-actor.test.ts index fd0bc35b4a3..87fd4ae8627 100644 --- a/server/src/middleware/cloud-tenant-actor.test.ts +++ b/server/src/middleware/cloud-tenant-actor.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import express, { type Request } from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; @@ -29,10 +29,9 @@ function createFakeDb(options: { membershipQueryRows?: Array<{ companyId: string; membershipRole: string | null; status: string }>; settingsRow?: Record | null; selectThrows?: boolean; - beforeWrite?: () => Promise; + beforeWrite?: (table: unknown) => Promise; + unchangedMembership?: boolean; } = {}) { - const membershipRow = - options.membershipRow ?? { companyId: "company-x", membershipRole: "owner", status: "active" }; const settingsRow = options.settingsRow === undefined ? { @@ -48,22 +47,29 @@ function createFakeDb(options: { const insertedTables: unknown[] = []; const deletedTables: unknown[] = []; const selectWheres: Array<{ table: unknown; condition: unknown }> = []; - const chain: Record = {}; - chain.values = () => chain; - chain.onConflictDoUpdate = () => chain; - chain.onConflictDoNothing = () => chain; - chain.where = () => chain; - chain.returning = async () => { await options.beforeWrite?.(); return [membershipRow]; }; - chain.then = (resolve: (v: unknown) => unknown, reject?: (error: unknown) => unknown) => - Promise.resolve().then(options.beforeWrite).then(resolve, reject); + function writeChain(table: unknown) { + let values: unknown; + const chain: Record = {}; + chain.values = (value: unknown) => { values = value; return chain; }; + chain.onConflictDoUpdate = () => chain; + chain.onConflictDoNothing = () => chain; + chain.where = () => chain; + chain.returning = async () => { + await options.beforeWrite?.(table); + return options.unchangedMembership && table === companyMemberships ? [] : [options.membershipRow ?? values]; + }; + chain.then = (resolve: (v: unknown) => unknown, reject?: (error: unknown) => unknown) => + Promise.resolve().then(() => options.beforeWrite?.(table)).then(resolve, reject); + return chain; + } const db = { insert: (table: unknown) => { insertedTables.push(table); - return chain; + return writeChain(table); }, delete: (table: unknown) => { deletedTables.push(table); - return chain; + return writeChain(table); }, select: () => { return { @@ -236,6 +242,132 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => { expect(insertedTables.filter((table) => table === companyMemberships)).toHaveLength(3); }); + it("coalesces concurrent identical syncs while keeping authority reads and stale-admin purges per request", async () => { + let release!: () => void; + let entered!: () => void; + const blocked = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const { db, insertedTables, deletedTables, selectWheres } = createFakeDb({ + beforeWrite: async table => { if (table === authUsers) { entered(); await blocked; } }, + }); + const requests = Array.from({ length: 20 }, () => resolveCloudTenantActor(db, fakeReq(VALID_HEADERS))); + await started; + expect(insertedTables.filter(table => table === authUsers)).toHaveLength(1); + release(); + const actors = await Promise.all(requests); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(1); + expect(deletedTables.filter(table => table === instanceUserRoles)).toHaveLength(20); + expect(selectWheres.filter(entry => entry.table === instanceSettings)).toHaveLength(20); + expect(selectWheres.filter(entry => entry.table === companyMemberships)).toHaveLength(20); + expect(actors.every(actor => actor?.memberships?.[0]?.membershipRole === "owner")).toBe(true); + }); + + it("serializes concurrent A to B to A without sharing the wrong role", async () => { + let release!: () => void; + let entered!: () => void; + const blocked = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let writes = 0; + const { db, insertedTables } = createFakeDb({ beforeWrite: async table => { + if (table === authUsers && ++writes === 1) { entered(); await blocked; } + } }); + const first = resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + await started; + const second = resolveCloudTenantActor(db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "member" })); + const third = resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + release(); + const actors = await Promise.all([first, second, third]); + expect(actors.map(actor => actor?.memberships?.[0]?.membershipRole)).toEqual(["owner", "member", "owner"]); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(3); + }); + + it("does not block another user behind a slow user's synchronization", async () => { + let release!: () => void; + let entered!: () => void; + const blocked = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let firstWrite = true; + const { db } = createFakeDb({ beforeWrite: async table => { + if (table === authUsers && firstWrite) { firstWrite = false; entered(); await blocked; } + } }); + const slow = resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + await started; + try { + const other = await resolveCloudTenantActor(db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-user-id": "other-user" })); + expect(other?.userId).toBe("other-user"); + } finally { + release(); + await slow; + } + }); + + it("does not debounce a failed partial context transition or its repair", async () => { + let fail = false; + const { db, insertedTables } = createFakeDb({ beforeWrite: async table => { + if (fail && table === companyMemberships) throw new Error("partial sync failed"); + } }); + await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + fail = true; + await expect(resolveCloudTenantActor(db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "member" }))) + .rejects.toThrow("partial sync failed"); + fail = false; + const repaired = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(repaired?.memberships?.[0]?.membershipRole).toBe("owner"); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(3); + }); + + it("retries a shared transient failure and completes default grants once", async () => { + let failed = false; + const { db, insertedTables } = createFakeDb({ beforeWrite: async table => { + if (!failed && table === companyMemberships) { + failed = true; + throw Object.assign(new Error("synthetic connection loss"), { code: "CONNECTION_CLOSED" }); + } + } }); + const actors = await Promise.all(Array.from({ length: 10 }, () => resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)))); + expect(actors.every(actor => actor?.memberships?.[0]?.membershipRole === "owner")).toBe(true); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(2); + expect(insertedTables.filter(table => table === principalPermissionGrants)).toHaveLength(1); + }); + + it("keeps default-grant repair and the input role when a guarded upsert returns no row", async () => { + const { db, insertedTables } = createFakeDb({ unchangedMembership: true }); + const actor = await resolveCloudTenantActor(db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "member" })); + expect(actor?.memberships?.[0]?.membershipRole).toBe("member"); + expect(insertedTables).toContain(principalPermissionGrants); + }); + + it("does not share completed syncs across databases or companies", async () => { + const first = createFakeDb(); + const second = createFakeDb(); + await Promise.all([resolveCloudTenantActor(first.db, fakeReq(VALID_HEADERS)), resolveCloudTenantActor(second.db, fakeReq(VALID_HEADERS))]); + await resolveCloudTenantActor(first.db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-id": "stack-other" })); + expect(first.insertedTables.filter(table => table === companyMemberships)).toHaveLength(2); + expect(second.insertedTables.filter(table => table === companyMemberships)).toHaveLength(1); + }); + + it("resyncs when the requested company name changes", async () => { + const { db, insertedTables } = createFakeDb(); + await resolveCloudTenantActor(db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-paperclip-company-name": "First" })); + await resolveCloudTenantActor(db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-paperclip-company-name": "Second" })); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(2); + }); + + it("resumes membership and grant repair after the existing five-second debounce expires", async () => { + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + try { + const { db, insertedTables } = createFakeDb(); + await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(1); + clock.mockReturnValue(now + 5_001); + await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(insertedTables.filter(table => table === companyMemberships)).toHaveLength(2); + expect(insertedTables.filter(table => table === principalPermissionGrants)).toHaveLength(2); + } finally { clock.mockRestore(); } + }); + it("returns null when the server token is unset", async () => { delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; const { db } = createFakeDb(); diff --git a/server/src/middleware/cloud-tenant-sync-postgres.test.ts b/server/src/middleware/cloud-tenant-sync-postgres.test.ts new file mode 100644 index 00000000000..46fbf351f41 --- /dev/null +++ b/server/src/middleware/cloud-tenant-sync-postgres.test.ts @@ -0,0 +1,323 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { and, eq, sql } from "drizzle-orm"; +import { + agents, applyPendingMigrations, authUsers, companies, companyMemberships, createDb, instanceSettings, + instanceUserRoles, principalPermissionGrants, resourceLifecycleEvents, type Db, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; +import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "./auth.js"; +import { errorHandler } from "./error-handler.js"; +import { userProfileRoutes } from "../routes/user-profiles.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const aliasFields = ["name", "email-local", "email", "principal"] as const; +(support.supported ? describe : describe.skip)("Cloud membership synchronization with PostgreSQL", () => { + let database: Awaited>; + let db: Db; + const clients: Db[] = []; + + function freshDb() { + const client = createDb(database.connectionString); + clients.push(client); + return client; + } + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("cloud-membership-sync-"); + db = freshDb(); + // Count actual tuple changes, including updates whose values are unchanged. + await db.execute(sql`create table cloud_sync_write_probe (relation text, operation text)`); + await db.execute(sql`create function cloud_sync_probe() returns trigger language plpgsql as $$ + begin insert into cloud_sync_write_probe values (TG_TABLE_NAME, TG_OP); return NEW; end $$`); + await db.execute(sql`create trigger cloud_sync_probe after insert or update on company_memberships + for each row execute function cloud_sync_probe()`); + await db.execute(sql`create trigger cloud_sync_probe after insert or update on "user" + for each row execute function cloud_sync_probe()`); + }, 90_000); + afterAll(async () => { + await Promise.all(clients.map(client => client.$client.end({ timeout: 0 }))); + await database?.cleanup(); + }, 60_000); + beforeEach(() => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "synthetic-cloud-sync-token"; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_MANAGED_CONFIG; + }); + + function headers(userId: string = randomUUID(), role = "owner", stackId = `stack-${randomUUID()}`) { + return { + "x-paperclip-cloud-tenant-token": "synthetic-cloud-sync-token", + "x-paperclip-cloud-user-id": userId, + "x-paperclip-cloud-user-email": `${userId}@example.test`, + "x-paperclip-cloud-stack-id": stackId, + "x-paperclip-cloud-stack-role": role, + }; + } + + function resolve(client: Db, value: Record) { + return resolveCloudTenantActor(client, cloudActorHeaderSourceFromHeaders(value)); + } + + function profileApp(actor: Express.Request["actor"], client: Db = db) { + const lookups: Array<{ sql: string; params: unknown[]; returnedRows: number }> = []; + const observed = new Proxy(client, { get(target, key) { + if (key !== "select") return Reflect.get(target, key); + return (...fields: Parameters) => { + const builder = target.select(...fields); + const from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const query = from(...args); + if (args[0] === companyMemberships) { + const execute = query.execute.bind(query); + query.execute = (async () => { + const rows = await execute(); + lookups.push({ ...query.toSQL(), returnedRows: rows.length }); + return rows; + }) as typeof query.execute; + } + return query; + }) as typeof builder.from; + return builder; + }; + } }); + const app = express(); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", userProfileRoutes(observed)); + app.use(errorHandler); + return { app, lookups }; + } + + async function expectProfileAliases(client: Db, field: typeof aliasFields[number], cases: string[]) { + const actor = await resolve(client, headers()); + const companyId = actor!.companyIds![0]!; + const { app, lookups } = profileApp(actor!, client); + for (const [index, edge] of cases.entries()) { + const identity = field === "principal" ? `Case${index}:${edge}` : randomUUID(); + if (field !== "principal") await client.insert(authUsers).values({ + id: identity, + name: field === "name" ? `Case${index}:${edge}` : "", + email: field === "email-local" ? `Case${index}:${edge}@example.test` + : field === "email" ? `@Case${index}:${edge}` : `${identity}@example.test`, + createdAt: new Date(), updatedAt: new Date(), + }); + await client.insert(companyMemberships).values({ companyId, principalType: "user", principalId: identity }); + const exact = await request(app).get(`/api/companies/${companyId}/users/${encodeURIComponent(identity)}/profile`); + expect(exact.status).toBe(200); + // The actual response canonical slug is produced by userSlugCandidates; + // use it as the oracle instead of duplicating its JavaScript regex here. + const canonicalSlug = exact.body.user.slug; + lookups.length = 0; + const fallback = await request(app).get(`/api/companies/${companyId}/users/${canonicalSlug}/profile`); + expect(fallback.status, `${field}: ${JSON.stringify(edge)}`).toBe(200); + expect(fallback.body.user.id, `${field}: ${JSON.stringify(edge)}`).toBe(identity); + expect(lookups).toHaveLength(2); + } + } + + it("does not update unchanged tuples, returns the input role on an empty RETURNING result, and repairs missing grants", async () => { + const value = headers(); + const actor = await resolve(db, value); + const companyId = actor!.companyIds![0]!; + const userId = value["x-paperclip-cloud-user-id"]; + const membershipWhere = and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId)); + const oldUpdatedAt = new Date("2020-01-01T00:00:00Z"); + await db.update(authUsers).set({ updatedAt: oldUpdatedAt }).where(eq(authUsers.id, userId)); + await db.update(companyMemberships).set({ updatedAt: oldUpdatedAt }).where(membershipWhere); + const [beforeUser] = await db.select().from(authUsers).where(eq(authUsers.id, userId)); + const [beforeMembership] = await db.select().from(companyMemberships).where(membershipWhere); + await db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalId, userId))); + await db.execute(sql`truncate cloud_sync_write_probe`); + const second = await resolve(freshDb(), value); // bypass process-local debounce + expect(second?.memberships?.[0]).toEqual({ companyId, membershipRole: "owner", status: "active" }); + expect(await db.select().from(authUsers).where(eq(authUsers.id, userId))).toEqual([beforeUser]); + expect(await db.select().from(companyMemberships).where(membershipWhere)).toEqual([beforeMembership]); + expect(await db.execute(sql`select * from cloud_sync_write_probe`)).toEqual([]); + expect((await db.select().from(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalId, userId)))).length).toBeGreaterThan(0); + await resolve(freshDb(), { ...value, "x-paperclip-cloud-user-name": "Changed profile", "x-paperclip-cloud-stack-role": "member" }); + const [changedUser] = await db.select().from(authUsers).where(eq(authUsers.id, userId)); + const [changedMembership] = await db.select().from(companyMemberships).where(membershipWhere); + expect(changedUser.name).toBe("Changed profile"); + expect(changedUser.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + expect(changedMembership.membershipRole).toBe("member"); + expect(changedMembership.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it("preserves runtime invalidation on real membership changes while no-op conflicts produce no invalidation", async () => { + const value = headers(); + const actor = await resolve(db, value); + const companyId = actor!.companyIds![0]!; + await db.insert(agents).values({ companyId, name: "Synthetic sync observer", adapterType: "codex_local" }); + await db.delete(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId)); + await resolve(freshDb(), value); + expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId))).toEqual([]); + const demoted = await resolve(db, { ...value, "x-paperclip-cloud-stack-role": "member" }); + expect(demoted?.memberships?.[0]?.membershipRole).toBe("member"); + const events = await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId)); + expect(events.some(event => event.resourceType === "agent_runtime" && event.action === "update")).toBe(true); + }); + + it("rechecks imported memberships, stale admin rows and the owner flag inside the write debounce", async () => { + const value = headers(); + const actor = await resolve(db, value); + const userId = value["x-paperclip-cloud-user-id"]; + const imported = await resolve(db, headers(userId, "member")); + const importedCompanyId = imported!.companyIds![0]!; + await resolve(db, value); + await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" }); + await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, importedCompanyId), eq(companyMemberships.principalId, userId))); + await db.insert(instanceSettings).values({ singletonKey: "default", experimental: { enableOwnerInstanceAdmin: true } }) + .onConflictDoUpdate({ target: instanceSettings.singletonKey, set: { experimental: { enableOwnerInstanceAdmin: true } } }); + const elevated = await resolve(db, value); + expect(elevated?.isInstanceAdmin).toBe(true); + expect(elevated?.companyIds).toEqual(actor!.companyIds); + expect(await db.select().from(instanceUserRoles).where(eq(instanceUserRoles.userId, userId))).toEqual([]); + await db.update(instanceSettings).set({ experimental: { enableOwnerInstanceAdmin: false } }); + expect((await resolve(db, value))?.isInstanceAdmin).toBe(false); + }); + + it("bounds old non-UUID profile and missing-slug lookups to two statements regardless of company size", async () => { + const userId = `paperclip-id:TLqGPuleJpjX:${randomUUID()}`; + const value = headers(userId); + const first = await resolve(db, value); + const companyId = first!.companyIds![0]!; + const oldUpdatedAt = new Date("2020-01-01T00:00:00Z"); + await db.update(companyMemberships).set({ updatedAt: oldUpdatedAt }).where(and( + eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId), + )); + const newerUsers = Array.from({ length: 600 }, () => { + const id = randomUUID(); + return { id, name: `Newer ${id}`, email: `${id}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }; + }); + await db.insert(authUsers).values(newerUsers); + await db.insert(companyMemberships).values(newerUsers.map(user => ({ + companyId, principalType: "user", principalId: user.id, status: "active", membershipRole: "member", + }))); + // Retain the former cursor regression fixture: membership order differs + // within a millisecond, and the target remains beyond the first 200 rows. + await db.execute(sql`update company_memberships set updated_at = case + when principal_id = ${userId} then '2020-01-01 00:00:00.000100+00'::timestamptz + else '2020-01-01 00:00:00.000200+00'::timestamptz end + where company_id = ${companyId}`); + + const actor = await resolve(freshDb(), value); + const [unchanged] = await db.select({ updatedAt: companyMemberships.updatedAt }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId), + )); + expect(unchanged.updatedAt).toEqual(oldUpdatedAt); + const { app, lookups } = profileApp(actor!); + const response = await request(app).get(`/api/companies/${companyId}/users/${encodeURIComponent(userId)}/profile`); + expect(response.status).toBe(200); + expect(response.body.user.id).toBe(userId); + expect(response.body.user.membershipRole).toBe("owner"); + expect(lookups).toHaveLength(1); + expect(lookups[0].returnedRows).toBe(1); + + lookups.length = 0; + // userProfilePath normalizes even a non-UUID principal ID before linking. + const normalizedId = `paperclip-id-tlqgpulejpjx-${userId.split(":").at(-1)}`; + const normalized = await request(app).get(`/api/companies/${companyId}/users/${normalizedId}/profile`); + expect(normalized.status).toBe(200); + expect(normalized.body.user.id).toBe(userId); + expect(lookups).toHaveLength(2); + expect(lookups.every(query => query.returnedRows <= 1 && query.params.at(-1) === 1)).toBe(true); + + lookups.length = 0; + expect((await request(app).get(`/api/companies/${companyId}/users/missing-${randomUUID()}/profile`)).status).toBe(404); + expect(lookups).toHaveLength(2); + expect(lookups.every(query => query.returnedRows === 0 && query.params.at(-1) === 1)).toBe(true); + + const outsideCompanyId = randomUUID(); + const outsideUserId = randomUUID(); + await db.insert(companies).values({ id: outsideCompanyId, name: "Other profile tenant", issuePrefix: `X${outsideCompanyId.slice(0, 7)}` }); + await db.insert(authUsers).values({ id: outsideUserId, name: "Outside user", email: `${outsideUserId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: outsideCompanyId, principalType: "user", principalId: outsideUserId }); + lookups.length = 0; + expect((await request(app).get(`/api/companies/${companyId}/users/${outsideUserId}/profile`)).status).toBe(404); + expect(lookups).toHaveLength(2); + lookups.length = 0; + expect((await request(app).get(`/api/companies/${randomUUID()}/users/${userId}/profile`)).status).toBe(403); + expect(lookups).toHaveLength(0); + }); + + it("prefers an exact principal ID over a newer namesake and keeps fallback aliases ordered", async () => { + const actor = await resolve(db, headers()); + const companyId = actor!.companyIds![0]!; + const userId = actor!.userId!; + const newerId = randomUUID(); + await db.update(companyMemberships).set({ updatedAt: new Date("2020-01-01T00:00:00Z") }).where(and( + eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId), + )); + await db.insert(authUsers).values({ id: newerId, name: userId, email: `${newerId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: newerId }); + const { app, lookups } = profileApp(actor!); + expect((await request(app).get(`/api/companies/${companyId}/users/${userId}/profile`)).body.user.id).toBe(userId); + expect(lookups).toHaveLength(1); + await db.update(authUsers).set({ name: "Shared profile alias" }).where(eq(authUsers.id, userId)); + await db.update(authUsers).set({ name: "Shared profile alias" }).where(eq(authUsers.id, newerId)); + lookups.length = 0; + expect((await request(app).get(`/api/companies/${companyId}/users/shared-profile-alias/profile`)).body.user.id).toBe(newerId); + expect(lookups).toHaveLength(2); + }); + + it("returns a missing profile's 404 after exactly two membership statements with 601 members", async () => { + const actor = await resolve(db, headers()); + const companyId = actor!.companyIds![0]!; + await db.insert(companyMemberships).values(Array.from({ length: 600 }, () => ({ + companyId, principalType: "user", principalId: randomUUID(), + }))); + const { app, lookups } = profileApp(actor!); + expect((await request(app).get(`/api/companies/${companyId}/users/missing-${randomUUID()}/profile`)).status).toBe(404); + expect(lookups).toHaveLength(2); + expect(lookups.every(query => query.returnedRows === 0 && query.params.at(-1) === 1)).toBe(true); + }); + + it("exhaustively accounts for non-ASCII characters that JavaScript lowercases to ASCII", () => { + const mappings: Array<[string, string]> = []; + for (let codePoint = 128; codePoint <= 0x10ffff; codePoint += 1) { + const character = String.fromCodePoint(codePoint); + const lowered = character.toLowerCase(); + if (/[a-z]/.test(lowered)) mappings.push([character, lowered]); + } + expect(mappings).toEqual([["İ", "i\u0307"], ["K", "k"]]); + }); + + it.each(aliasFields)("matches the route's JavaScript slug oracle for %s aliases", async field => { + const cases = [" A'B\"C ", "İ", "xİy", "K", "xKy", "I\u0307", "İİ", "AİKB", "Å", "ΣΟΣ", "USER_with:ID", "\uFEFF A\u00A0B \t", "o'reilly", "a\"b", "@example.test", "a@@example.test", "---", "", "👩‍💻"]; + await expectProfileAliases(db, field, cases); + }); + + it("matches JavaScript aliases on SQL_ASCII without corrupting neighboring UTF-8 byte sequences", async () => { + const databaseName = `cloud_slug_ascii_${randomUUID().replace(/-/g, "")}`; + const connectionString = new URL(database.connectionString); + connectionString.pathname = `/${databaseName}`; + let client: Db | undefined; + let created = false; + try { + await db.$client.unsafe(`CREATE DATABASE ${databaseName} TEMPLATE template0 ENCODING 'SQL_ASCII' LC_COLLATE 'C' LC_CTYPE 'C'`); + created = true; + await applyPendingMigrations(connectionString.toString()); + client = createDb(connectionString.toString()); + const [encoding] = await client.$client.unsafe("SHOW server_encoding"); + expect(encoding.server_encoding).toBe("SQL_ASCII"); + const cases = ["A–B", "A—B", "A€B", "AÅB", "A℩B", "A\u2AAAB", "AİB", "AKB", "AİKB", " A'K\"İ B "]; + for (const field of aliasFields) await expectProfileAliases(client, field, cases); + + const actor = await resolve(client, headers()); + const companyId = actor!.companyIds![0]!; + const identity = randomUUID(); + await client.insert(authUsers).values({ id: identity, name: "€", email: "", createdAt: new Date(), updatedAt: new Date() }); + await client.insert(companyMemberships).values({ companyId, principalType: "user", principalId: identity }); + const { app, lookups } = profileApp(actor!, client); + expect((await request(app).get(`/api/companies/${companyId}/users/k/profile`)).status).toBe(404); + expect(lookups).toHaveLength(2); + } finally { + await client?.$client.end({ timeout: 0 }); + if (created) await db.$client.unsafe(`DROP DATABASE ${databaseName} WITH (FORCE)`); + } + }, 90_000); +}); diff --git a/server/src/middleware/cloud-tenant-sync.ts b/server/src/middleware/cloud-tenant-sync.ts new file mode 100644 index 00000000000..9ed0bccbb8b --- /dev/null +++ b/server/src/middleware/cloud-tenant-sync.ts @@ -0,0 +1,81 @@ +import type { Db } from "@paperclipai/db"; + +const DEBOUNCE_MS = 5_000; +const MAX_COMPLETED_USERS = 1_000; + +type SyncState = { + fingerprint?: string; + syncedAt?: number; + pending?: { fingerprint: string; promise: Promise }; +}; + +const statesByDatabase = new WeakMap>(); + +function prune(states: Map, now: number) { + for (const [userId, state] of states) { + if (!state.pending && (state.syncedAt === undefined || state.syncedAt <= now - DEBOUNCE_MS)) { + states.delete(userId); + } + } + let completed = [...states.values()].filter(state => !state.pending).length; + for (const [userId, state] of states) { + if (completed <= MAX_COMPLETED_USERS) break; + if (!state.pending) { + states.delete(userId); + completed -= 1; + } + } +} + +/** + * Serialize writes for a user (the auth user row is shared across companies). + * Only the last queued context can be shared: A -> B -> A must still apply A + * after B. Failed/partial syncs never populate the successful-write debounce. + * This caches writes only; callers must still resolve current authority. + */ +export async function syncCloudTenantWrites( + db: Db, + userId: string, + fingerprint: string, + sync: () => Promise, +): Promise { + let states = statesByDatabase.get(db); + if (!states) { + states = new Map(); + statesByDatabase.set(db, states); + } + prune(states, Date.now()); + let state = states.get(userId); + if (!state) { + state = {}; + states.set(userId, state); + } + if (state.pending?.fingerprint === fingerprint) return state.pending.promise; + + const previous = state.pending?.promise; + const run = async () => { + if (state.fingerprint === fingerprint && state.syncedAt !== undefined && state.syncedAt > Date.now() - DEBOUNCE_MS) { + return; + } + // A failed different context can have partially written the user or role. + // Invalidate the previous success before touching the database so a retry + // of that old context repairs it rather than incorrectly skipping writes. + state.fingerprint = undefined; + state.syncedAt = undefined; + await sync(); + state.fingerprint = fingerprint; + state.syncedAt = Date.now(); + }; + const promise = previous ? previous.catch(() => {}).then(run) : Promise.resolve().then(run); + state.pending = { fingerprint, promise }; + const settle = () => { + if (state.pending?.promise === promise) { + state.pending = undefined; + states.delete(userId); + states.set(userId, state); + prune(states, Date.now()); + } + }; + void promise.then(settle, settle); + return promise; +} diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts index 11d99273d97..24ed9f9e007 100644 --- a/server/src/routes/user-profiles.ts +++ b/server/src/routes/user-profiles.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, isNull, or, sql, type SQLWrapper } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -61,34 +61,57 @@ function userSlugCandidates(row: CompanyUserRow) { return [...candidates]; } +function userSlugSql(value: SQLWrapper) { + // Match JavaScript's ASCII slug after Unicode lowercasing, independently of + // the database locale. Only İ and K lowercase to ASCII from outside ASCII; + // İ also leaves a combining dot, which becomes a separator. Replace whole + // multibyte strings before ASCII-only translate so SQL_ASCII databases do + // not translate individual UTF-8 bytes. translate also drops both quotes. + return sql`btrim(regexp_replace(translate(replace(replace(${value}, 'İ', 'i-'), 'K', 'k'), + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ''"', 'abcdefghijklmnopqrstuvwxyz') collate "C", + '[^a-z0-9]+', '-', 'g'), '-')`; +} + async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): Promise { const slug = slugifyUserPart(rawSlug); if (!slug) return null; - const rows = await db - .select({ - id: companyMemberships.id, - principalId: companyMemberships.principalId, - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - createdAt: companyMemberships.createdAt, - userId: authUsers.id, - name: authUsers.name, - email: authUsers.email, - image: authUsers.image, - }) - .from(companyMemberships) - .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - ), - ) - .orderBy(desc(companyMemberships.updatedAt)) - .limit(200); + const select = () => db.select({ + id: companyMemberships.id, + principalId: companyMemberships.principalId, + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + createdAt: companyMemberships.createdAt, + userId: authUsers.id, + name: authUsers.name, + email: authUsers.email, + image: authUsers.image, + }) + .from(companyMemberships) + .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)); + const scope = and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user")); + const order = [desc(companyMemberships.updatedAt), desc(companyMemberships.id)]; - return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null; + // Known-user links use principal IDs. Prefer an indexed exact identity over + // a namesake's display slug, including older memberships beyond row 200. + const [exact] = await select() + .where(and(scope, inArray(companyMemberships.principalId, [...new Set([rawSlug, slug])]))) + .orderBy(desc(sql`${companyMemberships.principalId} = ${rawSlug}`), ...order) + .limit(1); + if (exact) return exact; + + // A normalized non-UUID ID cannot be reversed. Search all supported aliases + // in one bounded result instead of fetching/sorting successive member pages. + const [match] = await select() + .where(and(scope, or( + eq(userSlugSql(authUsers.name), slug), + eq(userSlugSql(sql`split_part(${authUsers.email}, '@', 1)`), slug), + eq(userSlugSql(authUsers.email), slug), + eq(userSlugSql(companyMemberships.principalId), slug), + ))) + .orderBy(...order) + .limit(1); + return match ?? null; } function userIssueInvolvementSql(companyId: string, userId: string) {