Skip to content

Commit db8348e

Browse files
os-warrenclaude
andcommitted
fix(plugin-auth): scope the no-mail-transport rider on the invitation remedy
Clause-② review F1: the rider was FALSE on a reachable population. The invitation carve-out is an ADMISSION verdict, not a verification bypass, so the forced-verification mechanism this same line warns about for remedy (b) applies to the INVITED login too. Measured by the reviewer on `open` and on `email_domain` with a pending invitation: sign-up 200 with emailVerified false, sign-in 403 EMAIL_NOT_VERIFIED — and the created row makes probeSignInAccountsPresence answer 'present', so the report goes silent. An operator following the PRIMARY remedy on a widened posture therefore got no login and turned this report off: the loud-dead-end-to-quiet-dead-end transition remedy (a) warns about, delivered by the remedy the card commissioned. The rider is now scoped to the default posture and carries the ordering the reviewer measured to work — close a widened posture back to `invite_only` BEFORE the invited person registers. All three copies (the string literal, the block comment, the changeset) are aligned again. Also folded in from the same review: - N2: the invitation `email` must be stored LOWERCASE. `hasPendingInvitationFor` searches `where: { email: target.toLowerCase() }`, so a mixed-case row is never found and the sign-up is refused SELF_REGISTRATION_CLOSED — a silent dead end the line was sending operators into. - N1: `(INVALID_EMAIL_OR_PASSWORD)` is row-shape-dependent — an issuer-less hand-written row answers 401, a fully-shaped one with a plaintext password answers 500. Widened to "a 401 or a 500 depending on the row shape, never a session", which holds for both. - N3: dropped the "next boot" timing claim; under `single` the holder is usually promoted at the sign-up itself. The outcome is unchanged, so the line now states the outcome and not the timing. Five new pins, because the clause that was wrong is the one nothing guarded: the scoped phrasing itself; a widened posture admitting the invited creation AND still forcing verification on it (the two mechanisms driven together, since the false claim was that the first cancels the second); only the default posture being mail-transport-free; the structural fact that an admission verdict carries no field that could exempt verification; and N5 — every widening posture in `AUDIENCE_POSTURES` is named in the message, read off the vocabulary so a fourth one cannot leave the line silently stale. ⛔ Still message-text only: no admission semantics, no probe change, sign-up path untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent c80045d commit db8348e

3 files changed

Lines changed: 142 additions & 53 deletions

File tree

.changeset/boot-sign-in-report-remedy-text.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ That boot line fires on the deployment nobody can sign in to: human `sys_user` r
99
- **"Open the audience posture so an existing person can register their own login"** produced no login. Every posture other than `invite_only` forces `requireEmailVerification` on, so a login registered that way is refused `EMAIL_NOT_VERIFIED` at its first sign-in — and a locked-out self-hosted install is usually the shape with no mail transport wired.
1010
- **"Write a `sys_account` credential row directly against the store"** was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and the probe behind this report asks only whether *any* `sys_account` row exists, so writing one turns the report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end.
1111

12-
The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — an address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door is widened and no mail transport is needed; on the `single` tenancy posture the next boot promotes that account holder. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe.
12+
The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — a lowercase address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door needs widening. It is an admission verdict and not a verification bypass, though, so the line scopes what follows from that: only under the default `invite_only` posture is the recovery mail-transport-free, and it tells the operator to close a widened posture back to `invite_only` before the invited person registers — otherwise the invited login is created, refused `EMAIL_NOT_VERIFIED` at first sign-in, and has silenced this report on the way past. On the `single` tenancy posture that account holder is then promoted to platform admin. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe.
1313

1414
**Message text only — no admission semantics move.** Nothing widens, nothing narrows, no accept set changes, and the probe is untouched: this changes what an operator *reads*, not what the platform *admits*. The long form of the same three facts is on the self-hosting deployment page.

packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts

Lines changed: 114 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2929
import {
3030
AUDIENCE_POSTURES,
3131
SystemObjectName,
32+
audiencePermitsSelfRegistration,
3233
type AudiencePosture,
3334
} from '@objectstack/spec/system';
3435
import { AuthPlugin } from './auth-plugin';
@@ -146,6 +147,27 @@ describe('#14353 — the dead-end shape reports, by name, with consequence AND r
146147
expect(msg).toContain("'email_domain'");
147148
});
148149

150+
it('[#15588/F1] the no-mail-transport rider is SCOPED to the default posture', () => {
151+
// ⛔ This rider was WRONG in the first version of this line, stated for
152+
// EVERY posture. The carve-out is an ADMISSION verdict, not a verification
153+
// bypass, so on `open`/`email_domain` the INVITED login is created and
154+
// then refused EMAIL_NOT_VERIFIED — and creating it SILENCES this report
155+
// on the way past. That is the loud-dead-end-to-quiet-dead-end transition
156+
// remedy (b) warns about, delivered by the primary remedy. The exact
157+
// scoped phrasing is asserted so no future edit can re-generalise it.
158+
const msg = resolveNoSignInAccountReport(DEAD_END)!;
159+
expect(msg).toContain('ADMISSION verdict, not a verification bypass');
160+
expect(msg).toContain(
161+
"under the default 'invite_only' posture no mail transport is needed either",
162+
);
163+
// the rider must name the invited login as the thing verification hits …
164+
expect(msg).toContain('forces email verification on the INVITED login too');
165+
// … and give the operator the ORDER, which is the actionable half
166+
expect(msg).toContain("close the posture back to 'invite_only' BEFORE that person registers");
167+
// [#15588/N2] the lookup lowercases, so a mixed-case row is never found
168+
expect(msg).toContain('LOWERCASE address');
169+
});
170+
149171
it('[#15588] says NOTHING about what a SEEDED person\u2019s own re-registration answers', () => {
150172
// That response is #15587's surface and is being changed (a sign-up for an
151173
// address that already carries a `sys_user` row currently answers 200 and
@@ -209,22 +231,39 @@ describe('#14353 — the controls: every other shape is SILENT', () => {
209231
// pure decision function and the existing probe, and assert.
210232
// ---------------------------------------------------------------------------
211233

212-
describe('#15588 — the named remedy WORKS: a pending invitation is admitted under EVERY posture', () => {
213-
/**
214-
* A self-serve sign-up by the invited address, judged by the real gate.
215-
* The `email_domain` allowlist deliberately holds a DIFFERENT domain, so an
216-
* admission under that posture can only have come from the invitation
217-
* carve-out and never from the domain list.
218-
*/
219-
const invitedSignUp = (posture: AudiencePosture, hasPendingInvitation: boolean) =>
220-
decideAudienceAdmission({
221-
audience: { posture, allowedEmailDomains: ['not-the-invitee.example'] },
222-
creationClass: 'self-serve',
223-
email: 'recovery@example.com',
224-
hasPendingInvitation,
225-
isBootstrap: false,
226-
});
234+
/**
235+
* A self-serve sign-up by the invited address, judged by the real gate.
236+
* The `email_domain` allowlist deliberately holds a DIFFERENT domain, so an
237+
* admission under that posture can only have come from the invitation
238+
* carve-out and never from the domain list.
239+
*/
240+
const invitedSignUp = (posture: AudiencePosture, hasPendingInvitation: boolean) =>
241+
decideAudienceAdmission({
242+
audience: { posture, allowedEmailDomains: ['not-the-invitee.example'] },
243+
creationClass: 'self-serve',
244+
email: 'recovery@example.com',
245+
hasPendingInvitation,
246+
isBootstrap: false,
247+
});
248+
249+
/** A manager carrying one audience declaration, for the wiring mirror. */
250+
const AUDIENCE_TEST_SECRET = 'test-secret-at-least-32-chars-long!!';
251+
const managerWith = (audience?: Record<string, unknown>) =>
252+
new AuthManager({
253+
secret: AUDIENCE_TEST_SECRET,
254+
baseUrl: 'http://localhost:3000',
255+
dataEngine: engineOver({}).engine,
256+
...(audience ? { audience } : {}),
257+
} as never);
258+
259+
/** The declaration a widening posture needs to pass entry validation. */
260+
const wideningAudience = (posture: AudiencePosture) => ({
261+
posture,
262+
...(posture === 'email_domain' ? { allowedEmailDomains: ['acme.example'] } : {}),
263+
selfRegistrationPermissionSet: 'member_default',
264+
});
227265

266+
describe('#15588 — the named remedy WORKS: a pending invitation is admitted under EVERY posture', () => {
228267
it('every posture in the vocabulary admits it — the message says "under EVERY audience posture"', () => {
229268
// Reading the vocabulary rather than listing it: a posture added later is
230269
// covered on the day it is added, and the message's "EVERY" stays honest.
@@ -290,28 +329,15 @@ describe('#15588 — the WARNING is true: ANY hand-written `sys_account` row sil
290329
});
291330

292331
describe('#15588 — the WARNING is true: widening the posture FORCES email verification on', () => {
293-
const SECRET = 'test-secret-at-least-32-chars-long!!';
294-
const managerWith = (audience?: Record<string, unknown>) =>
295-
new AuthManager({
296-
secret: SECRET,
297-
baseUrl: 'http://localhost:3000',
298-
dataEngine: engineOver({}).engine,
299-
...(audience ? { audience } : {}),
300-
} as never);
301-
302332
it('`email_domain` and `open` both wire requireEmailVerification ON', () => {
303333
// The manager forces it from `audiencePermitsSelfRegistration(posture)` and
304334
// `getPublicConfig()` mirrors the wired flag, so this is the same fact the
305335
// message reports: a login registered under a widened posture cannot sign
306336
// in until a mail transport delivers the link.
307337
for (const posture of ['email_domain', 'open'] as const) {
308-
const manager = managerWith({
309-
posture,
310-
...(posture === 'email_domain' ? { allowedEmailDomains: ['acme.example'] } : {}),
311-
selfRegistrationPermissionSet: 'member_default',
312-
});
313338
expect(
314-
manager.getPublicConfig().emailPassword.requireEmailVerification,
339+
managerWith(wideningAudience(posture)).getPublicConfig().emailPassword
340+
.requireEmailVerification,
315341
`posture ${posture}`,
316342
).toBe(true);
317343
}
@@ -329,21 +355,70 @@ describe('#15588 — the WARNING is true: widening the posture FORCES email veri
329355
// wrong, and this is where that is caught.
330356
for (const posture of AUDIENCE_POSTURES) {
331357
expect(
332-
(managerWith(
333-
posture === 'invite_only'
334-
? undefined
335-
: {
336-
posture,
337-
...(posture === 'email_domain' ? { allowedEmailDomains: ['acme.example'] } : {}),
338-
selfRegistrationPermissionSet: 'member_default',
339-
},
340-
).getPublicConfig().emailPassword.requireEmailVerification),
358+
managerWith(
359+
posture === 'invite_only' ? undefined : wideningAudience(posture),
360+
).getPublicConfig().emailPassword.requireEmailVerification,
341361
`posture ${posture}`,
342362
).toBe(posture !== 'invite_only');
343363
}
344364
});
345365
});
346366

367+
describe('#15588/F1 — the carve-out ADMITS, it does not EXEMPT: the rider\u2019s scope, pinned', () => {
368+
// The clause that was wrong is the one nothing guarded. These drive the two
369+
// mechanisms together — the carve-out and the verification wiring — because
370+
// the false claim was precisely that the first cancels the second.
371+
372+
it('a widened posture admits the INVITED creation AND still forces verification on it', () => {
373+
for (const posture of AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration)) {
374+
// admitted by the carve-out …
375+
expect(invitedSignUp(posture, true), `posture ${posture}`).toMatchObject({ admit: true });
376+
// … into a runtime that will refuse its first sign-in EMAIL_NOT_VERIFIED.
377+
expect(
378+
managerWith(wideningAudience(posture)).getPublicConfig().emailPassword
379+
.requireEmailVerification,
380+
`posture ${posture}`,
381+
).toBe(true);
382+
}
383+
});
384+
385+
it('ONLY the default posture is mail-transport-free — the half the message still promises', () => {
386+
expect(invitedSignUp('invite_only', true)).toMatchObject({ admit: true });
387+
expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
388+
});
389+
390+
it('an admission verdict CANNOT express a verification exemption', () => {
391+
// Structural, and the reason the two mechanisms can never cancel: the
392+
// carve-out's verdict carries no field that could turn verification off
393+
// for the invited login. If one is ever added, the rider's scope changes
394+
// and this is where that gets noticed.
395+
expect(Object.keys(invitedSignUp('open', true)).sort()).toEqual([
396+
'admit',
397+
'grantPermissionSet',
398+
]);
399+
});
400+
});
401+
402+
describe('#15588/N5 — the message NAMES every posture that widens, read off the vocabulary', () => {
403+
it('every self-registration-permitting posture appears in the message by name', () => {
404+
// The message's quantifier ("every posture other than 'invite_only'") is
405+
// pinned elsewhere against the WIRING. This pins the spellings the message
406+
// hands the operator, read off the vocabulary rather than hardcoded, so a
407+
// posture added to `AUDIENCE_POSTURES` cannot leave this line silently
408+
// stale — it is named nowhere in the message and this goes red.
409+
const msg = resolveNoSignInAccountReport(DEAD_END)!;
410+
const widening = AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration);
411+
expect(widening.length).toBeGreaterThan(0);
412+
for (const posture of widening) {
413+
expect(msg, `posture ${posture} must be named in the message`).toContain(`'${posture}'`);
414+
}
415+
// and the one that does NOT widen is named as the exception it is
416+
expect(AUDIENCE_POSTURES.filter((p) => !audiencePermitsSelfRegistration(p))).toEqual([
417+
'invite_only',
418+
]);
419+
});
420+
});
421+
347422
// ---------------------------------------------------------------------------
348423
// The probes
349424
// ---------------------------------------------------------------------------

packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,18 @@
4242
* circuits on `hasPendingInvitation` BEFORE the posture switch, so a
4343
* self-serve creation holding a pending, unexpired `sys_invitation`
4444
* (`audience-posture.ts`, "The invitation carve-out") is admitted under
45-
* every posture in `AUDIENCE_POSTURES`. No door is widened and no mail
46-
* transport is involved. Pinned across the whole posture vocabulary, with
47-
* the un-invited `invite_only` refusal as its control.
45+
* every posture in `AUDIENCE_POSTURES`. No door needs widening. ⚠️ But the
46+
* carve-out is an ADMISSION verdict and NOT a verification bypass, so the
47+
* third bullet below applies to the INVITED login too: only under the
48+
* default `invite_only` posture is the recovery mail-transport-free, and on
49+
* `open`/`email_domain` the invited login is created and then refused
50+
* `EMAIL_NOT_VERIFIED` — which also SILENCES this report, the very
51+
* transition the second bullet warns about, delivered by the primary
52+
* remedy. The message therefore scopes the no-mail-transport rider and
53+
* tells the operator to close the posture first; ⛔ never re-state that
54+
* rider for EVERY posture. Pinned across the whole posture vocabulary, with
55+
* the un-invited `invite_only` refusal as its control, and the scope itself
56+
* pinned beside it.
4857
* - **A hand-written credential row blinds this very check.**
4958
* {@link probeSignInAccountsPresence} asks only whether ANY
5059
* `sys_account` row exists, so the operator's first attempt at "provision
@@ -259,16 +268,21 @@ export function resolveNoSignInAccountReport(facts: SignInReachabilityFacts): st
259268
'exists who could send an invitation. Boot continues and this deployment will keep LOOKING healthy — ' +
260269
'its only symptom is a 401 on credentials nobody holds. RECOVER IT FROM OUTSIDE THE RUNNING ' +
261270
'PRODUCT, and the path that works is ONE ROW: INVITE ONE ADDRESS — write a pending ' +
262-
`'${SystemObjectName.INVITATION}' row directly against the store ('email' an address this ` +
263-
`directory does NOT already hold, 'status' 'pending', a future 'expires_at', 'inviter_id' the id ` +
264-
`of any existing '${SystemObjectName.USER}' row), then have that person register through the ` +
265-
'ordinary sign-up endpoint. The invitation carve-out admits that ONE creation under EVERY ' +
266-
'audience posture, so no door is widened and no mail transport is needed; on the ' +
267-
"'single' tenancy posture the next boot then promotes that account holder to platform admin. " +
268-
'Afterwards, re-run the provisioning job that seeded these people so it seeds their logins too. ' +
269-
'TWO THINGS THAT LOOK LIKE REMEDIES AND ARE NOT: (a) HAND-WRITING A CREDENTIAL ROW — a ' +
270-
`'${SystemObjectName.ACCOUNT}' row's 'password' column must carry a secret in the platform's own ` +
271-
'hash format, so a plaintext password authenticates nothing (INVALID_EMAIL_OR_PASSWORD), and ' +
271+
`'${SystemObjectName.INVITATION}' row directly against the store ('email' a LOWERCASE address ` +
272+
`this directory does NOT already hold, 'status' 'pending', a future 'expires_at', 'inviter_id' ` +
273+
`the id of any existing '${SystemObjectName.USER}' row — the pending-invitation lookup ` +
274+
'lowercases the address it searches for, so a mixed-case row is never found), then have that ' +
275+
'person register through the ordinary sign-up endpoint. The invitation carve-out admits that ' +
276+
'ONE creation under EVERY audience posture, so no door needs widening — but it is an ADMISSION ' +
277+
"verdict, not a verification bypass: under the default 'invite_only' posture no mail transport " +
278+
"is needed either, whereas an 'open' or 'email_domain' posture forces email verification on the " +
279+
"INVITED login too (see (b)), so close the posture back to 'invite_only' BEFORE that person " +
280+
"registers. On the 'single' tenancy posture that account holder is then promoted to platform " +
281+
'admin. Afterwards, re-run the provisioning job that seeded these people so it seeds their ' +
282+
'logins too. TWO THINGS THAT LOOK LIKE REMEDIES AND ARE NOT: (a) HAND-WRITING A CREDENTIAL ' +
283+
`ROW — a '${SystemObjectName.ACCOUNT}' row's 'password' column must carry a secret in the ` +
284+
"platform's own hash format, so a plaintext password authenticates nothing — a 401 or a 500 " +
285+
'depending on the row shape, never a session — and ' +
272286
`writing ANY '${SystemObjectName.ACCOUNT}' row SILENCES THIS REPORT, which asks only whether such ` +
273287
'a row EXISTS — the deployment stops being loudly broken and becomes quietly broken; and (b) ' +
274288
"OPENING THE AUDIENCE POSTURE — every posture other than 'invite_only' ('open', 'email_domain') " +

0 commit comments

Comments
 (0)