From e524f0f55d809e59d66d841028091a7257f10ea5 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 09:03:47 +0000 Subject: [PATCH] fix(accounts): say what a failed personal principal actually needs --- packages/accounts/__tests__/endpoint.test.ts | 27 +++++++++++++++++++- packages/accounts/src/client.ts | 25 +++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/accounts/__tests__/endpoint.test.ts b/packages/accounts/__tests__/endpoint.test.ts index f223fe4..9da461a 100644 --- a/packages/accounts/__tests__/endpoint.test.ts +++ b/packages/accounts/__tests__/endpoint.test.ts @@ -1,4 +1,9 @@ -import { EndpointError, missingField, normalizeEndpoint } from '../src'; +import { + EndpointError, + explainFailure, + missingField, + normalizeEndpoint, +} from '../src'; describe('normalizeEndpoint', () => { it('appends the graphql path to a bare host', () => { @@ -62,3 +67,23 @@ describe('missingField', () => { ); }); }); + +describe('explainFailure', () => { + const ENDPOINT = 'http://auth.localhost:3000/graphql'; + + it('turns the mask constraint into what the operator can actually do about it', () => { + const text = explainFailure( + 'null value in column "allowed_mask" of relation "principals" violates not-null constraint', + ENDPOINT + ); + expect(text).toContain('personal principal'); + expect(text).toContain('organization'); + expect(text).not.toContain('not-null constraint'); + }); + + it('leaves a failure it has nothing to add to exactly as the server put it', () => { + expect(explainFailure('permission denied for table principals', ENDPOINT)).toBe( + 'permission denied for table principals' + ); + }); +}); diff --git a/packages/accounts/src/client.ts b/packages/accounts/src/client.ts index 9feaa4a..80a2b66 100644 --- a/packages/accounts/src/client.ts +++ b/packages/accounts/src/client.ts @@ -113,10 +113,27 @@ const readSession = ( }; /** - * A 404 means the URL is not a GraphQL route at all, which is by far the most - * common way to get this wrong — say so instead of passing on `HTTP 404`. + * An auth plane old enough to still keep `allowed_mask` on `principals` derives + * the mask from `bit_length(permissions)` in the general permission table, and + * inserts NULL when that lookup finds nothing — so the constraint, not the + * lookup, is what fails, and the raw message names a column the caller never + * sent. Later schemas moved the mask onto `principal_scope_overrides`, where + * absent means "inherits", and the personal path simply works. */ -const explain = (message: string, endpoint: string): string => { +const PERSONAL_MASK = + /null value in column "allowed_mask" of relation "principals"/i; + +export const explainFailure = (message: string, endpoint: string): string => { + if (PERSONAL_MASK.test(message)) { + return ( + 'this auth plane cannot create a personal principal: it still stores a ' + + 'permission mask on the principal itself and found no permission row to ' + + 'size it from — scope the principal to an organization instead, or ' + + 'update the auth plane' + ); + } + // a 404 means the URL is not a GraphQL route at all, which is by far the + // most common way to get this wrong — say so instead of passing on `HTTP 404` if (!/\b404\b/.test(message)) return message; const suffix = endpoint.endsWith('/graphql') ? 'check the host and that the auth plane is running' @@ -274,7 +291,7 @@ const rethrow = (operation: string, endpoint: string, error: unknown): never => const message = error instanceof Error ? error.message : String(error); const kind = stepUpKind(message); if (kind) throw new StepUpRequiredError(operation, kind, message); - throw new AuthError(operation, explain(message, endpoint)); + throw new AuthError(operation, explainFailure(message, endpoint)); }; /** The real client: `@constructive-io/sdk`'s generated auth ORM. */