fix(server): coalesce Cloud membership synchronization writes (#17890)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Cloud requests synchronize a human user's profile and company
membership.
> - Concurrent requests repeated the same writes before the debounce
could record success.
> - Upserts also updated unchanged rows and caused avoidable tuple work.
> - This pull request shares identical pending writes and guards
unchanged updates.
> - Each request still reads current authority. Bounded profile lookup
also reaches older memberships.
> - The benefit is less database work during request bursts with the
same actor results.

## Linked Issues or Issue Description

**What happened?**

A burst of 20 trusted Cloud requests issued 20 membership upserts and 40
profile plus membership tuple updates for one unchanged user. The
completed-write debounce did not share in-flight work.

**Expected behavior**

Identical in-flight requests share persistence work. Distinct contexts
retain their order. Failures remain retryable. Each request resolves its
own current company and role authority.

**Steps to reproduce**

Run the synthetic PostgreSQL comparison below at baseline
`db70c5acd92f50fa37fb4f0dea3da0e7685089a2`. The harness imports the real
resolvers and counts actual writes with database triggers.

Related work: #17663 covers adjacent Cloud authorization and projection
behavior. This change preserves fresh imported memberships,
owner-elevation settings and stale administrator removal.

## What Changed

- Serialize synchronization per database client and user. Share only the
last identical queued context. Preserve A/B/A transitions and retry
after partial failure.
- Guard user and membership updates with null-safe value comparisons.
Retain default-grant repair when `RETURNING` is empty.
- Keep authority reads outside the write debounce.
- Resolve exact principal IDs through the existing index, then use at
most one company-scoped alias query. This prevents both the old 200-row
profile cutoff and repeated membership-page scans. Exact IDs take
precedence over namesake aliases.
- Add concurrency tests, PostgreSQL tests, a reproducible benchmark and
behavior documentation.
- Accept both Git 2.55 and 2.56 diagnostic messages in an existing
workspace safety assertion, after full CI exposed that portability
failure. No expiration behavior changes.

## Verification

- Five focused suites passed, 63 tests total. These include Cloud actor
concurrency, transient database retry, company provisioning, real
PostgreSQL synchronization and the user profile route.
- The auth-to-profile regression returned 404 before the lookup repair
and 200 after it. The final fixture has 601 memberships and tests raw
and normalized non-UUID IDs, missing slugs, alias ordering and exact-ID
priority. A counterfactual with the old paging repair performs four
membership queries for a missing slug; the final code performs two, each
returning at most one row. Cross-company access returns 403 before
lookup.
- Local PostgreSQL 17.11 benchmark uses two warmup pairs and five
alternating measured pairs. Each pair starts a fresh debounce scope. All
20 complete actor results match on every sample.
- Burst median: 51.07 ms before, 25.71 ms after. Ranges: 47.21–63.25 ms
and 24.17–29.32 ms. Membership statements: 20 to one. Tuple updates: 40
to zero.
- Reproduce: `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-cloud-membership-sync.ts
--baseline-ref=db70c5acd92f50fa37fb4f0dea3da0e7685089a2
--docker-postgres=17`.
- Real PostgreSQL tests compare all four alias fields with the route's
own JavaScript canonical slug output across 76 fixtures. Exhaustive
Unicode lowercase inspection validates the special SQL mappings.
- A real SQL_ASCII database reproduces the previous en-dash alias 404.
The final whole-character replacements precede ASCII-only translation.
Four alias fields and ten multibyte edge cases pass, including rejection
of a false euro-to-k alias. The disposable database is migrated
normally, then removed.
- Server TypeScript passed with no diagnostics.
- PostgreSQL tests used the normal migrations with a temporary Docker
startup override because this host's native PostgreSQL semaphore
allocation is exhausted. No tracked test infrastructure changed.
- The first full CI run had an unrelated CLI company-import/export
health-start timeout. Review also identified repeated profile scans;
this follow-up repairs that finding. A later run passed 3,882 tests in
server shard 4/12 and failed one existing assertion because Git 2.56
changed its error wording for a missing gitfile target. The safety
behavior still refused expiration and preserved the workspace. The
assertion now accepts both valid messages; the actual Git/PostgreSQL
regression passes locally. Final head
`3a644e600ab0ae7315f9828b1ea9dffdef407e4a` passed [full
CI](https://github.com/paperclipai/paperclip/actions/runs/40537153522),
including the previously failing shard. There are 55 successful
checks/statuses, two skipped and one neutral. Fresh Greptile and
Paperclip reviews are both 5/5 on this exact head, with zero unresolved
threads. GitHub reports mergeable and CLEAN.

- Full repository typecheck and build passed on the integrated final
source. The local full test command was stopped after native PostgreSQL
initialization failed repeatedly, producing 16 failures and skipped
database suites. It is incomplete, not a pass. Focused real-database
suites used disposable Docker PostgreSQL. Current-head CI runs the full
gates separately.

## Risks

- Coalescing is local to one process and database client. Other replicas
can still take conflict locks.
- `updated_at` now records actual profile and membership changes. Access
lists sorted by it can show a different order. Profile resolution no
longer depends on the first 200 entries. Exact principal IDs take
precedence over namesake aliases. The single alias fallback still scans
company candidates; no constant-CPU claim is made.
- Default role grants retain their existing additive semantics. This
change does not define a new demotion policy.
- Local synthetic service timings exclude HTTP and network latency. No
hosted deployment or production speedup is claimed.
- No migration, startup job, polling change or deployment is included.

## Model Used

OpenAI Codex, configured model `gpt-6.1-sol` with xhigh reasoning, tool
use and code execution. Independent blast-radius review also used
`gpt-6-astra` and `gpt-6.1-sol`. The runtime context window was not
exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-27 11:21:44 -05:00
1 parent 2f3516f6fd
commit a26d217e94
8 files changed
+893 -151

No files matched your search

+70
View File
@@ -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.
+167
View File
@@ -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=<pre-fix commit>");
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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let baselineDir: string | undefined;
const clients = new Set<Db>();
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<Db["insert"]>) => {
const builder = target.insert(...args);
const values = builder.values.bind(builder);
builder.values = ((...input: Parameters<typeof values>) => {
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<ReturnType<typeof sample>>[]; after: Awaited<ReturnType<typeof sample>>[] } = { 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); }
}
@@ -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);
});
+58 -112
View File
@@ -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<Db, Map<string, { fingerprint: string; syncedAt: number }>>();
function cloudTenantWriteDebounceFor(db: Db) {
let debounce = cloudTenantWriteDebounces.get(db);
if (!debounce) {
debounce = new Map();
cloudTenantWriteDebounces.set(db, debounce);
}
return debounce;
}
function pruneCloudTenantWriteDebounce(
debounce: Map<string, { fingerprint: string; syncedAt: number }>,
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
+146 -14
View File
@@ -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<string, unknown> | null;
selectThrows?: boolean;
beforeWrite?: () => Promise<void>;
beforeWrite?: (table: unknown) => Promise<void>;
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<string, unknown> = {};
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<string, unknown> = {};
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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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();
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
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<string, string>) {
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<Db["select"]>) => {
const builder = target.select(...fields);
const from = builder.from.bind(builder);
builder.from = ((...args: Parameters<typeof from>) => {
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);
});
@@ -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<void> };
};
const statesByDatabase = new WeakMap<Db, Map<string, SyncState>>();
function prune(states: Map<string, SyncState>, 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<void>,
): Promise<void> {
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;
}
+47 -24
View File
@@ -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<string>`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<CompanyUserRow | null> {
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) {