@revealui/auth 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/admission-waitlist.d.ts +34 -0
- package/dist/server/admission-waitlist.d.ts.map +1 -0
- package/dist/server/admission-waitlist.js +210 -0
- package/dist/server/audit-bridge.d.ts +32 -0
- package/dist/server/audit-bridge.d.ts.map +1 -1
- package/dist/server/audit-bridge.js +75 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +16 -0
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -2
- package/dist/server/margin-admit.d.ts +55 -0
- package/dist/server/margin-admit.d.ts.map +1 -0
- package/dist/server/margin-admit.js +282 -0
- package/dist/server/platform-operator.d.ts +48 -0
- package/dist/server/platform-operator.d.ts.map +1 -0
- package/dist/server/platform-operator.js +152 -0
- package/dist/server/platform-roles.d.ts +17 -0
- package/dist/server/platform-roles.d.ts.map +1 -1
- package/dist/server/platform-roles.js +32 -2
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +13 -0
- package/package.json +15 -5
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAP-256 PR-4 — admission_waitlist I/O (NOT marketing waitlist).
|
|
3
|
+
*
|
|
4
|
+
* Stores only SHA-256 of claim tokens. Raw token returned once at enqueue.
|
|
5
|
+
*/
|
|
6
|
+
import { type AdmissionWaitlistRow } from '@revealui/db/schema';
|
|
7
|
+
export declare function hashAdmissionToken(raw: string): string;
|
|
8
|
+
export declare function generateAdmissionToken(): string;
|
|
9
|
+
export interface EnqueueAdmissionWaitlistParams {
|
|
10
|
+
email: string;
|
|
11
|
+
snapshotId: string | null;
|
|
12
|
+
modeAtEnqueue?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
now?: Date;
|
|
15
|
+
}
|
|
16
|
+
export interface EnqueueAdmissionWaitlistResult {
|
|
17
|
+
waitlistToken: string;
|
|
18
|
+
positionEstimate: number | null;
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Insert or rotate a pending admission waitlist row for email.
|
|
23
|
+
* Returns a fresh raw token (never stored).
|
|
24
|
+
*/
|
|
25
|
+
export declare function enqueueAdmissionWaitlist(params: EnqueueAdmissionWaitlistParams): Promise<EnqueueAdmissionWaitlistResult>;
|
|
26
|
+
/** Active claim rows: pending or invited, not past expiresAt. */
|
|
27
|
+
export declare function getAdmissionWaitlistByToken(rawToken: string): Promise<AdmissionWaitlistRow | null>;
|
|
28
|
+
export declare function getAdmissionWaitlistByTokenAnyStatus(rawToken: string): Promise<AdmissionWaitlistRow | null>;
|
|
29
|
+
export declare function markAdmissionWaitlistConverted(id: string, now?: Date): Promise<void>;
|
|
30
|
+
/** Mask email for public status (anti-enumeration still requires valid token). */
|
|
31
|
+
export declare function maskAdmissionEmail(email: string): string;
|
|
32
|
+
/** Position estimate among pending (best-effort; null on failure). */
|
|
33
|
+
export declare function estimateAdmissionWaitlistPosition(id: string): Promise<number | null>;
|
|
34
|
+
//# sourceMappingURL=admission-waitlist.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admission-waitlist.d.ts","sourceRoot":"","sources":["../../src/server/admission-waitlist.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAE,KAAK,oBAAoB,EAAqB,MAAM,qBAAqB,CAAC;AAKnF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AA0BD,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,8BAA8B;IAC7C,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;GAGG;AACH,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,8BAA8B,GACrC,OAAO,CAAC,8BAA8B,CAAC,CA8FzC;AAED,iEAAiE;AACjE,wBAAsB,2BAA2B,CAC/C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAqBtC;AAED,wBAAsB,oCAAoC,CACxD,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAYtC;AAED,wBAAsB,8BAA8B,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAU1F;AAED,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUxD;AAED,sEAAsE;AACtE,wBAAsB,iCAAiC,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA2B1F"}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAP-256 PR-4 — admission_waitlist I/O (NOT marketing waitlist).
|
|
3
|
+
*
|
|
4
|
+
* Stores only SHA-256 of claim tokens. Raw token returned once at enqueue.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import { logger } from '@revealui/core/observability/logger';
|
|
8
|
+
import { getClient } from '@revealui/db';
|
|
9
|
+
import { admissionWaitlist } from '@revealui/db/schema';
|
|
10
|
+
import { and, count, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
11
|
+
const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
export function hashAdmissionToken(raw) {
|
|
13
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export function generateAdmissionToken() {
|
|
16
|
+
return randomBytes(32).toString('hex');
|
|
17
|
+
}
|
|
18
|
+
function normalizeEmail(email) {
|
|
19
|
+
return email.trim().toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
function expiresAtFrom(now) {
|
|
22
|
+
return new Date(now.getTime() + TOKEN_TTL_MS);
|
|
23
|
+
}
|
|
24
|
+
async function countPendingRows() {
|
|
25
|
+
try {
|
|
26
|
+
const db = getClient();
|
|
27
|
+
const [row] = await db
|
|
28
|
+
.select({ total: count() })
|
|
29
|
+
.from(admissionWaitlist)
|
|
30
|
+
.where(eq(admissionWaitlist.status, 'pending'));
|
|
31
|
+
return row?.total ?? 0;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
logger.warn('[admission-waitlist] pending count failed', {
|
|
35
|
+
error: err instanceof Error ? err.message : String(err),
|
|
36
|
+
});
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Insert or rotate a pending admission waitlist row for email.
|
|
42
|
+
* Returns a fresh raw token (never stored).
|
|
43
|
+
*/
|
|
44
|
+
export async function enqueueAdmissionWaitlist(params) {
|
|
45
|
+
const db = getClient();
|
|
46
|
+
const email = normalizeEmail(params.email);
|
|
47
|
+
const now = params.now ?? new Date();
|
|
48
|
+
const rawToken = generateAdmissionToken();
|
|
49
|
+
const tokenHash = hashAdmissionToken(rawToken);
|
|
50
|
+
const expiresAt = expiresAtFrom(now);
|
|
51
|
+
const modeAtEnqueue = params.modeAtEnqueue ?? 'waitlist';
|
|
52
|
+
const source = params.source ?? 'free_signup';
|
|
53
|
+
const [existing] = await db
|
|
54
|
+
.select({
|
|
55
|
+
id: admissionWaitlist.id,
|
|
56
|
+
position: admissionWaitlist.position,
|
|
57
|
+
})
|
|
58
|
+
.from(admissionWaitlist)
|
|
59
|
+
.where(and(eq(admissionWaitlist.email, email), eq(admissionWaitlist.status, 'pending')))
|
|
60
|
+
.limit(1);
|
|
61
|
+
if (existing) {
|
|
62
|
+
await db
|
|
63
|
+
.update(admissionWaitlist)
|
|
64
|
+
.set({
|
|
65
|
+
tokenHash,
|
|
66
|
+
snapshotId: params.snapshotId,
|
|
67
|
+
modeAtEnqueue,
|
|
68
|
+
source,
|
|
69
|
+
expiresAt,
|
|
70
|
+
})
|
|
71
|
+
.where(eq(admissionWaitlist.id, existing.id));
|
|
72
|
+
return {
|
|
73
|
+
waitlistToken: rawToken,
|
|
74
|
+
positionEstimate: existing.position,
|
|
75
|
+
id: existing.id,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const pendingCount = await countPendingRows();
|
|
79
|
+
const positionEstimate = pendingCount === null ? null : pendingCount + 1;
|
|
80
|
+
const id = crypto.randomUUID();
|
|
81
|
+
try {
|
|
82
|
+
await db.insert(admissionWaitlist).values({
|
|
83
|
+
id,
|
|
84
|
+
email,
|
|
85
|
+
status: 'pending',
|
|
86
|
+
tokenHash,
|
|
87
|
+
snapshotId: params.snapshotId,
|
|
88
|
+
modeAtEnqueue,
|
|
89
|
+
position: positionEstimate,
|
|
90
|
+
source,
|
|
91
|
+
metadata: {},
|
|
92
|
+
createdAt: now,
|
|
93
|
+
expiresAt,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Race on unique pending email: rotate the winning row's token.
|
|
98
|
+
logger.warn('[admission-waitlist] insert conflict; rotating existing pending', {
|
|
99
|
+
error: err instanceof Error ? err.message : String(err),
|
|
100
|
+
});
|
|
101
|
+
const [raced] = await db
|
|
102
|
+
.select({
|
|
103
|
+
id: admissionWaitlist.id,
|
|
104
|
+
position: admissionWaitlist.position,
|
|
105
|
+
})
|
|
106
|
+
.from(admissionWaitlist)
|
|
107
|
+
.where(and(eq(admissionWaitlist.email, email), eq(admissionWaitlist.status, 'pending')))
|
|
108
|
+
.limit(1);
|
|
109
|
+
if (!raced) {
|
|
110
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
111
|
+
}
|
|
112
|
+
await db
|
|
113
|
+
.update(admissionWaitlist)
|
|
114
|
+
.set({
|
|
115
|
+
tokenHash,
|
|
116
|
+
snapshotId: params.snapshotId,
|
|
117
|
+
modeAtEnqueue,
|
|
118
|
+
source,
|
|
119
|
+
expiresAt,
|
|
120
|
+
})
|
|
121
|
+
.where(eq(admissionWaitlist.id, raced.id));
|
|
122
|
+
return {
|
|
123
|
+
waitlistToken: rawToken,
|
|
124
|
+
positionEstimate: raced.position,
|
|
125
|
+
id: raced.id,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
waitlistToken: rawToken,
|
|
130
|
+
positionEstimate,
|
|
131
|
+
id,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Active claim rows: pending or invited, not past expiresAt. */
|
|
135
|
+
export async function getAdmissionWaitlistByToken(rawToken) {
|
|
136
|
+
if (!rawToken || rawToken.trim() === '') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const db = getClient();
|
|
140
|
+
const tokenHash = hashAdmissionToken(rawToken);
|
|
141
|
+
const now = new Date();
|
|
142
|
+
const [row] = await db
|
|
143
|
+
.select()
|
|
144
|
+
.from(admissionWaitlist)
|
|
145
|
+
.where(and(eq(admissionWaitlist.tokenHash, tokenHash), inArray(admissionWaitlist.status, ['pending', 'invited']), or(isNull(admissionWaitlist.expiresAt), gt(admissionWaitlist.expiresAt, now))))
|
|
146
|
+
.limit(1);
|
|
147
|
+
return row ?? null;
|
|
148
|
+
}
|
|
149
|
+
export async function getAdmissionWaitlistByTokenAnyStatus(rawToken) {
|
|
150
|
+
if (!rawToken || rawToken.trim() === '') {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const db = getClient();
|
|
154
|
+
const tokenHash = hashAdmissionToken(rawToken);
|
|
155
|
+
const [row] = await db
|
|
156
|
+
.select()
|
|
157
|
+
.from(admissionWaitlist)
|
|
158
|
+
.where(eq(admissionWaitlist.tokenHash, tokenHash))
|
|
159
|
+
.limit(1);
|
|
160
|
+
return row ?? null;
|
|
161
|
+
}
|
|
162
|
+
export async function markAdmissionWaitlistConverted(id, now) {
|
|
163
|
+
const db = getClient();
|
|
164
|
+
const convertedAt = now ?? new Date();
|
|
165
|
+
await db
|
|
166
|
+
.update(admissionWaitlist)
|
|
167
|
+
.set({
|
|
168
|
+
status: 'converted',
|
|
169
|
+
convertedAt,
|
|
170
|
+
})
|
|
171
|
+
.where(eq(admissionWaitlist.id, id));
|
|
172
|
+
}
|
|
173
|
+
/** Mask email for public status (anti-enumeration still requires valid token). */
|
|
174
|
+
export function maskAdmissionEmail(email) {
|
|
175
|
+
const normalized = normalizeEmail(email);
|
|
176
|
+
const at = normalized.indexOf('@');
|
|
177
|
+
if (at <= 0) {
|
|
178
|
+
return '***';
|
|
179
|
+
}
|
|
180
|
+
const local = normalized.slice(0, at);
|
|
181
|
+
const domain = normalized.slice(at + 1);
|
|
182
|
+
const visible = local.slice(0, 1);
|
|
183
|
+
return `${visible}***@${domain}`;
|
|
184
|
+
}
|
|
185
|
+
/** Position estimate among pending (best-effort; null on failure). */
|
|
186
|
+
export async function estimateAdmissionWaitlistPosition(id) {
|
|
187
|
+
try {
|
|
188
|
+
const db = getClient();
|
|
189
|
+
const [self] = await db
|
|
190
|
+
.select({
|
|
191
|
+
position: admissionWaitlist.position,
|
|
192
|
+
createdAt: admissionWaitlist.createdAt,
|
|
193
|
+
})
|
|
194
|
+
.from(admissionWaitlist)
|
|
195
|
+
.where(eq(admissionWaitlist.id, id))
|
|
196
|
+
.limit(1);
|
|
197
|
+
if (!self)
|
|
198
|
+
return null;
|
|
199
|
+
if (self.position != null)
|
|
200
|
+
return self.position;
|
|
201
|
+
const [row] = await db
|
|
202
|
+
.select({ total: count() })
|
|
203
|
+
.from(admissionWaitlist)
|
|
204
|
+
.where(and(eq(admissionWaitlist.status, 'pending'), sql `${admissionWaitlist.createdAt} <= ${self.createdAt}`));
|
|
205
|
+
return row?.total ?? null;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -66,4 +66,36 @@ export declare function auditSessionRevoked(userId: string, sessionId: string, i
|
|
|
66
66
|
* @param failedAttempts - Number of failed attempts that triggered the lockout
|
|
67
67
|
*/
|
|
68
68
|
export declare function auditAccountLocked(email: string, ip: string, failedAttempts: number): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Structured fields required by GAP-464 / #449 Audit wiring.
|
|
71
|
+
* `userId` may be absent on pre-JIT login failures.
|
|
72
|
+
*/
|
|
73
|
+
export interface SsoAuditContext {
|
|
74
|
+
providerType?: string;
|
|
75
|
+
accountId?: string;
|
|
76
|
+
userId?: string;
|
|
77
|
+
issuer?: string;
|
|
78
|
+
providerId?: string;
|
|
79
|
+
ip?: string;
|
|
80
|
+
userAgent?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Record a successful Enterprise SSO login (OIDC or SAML ACS).
|
|
84
|
+
* Persists via the audit door before the HTTP response returns.
|
|
85
|
+
*/
|
|
86
|
+
export declare function auditSsoLoginSuccess(context: SsoAuditContext): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Record a failed Enterprise SSO login attempt.
|
|
89
|
+
* Persists via the audit door before the HTTP response returns.
|
|
90
|
+
*/
|
|
91
|
+
export declare function auditSsoLoginFailure(context: SsoAuditContext & {
|
|
92
|
+
reason: string;
|
|
93
|
+
}): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Record an SSO provider create / update / delete (or equivalent mapping change).
|
|
96
|
+
* Persists via the audit door before the HTTP response returns.
|
|
97
|
+
*/
|
|
98
|
+
export declare function auditSsoConfigChanged(context: SsoAuditContext & {
|
|
99
|
+
action: string;
|
|
100
|
+
}): Promise<void>;
|
|
69
101
|
//# sourceMappingURL=audit-bridge.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-bridge.d.ts","sourceRoot":"","sources":["../../src/server/audit-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA2CH;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CASf;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CASnF;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAUjF;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAShF;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAUf"}
|
|
1
|
+
{"version":3,"file":"audit-bridge.d.ts","sourceRoot":"","sources":["../../src/server/audit-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA2CH;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CASf;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CASnF;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAUjF;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAShF;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAYD;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBlF;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAiBf;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAiBf"}
|
|
@@ -179,3 +179,78 @@ export async function auditAccountLocked(email, ip, failedAttempts) {
|
|
|
179
179
|
metadata: { email, failedAttempts },
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
|
+
function ssoMetadata(context) {
|
|
183
|
+
return {
|
|
184
|
+
providerType: context.providerType ?? null,
|
|
185
|
+
accountId: context.accountId ?? null,
|
|
186
|
+
userId: context.userId ?? null,
|
|
187
|
+
issuer: context.issuer ?? null,
|
|
188
|
+
providerId: context.providerId ?? null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Record a successful Enterprise SSO login (OIDC or SAML ACS).
|
|
193
|
+
* Persists via the audit door before the HTTP response returns.
|
|
194
|
+
*/
|
|
195
|
+
export async function auditSsoLoginSuccess(context) {
|
|
196
|
+
const actorId = context.userId ?? context.accountId ?? 'unknown';
|
|
197
|
+
await logAuditEvent({
|
|
198
|
+
type: 'sso_login_success',
|
|
199
|
+
severity: 'low',
|
|
200
|
+
actor: {
|
|
201
|
+
id: actorId,
|
|
202
|
+
type: 'user',
|
|
203
|
+
ip: context.ip,
|
|
204
|
+
userAgent: context.userAgent,
|
|
205
|
+
},
|
|
206
|
+
resource: context.providerId ? { type: 'sso_provider', id: context.providerId } : undefined,
|
|
207
|
+
action: 'sso_login',
|
|
208
|
+
result: 'success',
|
|
209
|
+
message: 'SSO login succeeded',
|
|
210
|
+
metadata: ssoMetadata(context),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Record a failed Enterprise SSO login attempt.
|
|
215
|
+
* Persists via the audit door before the HTTP response returns.
|
|
216
|
+
*/
|
|
217
|
+
export async function auditSsoLoginFailure(context) {
|
|
218
|
+
const actorId = context.userId ?? context.accountId ?? 'unknown';
|
|
219
|
+
await logAuditEvent({
|
|
220
|
+
type: 'sso_login_failure',
|
|
221
|
+
severity: 'medium',
|
|
222
|
+
actor: {
|
|
223
|
+
id: actorId,
|
|
224
|
+
type: 'user',
|
|
225
|
+
ip: context.ip,
|
|
226
|
+
userAgent: context.userAgent,
|
|
227
|
+
},
|
|
228
|
+
resource: context.providerId ? { type: 'sso_provider', id: context.providerId } : undefined,
|
|
229
|
+
action: 'sso_login',
|
|
230
|
+
result: 'failure',
|
|
231
|
+
message: `SSO login failed: ${context.reason}`,
|
|
232
|
+
metadata: { ...ssoMetadata(context), reason: context.reason },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Record an SSO provider create / update / delete (or equivalent mapping change).
|
|
237
|
+
* Persists via the audit door before the HTTP response returns.
|
|
238
|
+
*/
|
|
239
|
+
export async function auditSsoConfigChanged(context) {
|
|
240
|
+
const actorId = context.userId ?? context.accountId ?? 'unknown';
|
|
241
|
+
await logAuditEvent({
|
|
242
|
+
type: 'sso_config_changed',
|
|
243
|
+
severity: 'medium',
|
|
244
|
+
actor: {
|
|
245
|
+
id: actorId,
|
|
246
|
+
type: 'user',
|
|
247
|
+
ip: context.ip,
|
|
248
|
+
userAgent: context.userAgent,
|
|
249
|
+
},
|
|
250
|
+
resource: context.providerId ? { type: 'sso_provider', id: context.providerId } : undefined,
|
|
251
|
+
action: context.action,
|
|
252
|
+
result: 'success',
|
|
253
|
+
message: `SSO provider ${context.action}`,
|
|
254
|
+
metadata: { ...ssoMetadata(context), action: context.action },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAQ,MAAM,aAAa,CAAC;AAWpE;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAQ,MAAM,aAAa,CAAC;AAWpE;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,OAAO,CAAC,YAAY,CAAC,CA0QvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBtD;AAED;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,IAAI,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GACA,OAAO,CAAC,YAAY,CAAC,CAyOvB"}
|
package/dist/server/auth.js
CHANGED
|
@@ -192,6 +192,7 @@ export async function signIn(email, password, options) {
|
|
|
192
192
|
// Rotate session: delete all existing sessions for this user, then create
|
|
193
193
|
// a fresh one. This prevents session fixation attacks where an attacker
|
|
194
194
|
// plants a session token that the victim later authenticates.
|
|
195
|
+
// createSession also repairs membership-owner shell role (GAP-473).
|
|
195
196
|
let token;
|
|
196
197
|
try {
|
|
197
198
|
const sessionResult = await rotateSession(user.id, {
|
|
@@ -208,6 +209,21 @@ export async function signIn(email, password, options) {
|
|
|
208
209
|
error: 'Failed to create session',
|
|
209
210
|
};
|
|
210
211
|
}
|
|
212
|
+
// Re-read role after session mint so revealui-role cookie matches DB when
|
|
213
|
+
// ensureShellAdminIfAccountOwner promoted viewer → admin (GAP-473).
|
|
214
|
+
try {
|
|
215
|
+
const [roleRow] = await db
|
|
216
|
+
.select({ role: users.role })
|
|
217
|
+
.from(users)
|
|
218
|
+
.where(eq(users.id, user.id))
|
|
219
|
+
.limit(1);
|
|
220
|
+
if (roleRow && roleRow.role !== user.role) {
|
|
221
|
+
user = { ...user, role: roleRow.role };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Cookie may lag one login; next getSession still sees repaired DB role.
|
|
226
|
+
}
|
|
211
227
|
// A fresh session was minted: the login succeeded. Land a success receipt
|
|
212
228
|
// in the audit trail (best-effort; auditLoginSuccess never throws, so an
|
|
213
229
|
// audit-write failure cannot fail the login).
|
package/dist/server/index.d.ts
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* Inspired by Better Auth and Neon Auth patterns.
|
|
6
6
|
*/
|
|
7
7
|
export type { SignInResult, SignUpResult } from '../types.js';
|
|
8
|
-
export {
|
|
8
|
+
export { type EnqueueAdmissionWaitlistParams, type EnqueueAdmissionWaitlistResult, enqueueAdmissionWaitlist, estimateAdmissionWaitlistPosition, generateAdmissionToken, getAdmissionWaitlistByToken, getAdmissionWaitlistByTokenAnyStatus, hashAdmissionToken, markAdmissionWaitlistConverted, maskAdmissionEmail, } from './admission-waitlist.js';
|
|
9
|
+
export { auditAccountLocked, auditLoginFailure, auditLoginSuccess, auditMfaDisabled, auditMfaEnabled, auditPasswordChange, auditPasswordReset, auditSessionRevoked, auditSsoConfigChanged, auditSsoLoginFailure, auditSsoLoginSuccess, type SsoAuditContext, } from './audit-bridge.js';
|
|
9
10
|
export { isSignupAllowed, signIn, signUp } from './auth.js';
|
|
10
11
|
export { clearFailedAttempts, configureBruteForce, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, resetBruteForceConfig, } from './brute-force.js';
|
|
11
12
|
export { AuthError, AuthenticationError, DatabaseError, OAuthAccountConflictError, SessionError, TokenError, } from './errors.js';
|
|
12
13
|
export type { MagicLinkConfig } from './magic-link.js';
|
|
13
14
|
export { configureMagicLink, createMagicLink, resetMagicLinkConfig, verifyMagicLink, } from './magic-link.js';
|
|
15
|
+
export { type AdmitFreeIntakeInput, type AdmitFreeIntakeResult, admitFreeIntake, ensureFreeSignupEntitlement, OPEN_FREE_LIMITS, provisionHostedPersonalAccount, } from './margin-admit.js';
|
|
14
16
|
export type { MFAConfig, MFADisableProof, MFASetupResult } from './mfa.js';
|
|
15
17
|
export { configureMFA, disableMFA, initiateMFASetup, isMFAEnabled, regenerateBackupCodes, resetMFAConfig, verifyBackupCode, verifyMFACode, verifyMFASetup, } from './mfa.js';
|
|
16
18
|
export type { MfaCheckResult, MfaEnforcementOptions, MfaErrorResponse, MfaRequest, MfaSession, MfaSessionUser, } from './mfa-enforcement.js';
|
|
@@ -21,7 +23,8 @@ export { configurePasskey, countUserCredentials, deletePasskey, generateAuthenti
|
|
|
21
23
|
export type { ChangePasswordResult, PasswordResetResult, PasswordResetToken, } from './password-reset.js';
|
|
22
24
|
export { changePassword, generatePasswordResetToken, invalidatePasswordResetToken, resetPasswordWithToken, validatePasswordResetToken, } from './password-reset.js';
|
|
23
25
|
export { meetsMinimumPasswordRequirements, validatePasswordStrength, } from './password-validation.js';
|
|
24
|
-
export {
|
|
26
|
+
export { ensurePlatformOperatorEntitlement, isPlatformOperatorUser, PLATFORM_OPERATOR_LIMITS, type PlatformOperatorUserView, rolesFromUserJson, } from './platform-operator.js';
|
|
27
|
+
export { ensureAccountOwnerPlatformAdmin, ensureShellAdminIfAccountOwner, isPlatformShellAdminRole, PLATFORM_SHELL_ADMIN_COOKIE_ROLES, PLATFORM_SHELL_ADMIN_ROLES, platformRoleForAccountOwner, readUsersRole, } from './platform-roles.js';
|
|
25
28
|
export { checkRateLimit, configureRateLimit, getRateLimitStatus, resetRateLimit, resetRateLimitConfig, } from './rate-limit.js';
|
|
26
29
|
export type { RequestContext, SessionBindingConfig, SessionData } from './session.js';
|
|
27
30
|
export { configureSessionBinding, createSession, deleteAllUserSessions, deleteOtherUserSessions, deleteSession, getSession, isRecoverySession, resetSessionBindingConfig, rotateSession, validateSessionBinding, } from './session.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE9D,OAAO,EACL,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,EACf,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
|
package/dist/server/index.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Server-side authentication functions for Next.js and TanStack Start.
|
|
5
5
|
* Inspired by Better Auth and Neon Auth patterns.
|
|
6
6
|
*/
|
|
7
|
+
// GAP-256 admission waitlist (NOT marketing waitlist)
|
|
8
|
+
export { enqueueAdmissionWaitlist, estimateAdmissionWaitlistPosition, generateAdmissionToken, getAdmissionWaitlistByToken, getAdmissionWaitlistByTokenAnyStatus, hashAdmissionToken, markAdmissionWaitlistConverted, maskAdmissionEmail, } from './admission-waitlist.js';
|
|
7
9
|
// Audit bridge
|
|
8
|
-
export { auditAccountLocked, auditLoginFailure, auditLoginSuccess, auditMfaDisabled, auditMfaEnabled, auditPasswordChange, auditPasswordReset, auditSessionRevoked, } from './audit-bridge.js';
|
|
10
|
+
export { auditAccountLocked, auditLoginFailure, auditLoginSuccess, auditMfaDisabled, auditMfaEnabled, auditPasswordChange, auditPasswordReset, auditSessionRevoked, auditSsoConfigChanged, auditSsoLoginFailure, auditSsoLoginSuccess, } from './audit-bridge.js';
|
|
9
11
|
// NOTE (GAP-338): the audit storage boundary lives at the DEDICATED subpath
|
|
10
12
|
// `@revealui/auth/audit-storage`, deliberately NOT re-exported from this
|
|
11
13
|
// barrel. `@revealui/auth/server` is the fleet's most-mocked module (route
|
|
@@ -16,13 +18,16 @@ export { isSignupAllowed, signIn, signUp } from './auth.js';
|
|
|
16
18
|
export { clearFailedAttempts, configureBruteForce, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, resetBruteForceConfig, } from './brute-force.js';
|
|
17
19
|
export { AuthError, AuthenticationError, DatabaseError, OAuthAccountConflictError, SessionError, TokenError, } from './errors.js';
|
|
18
20
|
export { configureMagicLink, createMagicLink, resetMagicLinkConfig, verifyMagicLink, } from './magic-link.js';
|
|
21
|
+
// GAP-256 margin admit (shared admin + server free-intake)
|
|
22
|
+
export { admitFreeIntake, ensureFreeSignupEntitlement, OPEN_FREE_LIMITS, provisionHostedPersonalAccount, } from './margin-admit.js';
|
|
19
23
|
export { configureMFA, disableMFA, initiateMFASetup, isMFAEnabled, regenerateBackupCodes, resetMFAConfig, verifyBackupCode, verifyMFACode, verifyMFASetup, } from './mfa.js';
|
|
20
24
|
export { checkSessionMfa, requireMfa, toMfaSession } from './mfa-enforcement.js';
|
|
21
25
|
export { buildAuthUrl, exchangeCode, fetchProviderUser, generateOAuthState, getLinkedProviders, linkOAuthAccount, unlinkOAuthAccount, upsertOAuthUser, verifyOAuthState, } from './oauth.js';
|
|
22
26
|
export { configurePasskey, countUserCredentials, deletePasskey, generateAuthenticationChallenge, generateRegistrationChallenge, listPasskeys, renamePasskey, resetPasskeyConfig, storePasskey, verifyAuthentication, verifyRegistration, } from './passkey.js';
|
|
23
27
|
export { changePassword, generatePasswordResetToken, invalidatePasswordResetToken, resetPasswordWithToken, validatePasswordResetToken, } from './password-reset.js';
|
|
24
28
|
export { meetsMinimumPasswordRequirements, validatePasswordStrength, } from './password-validation.js';
|
|
25
|
-
export {
|
|
29
|
+
export { ensurePlatformOperatorEntitlement, isPlatformOperatorUser, PLATFORM_OPERATOR_LIMITS, rolesFromUserJson, } from './platform-operator.js';
|
|
30
|
+
export { ensureAccountOwnerPlatformAdmin, ensureShellAdminIfAccountOwner, isPlatformShellAdminRole, PLATFORM_SHELL_ADMIN_COOKIE_ROLES, PLATFORM_SHELL_ADMIN_ROLES, platformRoleForAccountOwner, readUsersRole, } from './platform-roles.js';
|
|
26
31
|
export { checkRateLimit, configureRateLimit, getRateLimitStatus, resetRateLimit, resetRateLimitConfig, } from './rate-limit.js';
|
|
27
32
|
export { configureSessionBinding, createSession, deleteAllUserSessions, deleteOtherUserSessions, deleteSession, getSession, isRecoverySession, resetSessionBindingConfig, rotateSession, validateSessionBinding, } from './session.js';
|
|
28
33
|
// Signed Cookie
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAP-256 free-intake admit + free@t0 entitlement (shared by server + admin).
|
|
3
|
+
*
|
|
4
|
+
* Pure decide lives in @revealui/core/margin-governor. This module is the I/O
|
|
5
|
+
* wrapper: read margin_snapshots, never COUNT(users). Waitlist enqueue is PR-4.
|
|
6
|
+
*/
|
|
7
|
+
import { type AdmissionChannel, type CohortLimits, type GovernorFlags, type PayingIntentSignal, type PureAdmitResult } from '@revealui/core/margin-governor';
|
|
8
|
+
export type AdmitFreeIntakeInput = {
|
|
9
|
+
channel: AdmissionChannel;
|
|
10
|
+
email?: string;
|
|
11
|
+
payingIntent?: PayingIntentSignal;
|
|
12
|
+
deploymentMode?: 'hosted' | 'forge' | 'unknown';
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
now?: Date;
|
|
15
|
+
/** Waitlist enqueue source tag (default free_signup). */
|
|
16
|
+
source?: string;
|
|
17
|
+
};
|
|
18
|
+
export type AdmitFreeIntakeResult = PureAdmitResult & {
|
|
19
|
+
flags: GovernorFlags;
|
|
20
|
+
/** Present on waitlist decision after successful enqueue (raw once). */
|
|
21
|
+
waitlistToken?: string;
|
|
22
|
+
positionEstimate?: number | null;
|
|
23
|
+
};
|
|
24
|
+
/** Hosted free open limits — lockstep with apps/server getHostedLimitsForTier('free'). */
|
|
25
|
+
export declare const OPEN_FREE_LIMITS: CohortLimits;
|
|
26
|
+
export declare function admitFreeIntake(input: AdmitFreeIntakeInput): Promise<AdmitFreeIntakeResult>;
|
|
27
|
+
/**
|
|
28
|
+
* Provision personal account + owner membership on hosted (same as signUp).
|
|
29
|
+
* Idempotent-ish: skips if user already has an active owner membership.
|
|
30
|
+
*/
|
|
31
|
+
export declare function provisionHostedPersonalAccount(params: {
|
|
32
|
+
userId: string;
|
|
33
|
+
displayName: string;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
accountId: string;
|
|
36
|
+
} | {
|
|
37
|
+
skipped: true;
|
|
38
|
+
reason: string;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Free entitlement@t0 (K15) with source=signup.
|
|
42
|
+
* Ensures owner membership/account exist first (passkey/oauth paths).
|
|
43
|
+
*/
|
|
44
|
+
export declare function ensureFreeSignupEntitlement(params: {
|
|
45
|
+
userId: string;
|
|
46
|
+
cohortLimits: CohortLimits;
|
|
47
|
+
displayName?: string;
|
|
48
|
+
now?: Date;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
accountId: string;
|
|
51
|
+
} | {
|
|
52
|
+
skipped: true;
|
|
53
|
+
reason: string;
|
|
54
|
+
}>;
|
|
55
|
+
//# sourceMappingURL=margin-admit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"margin-admit.d.ts","sourceRoot":"","sources":["../../src/server/margin-admit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAGjB,KAAK,aAAa,EAGlB,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACrB,MAAM,gCAAgC,CAAC;AAaxC,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,gBAAgB,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,kBAAkB,CAAC;IAClC,cAAc,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,eAAe,GAAG;IACpD,KAAK,EAAE,aAAa,CAAC;IACrB,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC,CAAC;AAEF,0FAA0F;AAC1F,eAAO,MAAM,gBAAgB,EAAE,YAI9B,CAAC;AA2DF,wBAAsB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA2FjG;AAED;;;GAGG;AACH,wBAAsB,8BAA8B,CAAC,MAAM,EAAE;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA2CrE;AAED;;;GAGG;AACH,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,YAAY,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAqFrE"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAP-256 free-intake admit + free@t0 entitlement (shared by server + admin).
|
|
3
|
+
*
|
|
4
|
+
* Pure decide lives in @revealui/core/margin-governor. This module is the I/O
|
|
5
|
+
* wrapper: read margin_snapshots, never COUNT(users). Waitlist enqueue is PR-4.
|
|
6
|
+
*/
|
|
7
|
+
import { getFeaturesForTier } from '@revealui/core/features';
|
|
8
|
+
import { decideFreeIntake, freeCohortLimitsForMode, governorFlagsFromEnv, } from '@revealui/core/margin-governor';
|
|
9
|
+
import { logger } from '@revealui/core/observability/logger';
|
|
10
|
+
import { getClient } from '@revealui/db';
|
|
11
|
+
import { accountEntitlements, accountMemberships, accounts, marginSnapshots, } from '@revealui/db/schema';
|
|
12
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
13
|
+
import { enqueueAdmissionWaitlist } from './admission-waitlist.js';
|
|
14
|
+
import { ensureAccountOwnerPlatformAdmin } from './platform-roles.js';
|
|
15
|
+
/** Hosted free open limits — lockstep with apps/server getHostedLimitsForTier('free'). */
|
|
16
|
+
export const OPEN_FREE_LIMITS = {
|
|
17
|
+
maxSites: 1,
|
|
18
|
+
maxUsers: 3,
|
|
19
|
+
maxAgentTasks: 0,
|
|
20
|
+
};
|
|
21
|
+
function detectDeploymentMode(env) {
|
|
22
|
+
const raw = env.REVEALUI_DEPLOYMENT_MODE?.trim().toLowerCase();
|
|
23
|
+
if (raw === 'hosted')
|
|
24
|
+
return 'hosted';
|
|
25
|
+
if (raw === 'forge')
|
|
26
|
+
return 'forge';
|
|
27
|
+
if (env.REVEALUI_LICENSE_PRIVATE_KEY)
|
|
28
|
+
return 'hosted';
|
|
29
|
+
if (raw)
|
|
30
|
+
return 'unknown';
|
|
31
|
+
return 'hosted';
|
|
32
|
+
}
|
|
33
|
+
async function loadLatestSnapshot() {
|
|
34
|
+
try {
|
|
35
|
+
const db = getClient();
|
|
36
|
+
const [row] = await db
|
|
37
|
+
.select({
|
|
38
|
+
id: marginSnapshots.id,
|
|
39
|
+
mode: marginSnapshots.mode,
|
|
40
|
+
computedAt: marginSnapshots.computedAt,
|
|
41
|
+
netCents: marginSnapshots.netCents,
|
|
42
|
+
freeCostRatio: marginSnapshots.freeCostRatio,
|
|
43
|
+
projected7dCents: marginSnapshots.projected7dCents,
|
|
44
|
+
})
|
|
45
|
+
.from(marginSnapshots)
|
|
46
|
+
.orderBy(desc(marginSnapshots.computedAt))
|
|
47
|
+
.limit(1);
|
|
48
|
+
if (!row)
|
|
49
|
+
return null;
|
|
50
|
+
const mode = row.mode;
|
|
51
|
+
if (mode !== 'open' && mode !== 'lean' && mode !== 'waitlist') {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
id: row.id,
|
|
56
|
+
mode,
|
|
57
|
+
computedAt: row.computedAt,
|
|
58
|
+
netCents: row.netCents,
|
|
59
|
+
freeCostRatio: row.freeCostRatio,
|
|
60
|
+
projected7dCents: row.projected7dCents,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
logger.warn('[admit-free-intake] snapshot read failed; fail-open', {
|
|
65
|
+
error: err instanceof Error ? err.message : String(err),
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Pre-check free intake. Call **before** any users insert (K13).
|
|
72
|
+
* Shadow defaults → always admit (never waitlist response).
|
|
73
|
+
* Enforce waitlist → enqueue admission_waitlist and attach raw token.
|
|
74
|
+
*/
|
|
75
|
+
function leanMaxAgentTasksFromEnv(env) {
|
|
76
|
+
const raw = env.LEAN_FREE_MAX_AGENT_TASKS;
|
|
77
|
+
if (raw === undefined || raw.trim() === '')
|
|
78
|
+
return 250;
|
|
79
|
+
const n = Number.parseInt(raw, 10);
|
|
80
|
+
return Number.isFinite(n) && n > 0 ? n : 250;
|
|
81
|
+
}
|
|
82
|
+
export async function admitFreeIntake(input) {
|
|
83
|
+
const env = input.env ?? process.env;
|
|
84
|
+
const flags = governorFlagsFromEnv(env);
|
|
85
|
+
const snapshot = await loadLatestSnapshot();
|
|
86
|
+
const result = decideFreeIntake({
|
|
87
|
+
channel: input.channel,
|
|
88
|
+
deploymentMode: input.deploymentMode ?? detectDeploymentMode(env),
|
|
89
|
+
payingIntent: input.payingIntent ?? { kind: 'none' },
|
|
90
|
+
snapshot,
|
|
91
|
+
flags,
|
|
92
|
+
openLimits: OPEN_FREE_LIMITS,
|
|
93
|
+
leanMaxAgentTasks: leanMaxAgentTasksFromEnv(env),
|
|
94
|
+
now: input.now,
|
|
95
|
+
});
|
|
96
|
+
if (result.decision === 'waitlist') {
|
|
97
|
+
const email = input.email?.trim();
|
|
98
|
+
if (!email) {
|
|
99
|
+
// Cannot enqueue without identity — fail-open admit (not margin 403).
|
|
100
|
+
logger.warn('[admit-free-intake] waitlist without email; fail-open admit', {
|
|
101
|
+
channel: input.channel,
|
|
102
|
+
snapshotId: result.snapshotId,
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
decision: 'admit',
|
|
106
|
+
mode: 'open',
|
|
107
|
+
cohortLimits: freeCohortLimitsForMode('open', OPEN_FREE_LIMITS),
|
|
108
|
+
snapshotId: result.snapshotId,
|
|
109
|
+
reason: 'waitlist_missing_email',
|
|
110
|
+
shadow: false,
|
|
111
|
+
flags,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const enqueued = await enqueueAdmissionWaitlist({
|
|
116
|
+
email,
|
|
117
|
+
snapshotId: result.snapshotId,
|
|
118
|
+
modeAtEnqueue: 'waitlist',
|
|
119
|
+
source: input.source ?? input.channel,
|
|
120
|
+
now: input.now,
|
|
121
|
+
});
|
|
122
|
+
logger.info('[admit-free-intake]', {
|
|
123
|
+
channel: input.channel,
|
|
124
|
+
email: '[redacted]',
|
|
125
|
+
decision: 'waitlist',
|
|
126
|
+
mode: result.mode,
|
|
127
|
+
reason: result.reason,
|
|
128
|
+
shadow: result.shadow,
|
|
129
|
+
snapshotId: result.snapshotId,
|
|
130
|
+
governorEnabled: flags.enabled,
|
|
131
|
+
waitlistId: enqueued.id,
|
|
132
|
+
positionEstimate: enqueued.positionEstimate,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
...result,
|
|
136
|
+
flags,
|
|
137
|
+
waitlistToken: enqueued.waitlistToken,
|
|
138
|
+
positionEstimate: enqueued.positionEstimate,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
// Enqueue failure must not 403; fail-open admit so signup can proceed.
|
|
143
|
+
logger.error('[admit-free-intake] waitlist enqueue failed; fail-open admit', err instanceof Error ? err : undefined, { channel: input.channel });
|
|
144
|
+
return {
|
|
145
|
+
decision: 'admit',
|
|
146
|
+
mode: 'open',
|
|
147
|
+
cohortLimits: freeCohortLimitsForMode('open', OPEN_FREE_LIMITS),
|
|
148
|
+
snapshotId: result.snapshotId,
|
|
149
|
+
reason: 'waitlist_enqueue_failed',
|
|
150
|
+
shadow: false,
|
|
151
|
+
flags,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
logger.info('[admit-free-intake]', {
|
|
156
|
+
channel: input.channel,
|
|
157
|
+
email: input.email ? '[redacted]' : undefined,
|
|
158
|
+
decision: result.decision,
|
|
159
|
+
mode: result.mode,
|
|
160
|
+
reason: result.reason,
|
|
161
|
+
shadow: result.shadow,
|
|
162
|
+
snapshotId: result.snapshotId,
|
|
163
|
+
governorEnabled: flags.enabled,
|
|
164
|
+
});
|
|
165
|
+
return { ...result, flags };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Provision personal account + owner membership on hosted (same as signUp).
|
|
169
|
+
* Idempotent-ish: skips if user already has an active owner membership.
|
|
170
|
+
*/
|
|
171
|
+
export async function provisionHostedPersonalAccount(params) {
|
|
172
|
+
const { isHostedDeployment } = await import('@revealui/core/deployment-mode');
|
|
173
|
+
if (!isHostedDeployment(process.env)) {
|
|
174
|
+
return { skipped: true, reason: 'not_hosted' };
|
|
175
|
+
}
|
|
176
|
+
const db = getClient();
|
|
177
|
+
const [existing] = await db
|
|
178
|
+
.select({ accountId: accountMemberships.accountId })
|
|
179
|
+
.from(accountMemberships)
|
|
180
|
+
.where(and(eq(accountMemberships.userId, params.userId), eq(accountMemberships.role, 'owner'), eq(accountMemberships.status, 'active')))
|
|
181
|
+
.limit(1);
|
|
182
|
+
if (existing) {
|
|
183
|
+
return { accountId: existing.accountId };
|
|
184
|
+
}
|
|
185
|
+
const accountId = crypto.randomUUID();
|
|
186
|
+
const now = new Date();
|
|
187
|
+
await db.insert(accounts).values({
|
|
188
|
+
id: accountId,
|
|
189
|
+
name: `${params.displayName || 'RevealUI'} Workspace`,
|
|
190
|
+
slug: `acct-${accountId}`,
|
|
191
|
+
status: 'active',
|
|
192
|
+
createdAt: now,
|
|
193
|
+
updatedAt: now,
|
|
194
|
+
});
|
|
195
|
+
await db.insert(accountMemberships).values({
|
|
196
|
+
id: crypto.randomUUID(),
|
|
197
|
+
accountId,
|
|
198
|
+
userId: params.userId,
|
|
199
|
+
role: 'owner',
|
|
200
|
+
status: 'active',
|
|
201
|
+
createdAt: now,
|
|
202
|
+
updatedAt: now,
|
|
203
|
+
});
|
|
204
|
+
await ensureAccountOwnerPlatformAdmin(db, params.userId);
|
|
205
|
+
return { accountId };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Free entitlement@t0 (K15) with source=signup.
|
|
209
|
+
* Ensures owner membership/account exist first (passkey/oauth paths).
|
|
210
|
+
*/
|
|
211
|
+
export async function ensureFreeSignupEntitlement(params) {
|
|
212
|
+
const provisioned = await provisionHostedPersonalAccount({
|
|
213
|
+
userId: params.userId,
|
|
214
|
+
displayName: params.displayName ?? 'RevealUI',
|
|
215
|
+
});
|
|
216
|
+
if ('skipped' in provisioned && provisioned.skipped) {
|
|
217
|
+
// still try membership lookup on non-hosted? skip
|
|
218
|
+
if (provisioned.reason === 'not_hosted') {
|
|
219
|
+
return provisioned;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const db = getClient();
|
|
223
|
+
const now = params.now ?? new Date();
|
|
224
|
+
const [membership] = await db
|
|
225
|
+
.select({ accountId: accountMemberships.accountId })
|
|
226
|
+
.from(accountMemberships)
|
|
227
|
+
.where(and(eq(accountMemberships.userId, params.userId), eq(accountMemberships.role, 'owner'), eq(accountMemberships.status, 'active')))
|
|
228
|
+
.limit(1);
|
|
229
|
+
if (!membership) {
|
|
230
|
+
logger.warn('[free-signup-entitlement] no owner membership; skip', {
|
|
231
|
+
userId: params.userId,
|
|
232
|
+
});
|
|
233
|
+
return { skipped: true, reason: 'no_owner_membership' };
|
|
234
|
+
}
|
|
235
|
+
const accountId = membership.accountId;
|
|
236
|
+
const [existing] = await db
|
|
237
|
+
.select({ source: accountEntitlements.source })
|
|
238
|
+
.from(accountEntitlements)
|
|
239
|
+
.where(eq(accountEntitlements.accountId, accountId))
|
|
240
|
+
.limit(1);
|
|
241
|
+
if (existing?.source === 'stripe' || existing?.source === 'grant') {
|
|
242
|
+
// Do not overwrite paid/gifted rows
|
|
243
|
+
return { accountId };
|
|
244
|
+
}
|
|
245
|
+
const features = getFeaturesForTier('free');
|
|
246
|
+
const row = {
|
|
247
|
+
accountId,
|
|
248
|
+
planId: 'free',
|
|
249
|
+
tier: 'free',
|
|
250
|
+
status: 'active',
|
|
251
|
+
features,
|
|
252
|
+
limits: {
|
|
253
|
+
maxSites: params.cohortLimits.maxSites,
|
|
254
|
+
maxUsers: params.cohortLimits.maxUsers,
|
|
255
|
+
maxAgentTasks: params.cohortLimits.maxAgentTasks,
|
|
256
|
+
},
|
|
257
|
+
meteringStatus: 'active',
|
|
258
|
+
mode: 'live',
|
|
259
|
+
source: 'signup',
|
|
260
|
+
graceUntil: null,
|
|
261
|
+
lastEventAt: null,
|
|
262
|
+
updatedAt: now,
|
|
263
|
+
cogsBreakerTrippedAt: null,
|
|
264
|
+
cogsBreakerReason: null,
|
|
265
|
+
};
|
|
266
|
+
if (existing) {
|
|
267
|
+
// free_preserve: do not clobber existing free limits mid-cycle
|
|
268
|
+
await db
|
|
269
|
+
.update(accountEntitlements)
|
|
270
|
+
.set({ updatedAt: now })
|
|
271
|
+
.where(eq(accountEntitlements.accountId, accountId));
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
await db.insert(accountEntitlements).values(row);
|
|
275
|
+
}
|
|
276
|
+
logger.info('[free-signup-entitlement] upserted', {
|
|
277
|
+
accountId,
|
|
278
|
+
userId: params.userId,
|
|
279
|
+
maxAgentTasks: params.cohortLimits.maxAgentTasks,
|
|
280
|
+
});
|
|
281
|
+
return { accountId };
|
|
282
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-operator entitlement.
|
|
3
|
+
*
|
|
4
|
+
* Super-admin (`_json.roles`) is the instance operator (first signup / bootstrap).
|
|
5
|
+
* Hosted account owners are shell admins, not super-admin.
|
|
6
|
+
*
|
|
7
|
+
* Operators receive a real Enterprise `account_entitlements` row (source=grant).
|
|
8
|
+
* Gates stay fail-closed. This is not an identity bypass.
|
|
9
|
+
*
|
|
10
|
+
* Live on `@revealui/auth/platform-operator`, not the `/server` barrel.
|
|
11
|
+
* Route tests bare-mock that barrel for getSession (same reason as audit-storage).
|
|
12
|
+
*/
|
|
13
|
+
/** Lockstep with `getHostedLimitsForTier('enterprise')` in apps/server. */
|
|
14
|
+
export declare const PLATFORM_OPERATOR_LIMITS: {
|
|
15
|
+
readonly maxAgentTasks: number;
|
|
16
|
+
};
|
|
17
|
+
export declare function rolesFromUserJson(json: unknown): string[];
|
|
18
|
+
/** Session/engine user fields this check reads. Extra keys (id, email) are fine. */
|
|
19
|
+
export interface PlatformOperatorUserView {
|
|
20
|
+
id?: string;
|
|
21
|
+
_json?: unknown;
|
|
22
|
+
roles?: unknown;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* True when this user is the instance operator (engine super-admin).
|
|
26
|
+
* Accepts session `_json.roles` and engine `roles`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isPlatformOperatorUser(user: PlatformOperatorUserView | null | undefined): boolean;
|
|
29
|
+
export interface EnsurePlatformOperatorEntitlementResult {
|
|
30
|
+
accountId: string;
|
|
31
|
+
wrote: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface EnsurePlatformOperatorSkipped {
|
|
34
|
+
skipped: true;
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Upsert Enterprise (source=grant) for a platform operator's billing account.
|
|
39
|
+
*
|
|
40
|
+
* Skips users who are not super-admin. Idempotent when the row is already
|
|
41
|
+
* a healthy Enterprise grant with `ai`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ensurePlatformOperatorEntitlement(params: {
|
|
44
|
+
userId: string;
|
|
45
|
+
accountId?: string;
|
|
46
|
+
now?: Date;
|
|
47
|
+
}): Promise<EnsurePlatformOperatorEntitlementResult | EnsurePlatformOperatorSkipped>;
|
|
48
|
+
//# sourceMappingURL=platform-operator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform-operator.d.ts","sourceRoot":"","sources":["../../src/server/platform-operator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AASH,2EAA2E;AAC3E,eAAO,MAAM,wBAAwB;;CAE3B,CAAC;AAIX,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,CAiBzD;AAED,oFAAoF;AACpF,MAAM,WAAW,wBAAwB;IACvC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,wBAAwB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAQjG;AAED,MAAM,WAAW,uCAAuC;IACtD,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,IAAI,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AA4BD;;;;;GAKG;AACH,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ,GAAG,OAAO,CAAC,uCAAuC,GAAG,6BAA6B,CAAC,CA0FnF"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-operator entitlement.
|
|
3
|
+
*
|
|
4
|
+
* Super-admin (`_json.roles`) is the instance operator (first signup / bootstrap).
|
|
5
|
+
* Hosted account owners are shell admins, not super-admin.
|
|
6
|
+
*
|
|
7
|
+
* Operators receive a real Enterprise `account_entitlements` row (source=grant).
|
|
8
|
+
* Gates stay fail-closed. This is not an identity bypass.
|
|
9
|
+
*
|
|
10
|
+
* Live on `@revealui/auth/platform-operator`, not the `/server` barrel.
|
|
11
|
+
* Route tests bare-mock that barrel for getSession (same reason as audit-storage).
|
|
12
|
+
*/
|
|
13
|
+
import { getConfiguredStripeMode } from '@revealui/config/stripe-mode';
|
|
14
|
+
import { getFeaturesForTier } from '@revealui/core/features';
|
|
15
|
+
import { logger } from '@revealui/core/observability/logger';
|
|
16
|
+
import { getClient } from '@revealui/db';
|
|
17
|
+
import { accountEntitlements, accountMemberships, users } from '@revealui/db/schema';
|
|
18
|
+
import { and, eq } from 'drizzle-orm';
|
|
19
|
+
/** Lockstep with `getHostedLimitsForTier('enterprise')` in apps/server. */
|
|
20
|
+
export const PLATFORM_OPERATOR_LIMITS = {
|
|
21
|
+
maxAgentTasks: Number.MAX_SAFE_INTEGER,
|
|
22
|
+
};
|
|
23
|
+
const SUPER_ADMIN_ROLE = 'super-admin';
|
|
24
|
+
export function rolesFromUserJson(json) {
|
|
25
|
+
let value = json;
|
|
26
|
+
if (typeof value === 'string') {
|
|
27
|
+
try {
|
|
28
|
+
value = JSON.parse(value);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const roles = value.roles;
|
|
38
|
+
if (!Array.isArray(roles)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
return roles.filter((role) => typeof role === 'string');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when this user is the instance operator (engine super-admin).
|
|
45
|
+
* Accepts session `_json.roles` and engine `roles`.
|
|
46
|
+
*/
|
|
47
|
+
export function isPlatformOperatorUser(user) {
|
|
48
|
+
if (!user) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(user.roles) && user.roles.includes(SUPER_ADMIN_ROLE)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return rolesFromUserJson(user._json).includes(SUPER_ADMIN_ROLE);
|
|
55
|
+
}
|
|
56
|
+
function featureRecord(features) {
|
|
57
|
+
return Object.fromEntries(Object.entries(features).filter((entry) => typeof entry[1] === 'boolean'));
|
|
58
|
+
}
|
|
59
|
+
function enterpriseRowComplete(row) {
|
|
60
|
+
if (row.tier !== 'enterprise') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (row.status !== 'active' && row.status !== 'trialing') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const features = row.features && typeof row.features === 'object' && !Array.isArray(row.features)
|
|
67
|
+
? row.features
|
|
68
|
+
: {};
|
|
69
|
+
return features.ai === true;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Upsert Enterprise (source=grant) for a platform operator's billing account.
|
|
73
|
+
*
|
|
74
|
+
* Skips users who are not super-admin. Idempotent when the row is already
|
|
75
|
+
* a healthy Enterprise grant with `ai`.
|
|
76
|
+
*/
|
|
77
|
+
export async function ensurePlatformOperatorEntitlement(params) {
|
|
78
|
+
const db = getClient();
|
|
79
|
+
const [user] = await db
|
|
80
|
+
.select({ id: users.id, json: users._json })
|
|
81
|
+
.from(users)
|
|
82
|
+
.where(eq(users.id, params.userId))
|
|
83
|
+
.limit(1);
|
|
84
|
+
if (!(user && isPlatformOperatorUser({ _json: user.json }))) {
|
|
85
|
+
return { skipped: true, reason: 'not_platform_operator' };
|
|
86
|
+
}
|
|
87
|
+
const ownerMemberships = await db
|
|
88
|
+
.select({ accountId: accountMemberships.accountId })
|
|
89
|
+
.from(accountMemberships)
|
|
90
|
+
.where(and(eq(accountMemberships.userId, params.userId), eq(accountMemberships.role, 'owner'), eq(accountMemberships.status, 'active')));
|
|
91
|
+
const ownerIds = ownerMemberships.map((row) => row.accountId);
|
|
92
|
+
if (params.accountId && !ownerIds.includes(params.accountId)) {
|
|
93
|
+
logger.warn('[platform-operator-entitlement] refused non-owner workspace', {
|
|
94
|
+
userId: params.userId,
|
|
95
|
+
accountId: params.accountId,
|
|
96
|
+
});
|
|
97
|
+
return { skipped: true, reason: 'not_owner_workspace' };
|
|
98
|
+
}
|
|
99
|
+
const accountId = params.accountId ?? ownerIds[0];
|
|
100
|
+
if (!accountId) {
|
|
101
|
+
logger.warn('[platform-operator-entitlement] no owner membership; skip', {
|
|
102
|
+
userId: params.userId,
|
|
103
|
+
});
|
|
104
|
+
return { skipped: true, reason: 'no_owner_membership' };
|
|
105
|
+
}
|
|
106
|
+
const mode = getConfiguredStripeMode();
|
|
107
|
+
const [existing] = await db
|
|
108
|
+
.select({
|
|
109
|
+
tier: accountEntitlements.tier,
|
|
110
|
+
status: accountEntitlements.status,
|
|
111
|
+
features: accountEntitlements.features,
|
|
112
|
+
lastEventAt: accountEntitlements.lastEventAt,
|
|
113
|
+
})
|
|
114
|
+
.from(accountEntitlements)
|
|
115
|
+
.where(and(eq(accountEntitlements.accountId, accountId), eq(accountEntitlements.mode, mode)))
|
|
116
|
+
.limit(1);
|
|
117
|
+
if (existing && enterpriseRowComplete(existing)) {
|
|
118
|
+
return { accountId, wrote: false };
|
|
119
|
+
}
|
|
120
|
+
const now = params.now ?? new Date();
|
|
121
|
+
const values = {
|
|
122
|
+
planId: 'enterprise',
|
|
123
|
+
tier: 'enterprise',
|
|
124
|
+
status: 'active',
|
|
125
|
+
features: featureRecord(getFeaturesForTier('enterprise')),
|
|
126
|
+
limits: { ...PLATFORM_OPERATOR_LIMITS },
|
|
127
|
+
meteringStatus: 'active',
|
|
128
|
+
mode,
|
|
129
|
+
source: 'grant',
|
|
130
|
+
graceUntil: null,
|
|
131
|
+
lastEventAt: existing ? (existing.lastEventAt ?? null) : null,
|
|
132
|
+
updatedAt: now,
|
|
133
|
+
cogsBreakerTrippedAt: null,
|
|
134
|
+
cogsBreakerReason: null,
|
|
135
|
+
};
|
|
136
|
+
if (existing) {
|
|
137
|
+
await db
|
|
138
|
+
.update(accountEntitlements)
|
|
139
|
+
.set(values)
|
|
140
|
+
.where(and(eq(accountEntitlements.accountId, accountId), eq(accountEntitlements.mode, mode)));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await db.insert(accountEntitlements).values({ accountId, ...values });
|
|
144
|
+
}
|
|
145
|
+
logger.info('[platform-operator-entitlement] granted enterprise', {
|
|
146
|
+
accountId,
|
|
147
|
+
userId: params.userId,
|
|
148
|
+
mode,
|
|
149
|
+
previousTier: existing?.tier ?? null,
|
|
150
|
+
});
|
|
151
|
+
return { accountId, wrote: true };
|
|
152
|
+
}
|
|
@@ -39,4 +39,21 @@ export declare function ensureAccountOwnerPlatformAdmin(db: Database, userId: st
|
|
|
39
39
|
nextRole: string;
|
|
40
40
|
updated: boolean;
|
|
41
41
|
} | null>;
|
|
42
|
+
/**
|
|
43
|
+
* If the user has an active account_memberships.owner seat, ensure users.role
|
|
44
|
+
* is shell-admin (admin/owner). No-op for members/admins who are not owners.
|
|
45
|
+
*
|
|
46
|
+
* GAP-473 residual: accounts provisioned before ensureAccountOwnerPlatformAdmin
|
|
47
|
+
* (or any race that left membership owner + users.role=viewer) get shell access
|
|
48
|
+
* on the next session mint without a manual backfill. Never promotes non-owners.
|
|
49
|
+
*
|
|
50
|
+
* Best-effort call sites: createSession (covers password/MFA/passkey/OAuth mint).
|
|
51
|
+
*/
|
|
52
|
+
export declare function ensureShellAdminIfAccountOwner(db: Database, userId: string): Promise<{
|
|
53
|
+
previousRole: string;
|
|
54
|
+
nextRole: string;
|
|
55
|
+
updated: boolean;
|
|
56
|
+
} | null>;
|
|
57
|
+
/** Fresh users.role after session mint / shell repair (for revealui-role cookie). */
|
|
58
|
+
export declare function readUsersRole(db: Database, userId: string): Promise<string | null>;
|
|
42
59
|
//# sourceMappingURL=platform-roles.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform-roles.d.ts","sourceRoot":"","sources":["../../src/server/platform-roles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,MAAM,CAA+B,CAAC;AAE3F;;;GAGG;AACH,eAAO,MAAM,iCAAiC,EAAE,WAAW,CAAC,MAAM,CAIhE,CAAC;AAEH;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAKvE;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEjF;AAED;;;;;;GAMG;AACH,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAmB9E"}
|
|
1
|
+
{"version":3,"file":"platform-roles.d.ts","sourceRoot":"","sources":["../../src/server/platform-roles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,MAAM,CAA+B,CAAC;AAE3F;;;GAGG;AACH,eAAO,MAAM,iCAAiC,EAAE,WAAW,CAAC,MAAM,CAIhE,CAAC;AAEH;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAKvE;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEjF;AAED;;;;;;GAMG;AACH,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAmB9E;AAED;;;;;;;;;GASG;AACH,wBAAsB,8BAA8B,CAClD,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAkB9E;AAED,qFAAqF;AACrF,wBAAsB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxF"}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* via users.role = admin so proxy isAdminRole + /settings (API keys) work.
|
|
13
13
|
* That is NOT super-admin.
|
|
14
14
|
*/
|
|
15
|
-
import { users } from '@revealui/db/schema';
|
|
16
|
-
import { eq } from 'drizzle-orm';
|
|
15
|
+
import { accountMemberships, users } from '@revealui/db/schema';
|
|
16
|
+
import { and, eq } from 'drizzle-orm';
|
|
17
17
|
/** DB column values that already pass isAdminRole / shell admin cookie gate. */
|
|
18
18
|
export const PLATFORM_SHELL_ADMIN_ROLES = new Set(['owner', 'admin']);
|
|
19
19
|
/**
|
|
@@ -63,3 +63,33 @@ export async function ensureAccountOwnerPlatformAdmin(db, userId) {
|
|
|
63
63
|
await db.update(users).set({ role: nextRole }).where(eq(users.id, userId));
|
|
64
64
|
return { previousRole, nextRole, updated: true };
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* If the user has an active account_memberships.owner seat, ensure users.role
|
|
68
|
+
* is shell-admin (admin/owner). No-op for members/admins who are not owners.
|
|
69
|
+
*
|
|
70
|
+
* GAP-473 residual: accounts provisioned before ensureAccountOwnerPlatformAdmin
|
|
71
|
+
* (or any race that left membership owner + users.role=viewer) get shell access
|
|
72
|
+
* on the next session mint without a manual backfill. Never promotes non-owners.
|
|
73
|
+
*
|
|
74
|
+
* Best-effort call sites: createSession (covers password/MFA/passkey/OAuth mint).
|
|
75
|
+
*/
|
|
76
|
+
export async function ensureShellAdminIfAccountOwner(db, userId) {
|
|
77
|
+
const [membership] = await db
|
|
78
|
+
.select({ id: accountMemberships.id })
|
|
79
|
+
.from(accountMemberships)
|
|
80
|
+
.where(and(eq(accountMemberships.userId, userId), eq(accountMemberships.role, 'owner'), eq(accountMemberships.status, 'active')))
|
|
81
|
+
.limit(1);
|
|
82
|
+
if (!membership) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return ensureAccountOwnerPlatformAdmin(db, userId);
|
|
86
|
+
}
|
|
87
|
+
/** Fresh users.role after session mint / shell repair (for revealui-role cookie). */
|
|
88
|
+
export async function readUsersRole(db, userId) {
|
|
89
|
+
const [row] = await db
|
|
90
|
+
.select({ role: users.role })
|
|
91
|
+
.from(users)
|
|
92
|
+
.where(eq(users.id, userId))
|
|
93
|
+
.limit(1);
|
|
94
|
+
return row?.role ?? null;
|
|
95
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AASjD,MAAM,WAAW,oBAAoB;IACnC,iEAAiE;IACjE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iFAAiF;IACjF,SAAS,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,cAAc,EAAE,OAAO,CAAC;CACzB;AAaD,mFAAmF;AACnF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,OAAO,CAAC,oBAAoB,CAAC,GAAG,IAAI,CAEtF;AAED,qCAAqC;AACrC,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD;AAMD,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CA0B3F;AAMD,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAI1E;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,OAAO,EAChB,cAAc,CAAC,EAAE,cAAc,GAC9B,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAgI7B;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,GACA,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAgF9C;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,+FAA+F;IAC/F,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,GACA,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAqC9C;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CA+BtE;AAED;;;;GAIG;AACH;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,MAAM,EACd,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBzE"}
|
package/dist/server/session.js
CHANGED
|
@@ -10,6 +10,7 @@ import { sessions, users } from '@revealui/db/schema';
|
|
|
10
10
|
import { and, eq, gt, inArray, isNull, ne } from 'drizzle-orm';
|
|
11
11
|
import { hashToken } from '../utils/token.js';
|
|
12
12
|
import { DatabaseError, TokenError } from './errors.js';
|
|
13
|
+
import { ensureShellAdminIfAccountOwner } from './platform-roles.js';
|
|
13
14
|
const DEFAULT_SESSION_BINDING = {
|
|
14
15
|
enforceUserAgent: true,
|
|
15
16
|
enforceIp: false,
|
|
@@ -216,6 +217,18 @@ export async function createSession(userId, options) {
|
|
|
216
217
|
logger.error('Error getting database client');
|
|
217
218
|
throw new DatabaseError('Database connection failed', error);
|
|
218
219
|
}
|
|
220
|
+
// GAP-473: membership owner + stale users.role=viewer must not mint a
|
|
221
|
+
// session that still fails the proxy admin shell gate. Best-effort only —
|
|
222
|
+
// never fail session mint if role repair fails.
|
|
223
|
+
try {
|
|
224
|
+
await ensureShellAdminIfAccountOwner(db, userId);
|
|
225
|
+
}
|
|
226
|
+
catch (roleRepairError) {
|
|
227
|
+
logger.warn('ensureShellAdminIfAccountOwner failed during createSession', {
|
|
228
|
+
userId,
|
|
229
|
+
message: roleRepairError instanceof Error ? roleRepairError.message : String(roleRepairError),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
219
232
|
// Generate secure session token
|
|
220
233
|
let token;
|
|
221
234
|
let tokenHash;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revealui/auth",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Database-backed session auth for Hono and Next.js — bcrypt, OAuth, brute-force protection, rate limiting, password reset. Ships with RevealUI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"jose": "^5.10.0",
|
|
18
18
|
"zod": "^4.4.3",
|
|
19
19
|
"@revealui/config": "0.6.0",
|
|
20
|
-
"@revealui/contracts": "0.8.
|
|
21
|
-
"@revealui/core": "0.
|
|
20
|
+
"@revealui/contracts": "0.8.3",
|
|
21
|
+
"@revealui/core": "0.14.0",
|
|
22
22
|
"@revealui/db": "0.10.0",
|
|
23
|
-
"@revealui/security": "0.
|
|
23
|
+
"@revealui/security": "0.8.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@simplewebauthn/browser": "^13.3.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"happy-dom": "^20.10.6",
|
|
32
32
|
"react": "19.2.8",
|
|
33
33
|
"selfsigned": "2.4.1",
|
|
34
|
-
"typescript": "
|
|
34
|
+
"typescript": "6.0.3",
|
|
35
35
|
"vitest": "^4.1.10",
|
|
36
36
|
"xml-crypto": "6.1.2",
|
|
37
37
|
"@revealui/dev": "0.1.0"
|
|
@@ -50,11 +50,21 @@
|
|
|
50
50
|
"import": "./dist/server/index.js",
|
|
51
51
|
"default": "./dist/server/index.js"
|
|
52
52
|
},
|
|
53
|
+
"./server/storage": {
|
|
54
|
+
"types": "./dist/server/storage/index.d.ts",
|
|
55
|
+
"import": "./dist/server/storage/index.js",
|
|
56
|
+
"default": "./dist/server/storage/index.js"
|
|
57
|
+
},
|
|
53
58
|
"./audit-storage": {
|
|
54
59
|
"types": "./dist/server/audit-storage.d.ts",
|
|
55
60
|
"import": "./dist/server/audit-storage.js",
|
|
56
61
|
"default": "./dist/server/audit-storage.js"
|
|
57
62
|
},
|
|
63
|
+
"./platform-operator": {
|
|
64
|
+
"types": "./dist/server/platform-operator.d.ts",
|
|
65
|
+
"import": "./dist/server/platform-operator.js",
|
|
66
|
+
"default": "./dist/server/platform-operator.js"
|
|
67
|
+
},
|
|
58
68
|
"./react": {
|
|
59
69
|
"types": "./dist/react/index.d.ts",
|
|
60
70
|
"import": "./dist/react/index.js",
|