Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/accounts/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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'
);
});
});
25 changes: 21 additions & 4 deletions packages/accounts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */
Expand Down
Loading