Repository navigation
Expand file tree
/
Copy pathdev-admin-seed-gate.ts
More file actions
154 lines (145 loc) · 6.87 KB
/
Copy pathdev-admin-seed-gate.ts
File metadata and controls
154 lines (145 loc) · 6.87 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#14157] THE dev-admin seed's precondition — "does a LOGIN already exist?"
*
* ## The defect this module exists to close
*
* `objectstack dev` declares, in its own `--help`, that it seeds *a known,
* loginable dev admin … never overwrites an existing account*. The gate that
* implemented it asked a different question: **does any human `sys_user` row
* exist?** Those two are the same question only while every user row carries a
* credential.
*
* They come apart the moment an app declares people in `defineStack({ data })`.
* A seeded person is a **directory row with no account** — it is not a login,
* and treating it as one is the defect. The declarative seed is awaited inside
* `AppPlugin.start()`, so it has always landed before the seed's own
* `kernel:ready` hook runs: the database is non-zero-user *before* the check,
* the admin is never minted, and because the row survives, it is never minted
* on any later boot either. The deployment ends up with **no loginable account
* at all** — measured end to end on a 13-person demo seed: 13 `sys_user` rows,
* **zero** `sys_account` rows, `POST /auth/sign-in/email` → 401.
*
* So the predicate moves from the directory to the credential store, which is
* where the answer actually lives. Two reads, because the card's two suggested
* spellings each guard a case the other does not:
*
* 1. **Is the configured seed address already claimed?** Any account of any
* provider on that address means the seed must not touch it — the
* never-overwrite half of the declared contract, held whether that account
* is a local password or a federated identity. (This is the question
* `maybeReportExistingSeedAdmin` already asks one function later; it is
* asked *here* too so the gate and the report agree.)
* 2. **Does a local password login exist anywhere?** That is what the seed
* provides, so that is what makes providing it unnecessary. `provider_id:
* 'credential'` is the card's own prescription and triage's ruled fix
* direction ("gate on a credential-bearing account, not on user rows").
*
* ## Fail posture: unanswerable ⇒ do not act, loudly
*
* A probe that cannot be answered is NOT "no login" — reading it that way is
* how a gate mints a known-credential account into an environment it could not
* read. `unanswerable` is therefore its own verdict and the caller reports it,
* rather than folding into either decision. In practice it is close to
* unreachable: `sys_account` is registered by this very plugin, so a
* composition that has an auth plugin has the table this probe reads.
*
* ## Deliberate NON-consequence: this widens no public door
*
* The gate decides only whether the deployment's own boot command provisions
* its admin. The *admission* of that provisioning call is a separate question
* with a separate answer (`AuthManager.stageOperatorProvisioning`), because the
* audience gate's bootstrap bypass — "zero HUMAN users" — is a public
* self-registration carve-out and must keep counting humans. Measured: with 13
* seeded people and zero accounts, the seed's own `api.signUpEmail` call is
* refused `SELF_REGISTRATION_CLOSED` under the default `invite_only` posture.
* A fix that moved only this gate would have produced a seed that decides to
* run and a gate that then refuses it — the exact drift `isHumanUserRow`'s doc
* warns about.
*/
import { SystemObjectName } from '@objectstack/spec/system';
/** The bounded reads this probe performs — every data engine satisfies them. */
export interface DevAdminSeedProbeEngine {
find(
object: string,
query: Record<string, unknown>,
options?: unknown,
): Promise<Array<Record<string, unknown>>>;
}
/**
* Why the seed is not acting on this boot — or `act: true` when it should.
*
* The reasons are distinct because the caller says different things about
* them: a claimed address and an existing local login are both normal,
* expected outcomes that re-arm the credential hint, while `unanswerable` is a
* degradation the operator has to be told about.
*/
export type DevAdminSeedGateVerdict =
| { act: true }
| {
act: false;
/**
* `seed-address-claimed` — an account already holds the configured seed
* address (never overwrite it).
* `local-login-exists` — some other local password login already exists,
* so the environment is not login-less and the seed has nothing to add.
* `unanswerable` — the credential store could not be read; the seed
* declines rather than guessing.
*/
reason: 'seed-address-claimed' | 'local-login-exists' | 'unanswerable';
};
const SYSTEM_READ = { context: { isSystem: true } } as const;
/** better-auth's local password provider — the one the dev seed provisions. */
export const CREDENTIAL_PROVIDER_ID = 'credential';
/**
* Decide whether the dev-admin seed should provision on this boot.
*
* Never throws: an unanswerable read is reported as `unanswerable`, never
* silently folded into "no login exists".
*
* @param engine the data engine, read through the system context
* @param seedEmail the address {@link devSeedAdminEmail} resolved
*/
export async function decideDevAdminSeedGate(
engine: DevAdminSeedProbeEngine | undefined,
seedEmail: string,
): Promise<DevAdminSeedGateVerdict> {
if (!engine || typeof engine.find !== 'function') return { act: false, reason: 'unanswerable' };
try {
// (1) Is the configured address already claimed by an account?
//
// Both spellings, the same two-spelling read the walled-owner probe
// performs: better-auth lowercases on `createUser`, but a row inserted by
// some other path (an app seed, an import) carries whatever it was given,
// and `OS_SEED_ADMIN_EMAIL` may itself be mixed case.
const spellings = [...new Set([seedEmail, seedEmail.trim().toLowerCase()])];
const seedUserIds = new Set<unknown>();
for (const spelling of spellings) {
for (const row of await engine.find(
SystemObjectName.USER,
{ where: { email: spelling }, limit: 5 },
SYSTEM_READ,
)) {
if (row?.id != null) seedUserIds.add(row.id);
}
}
for (const userId of seedUserIds) {
const accounts = await engine.find(
SystemObjectName.ACCOUNT,
{ where: { user_id: userId }, limit: 1 },
SYSTEM_READ,
);
if (accounts.length > 0) return { act: false, reason: 'seed-address-claimed' };
}
// (2) Does any local password login exist at all?
const credentials = await engine.find(
SystemObjectName.ACCOUNT,
{ where: { provider_id: CREDENTIAL_PROVIDER_ID }, limit: 1 },
SYSTEM_READ,
);
if (credentials.length > 0) return { act: false, reason: 'local-login-exists' };
return { act: true };
} catch {
return { act: false, reason: 'unanswerable' };
}
}