handoff-app 2.0.0-1 → 2.0.0-3
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/README.md +170 -91
- package/config/docs/foundations/colors/guidelines.md +9 -0
- package/config/docs/foundations/effects/guidelines.md +9 -0
- package/config/docs/foundations/effects/resources.md +9 -0
- package/config/docs/foundations/grid.md +10 -0
- package/config/docs/foundations/icons/guidelines.md +9 -0
- package/config/docs/foundations/images/guidelines.md +9 -0
- package/config/docs/foundations/images/resources.md +9 -0
- package/config/docs/foundations/images.md +10 -0
- package/config/docs/foundations/logo/resources.md +9 -0
- package/config/docs/foundations/typography/guidelines.md +9 -0
- package/config/docs/foundations/typography/resources.md +9 -0
- package/config/docs/foundations.md +5 -3
- package/dist/app-builder/build.js +109 -49
- package/dist/app-builder/client-config.js +13 -12
- package/dist/app-builder/paths.js +7 -9
- package/dist/app-builder/static-export.js +6 -6
- package/dist/app-builder/vercel-output.js +2 -2
- package/dist/artifacts/url.d.ts +7 -7
- package/dist/artifacts/url.js +7 -7
- package/dist/cli/auth/browser.d.ts +2 -0
- package/dist/cli/auth/browser.js +33 -0
- package/dist/cli/auth/device.d.ts +11 -0
- package/dist/cli/auth/device.js +155 -0
- package/dist/cli/auth/store.d.ts +29 -0
- package/dist/cli/auth/store.js +146 -0
- package/dist/cli/create.js +48 -3
- package/dist/cli/scaffold.js +20 -154
- package/dist/commands/checkout/index.d.ts +6 -6
- package/dist/commands/checkout/index.js +8 -12
- package/dist/commands/index.js +4 -0
- package/dist/commands/login/index.d.ts +8 -0
- package/dist/commands/login/index.js +53 -0
- package/dist/commands/logout/index.d.ts +7 -0
- package/dist/commands/logout/index.js +54 -0
- package/dist/commands/publish/index.d.ts +6 -5
- package/dist/commands/publish/index.js +8 -11
- package/dist/commands/utils.d.ts +6 -0
- package/dist/commands/utils.js +9 -1
- package/dist/config/entries.d.ts +47 -0
- package/dist/config/entries.js +162 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +5 -1
- package/dist/config/runtime.d.ts +9 -9
- package/dist/config/runtime.js +15 -15
- package/dist/index.d.ts +12 -8
- package/dist/index.js +26 -10
- package/dist/registry/asset-storage/resolve.d.ts +1 -1
- package/dist/registry/asset-storage/resolve.js +1 -1
- package/dist/registry/auth/access-tokens.d.ts +46 -0
- package/dist/registry/auth/access-tokens.js +165 -0
- package/dist/registry/auth/action-tokens.d.ts +66 -0
- package/dist/registry/auth/action-tokens.js +178 -0
- package/dist/registry/auth/crypto.d.ts +4 -0
- package/dist/registry/auth/crypto.js +16 -0
- package/dist/registry/auth/database.d.ts +7 -0
- package/dist/registry/auth/database.js +22 -0
- package/dist/registry/auth/device-authorizations.d.ts +51 -0
- package/dist/registry/auth/device-authorizations.js +170 -0
- package/dist/registry/auth/gravatar.d.ts +1 -0
- package/dist/registry/auth/gravatar.js +8 -0
- package/dist/registry/auth/index.d.ts +11 -0
- package/dist/registry/auth/index.js +72 -0
- package/dist/registry/auth/installation.d.ts +42 -0
- package/dist/registry/auth/installation.js +105 -0
- package/dist/registry/auth/password.d.ts +12 -0
- package/dist/registry/auth/password.js +58 -0
- package/dist/registry/auth/rate-limits.d.ts +22 -0
- package/dist/registry/auth/rate-limits.js +75 -0
- package/dist/registry/auth/types.d.ts +26 -0
- package/dist/registry/auth/types.js +9 -0
- package/dist/registry/auth/user-record.d.ts +13 -0
- package/dist/registry/auth/user-record.js +16 -0
- package/dist/registry/auth/users.d.ts +31 -0
- package/dist/registry/auth/users.js +135 -0
- package/dist/registry/auth/validation.d.ts +3 -0
- package/dist/registry/auth/validation.js +28 -0
- package/dist/registry/checkout/assets.js +4 -2
- package/dist/registry/checkout/index.d.ts +11 -6
- package/dist/registry/checkout/index.js +159 -30
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/client.d.ts +2 -1
- package/dist/registry/client.js +8 -0
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/client.d.ts +9 -9
- package/dist/registry/db/client.js +7 -7
- package/dist/registry/db/driver.d.ts +46 -0
- package/dist/registry/db/{adapter.js → driver.js} +22 -19
- package/dist/registry/db/index.d.ts +2 -2
- package/dist/registry/db/index.js +15 -9
- package/dist/registry/db/migrate.d.ts +1 -1
- package/dist/registry/db/migrate.js +5 -5
- package/dist/registry/db/schema.d.ts +2521 -750
- package/dist/registry/db/schema.js +126 -1
- package/dist/registry/publish/assets.js +1 -1
- package/dist/registry/publish/errors.js +3 -3
- package/dist/registry/publish/index.d.ts +9 -2
- package/dist/registry/publish/index.js +96 -10
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/registry/transfer.d.ts +13 -0
- package/dist/types/config.d.ts +11 -4
- package/docs/cli.md +4 -0
- package/drizzle/0005_add_registry_auth.sql +95 -0
- package/drizzle/meta/0005_snapshot.json +1926 -0
- package/drizzle/meta/_journal.json +8 -1
- package/drizzle.config.ts +1 -1
- package/package.json +5 -4
- package/src/app/components/Auth/AccountLayout.tsx +87 -0
- package/src/app/components/Auth/AuthControls.tsx +99 -0
- package/src/app/components/Auth/AuthShell.tsx +73 -0
- package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
- package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
- package/src/app/components/Auth/api.ts +20 -0
- package/src/app/components/Foundations/DisplayLogo.tsx +3 -3
- package/src/app/components/Layout/Header.tsx +2 -0
- package/src/app/components/Navigation/MobileNav.tsx +2 -0
- package/src/app/lib/auth/api.ts +113 -0
- package/src/app/lib/auth/config.ts +175 -0
- package/src/app/lib/auth/email.ts +34 -0
- package/src/app/lib/docs-api/runtime-config.ts +10 -24
- package/src/app/lib/registry-api/auth.ts +29 -46
- package/src/app/lib/registry-api/errors.ts +2 -2
- package/src/app/lib/registry-api/handler.ts +19 -14
- package/src/app/lib/registry-api/token-transfer.ts +1 -1
- package/src/app/lib/registry-api/transfer.ts +31 -2
- package/src/app/lib/registry-connection.ts +2 -2
- package/src/app/next.config.mjs +13 -4
- package/src/app/pages/_app.tsx +10 -4
- package/src/app/pages/account/index.tsx +153 -0
- package/src/app/pages/account/tokens.tsx +230 -0
- package/src/app/pages/account/users.tsx +310 -0
- package/src/app/pages/api/account/index.ts +26 -0
- package/src/app/pages/api/account/tokens/[id].ts +17 -0
- package/src/app/pages/api/account/tokens/index.ts +44 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
- package/src/app/pages/api/admin/users/index.ts +53 -0
- package/src/app/pages/api/auth/[...nextauth].ts +4 -0
- package/src/app/pages/api/auth/request-reset.ts +53 -0
- package/src/app/pages/api/auth/reset-password.ts +29 -0
- package/src/app/pages/api/install.ts +102 -0
- package/src/app/pages/api/oauth/device/approve.ts +23 -0
- package/src/app/pages/api/oauth/device/index.ts +41 -0
- package/src/app/pages/api/oauth/revoke.ts +24 -0
- package/src/app/pages/api/oauth/token.ts +31 -0
- package/src/app/pages/api/registry/health.ts +4 -3
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
- package/src/app/pages/api/registry/transfer/[kind]/index.ts +21 -0
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
- package/src/app/pages/cli/device.tsx +143 -0
- package/src/app/pages/foundations/icons/index.tsx +2 -2
- package/src/app/pages/foundations/icons/{[name] → library/[name]}/index.tsx +6 -6
- package/src/app/pages/install.tsx +336 -0
- package/src/app/pages/login.tsx +117 -0
- package/src/app/pages/reset-password.tsx +159 -0
- package/src/app/types/next-auth.d.ts +27 -0
- package/src/app-builder/build.ts +96 -59
- package/src/app-builder/client-config.ts +12 -11
- package/src/app-builder/paths.ts +7 -9
- package/src/app-builder/static-export.ts +6 -6
- package/src/app-builder/vercel-output.ts +2 -2
- package/src/artifacts/url.ts +7 -7
- package/src/cli/auth/browser.ts +29 -0
- package/src/cli/auth/device.ts +181 -0
- package/src/cli/auth/store.ts +137 -0
- package/src/cli/create.ts +58 -3
- package/src/cli/scaffold.ts +20 -197
- package/src/commands/checkout/index.ts +10 -15
- package/src/commands/index.ts +4 -0
- package/src/commands/login/index.ts +48 -0
- package/src/commands/logout/index.ts +51 -0
- package/src/commands/publish/index.ts +10 -14
- package/src/commands/utils.ts +12 -0
- package/src/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- package/src/index.ts +26 -12
- package/src/registry/asset-storage/resolve.ts +1 -1
- package/src/registry/auth/access-tokens.ts +198 -0
- package/src/registry/auth/action-tokens.ts +225 -0
- package/src/registry/auth/crypto.ts +13 -0
- package/src/registry/auth/database.ts +15 -0
- package/src/registry/auth/device-authorizations.ts +223 -0
- package/src/registry/auth/gravatar.ts +6 -0
- package/src/registry/auth/index.ts +89 -0
- package/src/registry/auth/installation.ts +117 -0
- package/src/registry/auth/password.ts +46 -0
- package/src/registry/auth/rate-limits.ts +84 -0
- package/src/registry/auth/types.ts +34 -0
- package/src/registry/auth/user-record.ts +25 -0
- package/src/registry/auth/users.ts +145 -0
- package/src/registry/auth/validation.ts +24 -0
- package/src/registry/checkout/assets.ts +4 -2
- package/src/registry/checkout/index.ts +182 -30
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/client.ts +7 -1
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/client.ts +12 -12
- package/src/registry/db/{adapter.ts → driver.ts} +24 -21
- package/src/registry/db/index.ts +16 -4
- package/src/registry/db/migrate.ts +5 -5
- package/src/registry/db/schema.ts +174 -1
- package/src/registry/publish/assets.ts +1 -1
- package/src/registry/publish/errors.ts +3 -3
- package/src/registry/publish/index.ts +108 -10
- package/src/registry/publish/tokens.ts +1 -1
- package/src/registry/transfer.ts +14 -0
- package/src/types/config.ts +11 -4
- package/templates/default/README.md.tpl +58 -1
- package/templates/default/gitignore.tpl +19 -3
- package/templates/default/handoff.config.js.tpl +13 -0
- package/templates/default/handoff.config.ts.tpl +13 -0
- package/templates/default/handoff.config.with-examples.js.tpl +13 -0
- package/templates/default/handoff.config.with-examples.ts.tpl +13 -0
- package/templates/default/package.json.tpl +2 -1
- package/templates/default/vercel.json.tpl +4 -0
- package/dist/registry/db/adapter.d.ts +0 -43
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { and, eq, gt, isNull, sql } from 'drizzle-orm';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import type { RegistryDatabase } from '../db/client';
|
|
4
|
+
import { registryAuthActionTokens, registryUsers, type RegistryAuthActionPurpose, type RegistryUserRole } from '../db/schema';
|
|
5
|
+
import { revokeAllRegistryAccessTokens } from './access-tokens';
|
|
6
|
+
import { createOpaqueSecret, hashSecret, normalizeEmail } from './crypto';
|
|
7
|
+
import { withRegistryTransaction } from './database';
|
|
8
|
+
import { hashPassword, validatePassword } from './password';
|
|
9
|
+
import type { RegistryUser } from './types';
|
|
10
|
+
import { toRegistryUser } from './user-record';
|
|
11
|
+
import { validateRegistryDisplayName, validateRegistryEmail } from './validation';
|
|
12
|
+
|
|
13
|
+
export const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
export const PASSWORD_RESET_LIFETIME_MS = 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export type CreateUserInvitationResult =
|
|
17
|
+
| { ok: true; user: RegistryUser; token: string; expiresAt: Date }
|
|
18
|
+
| { ok: false; reason: 'invalid_email' | 'invalid_name' | 'invalid_role' | 'email_exists' };
|
|
19
|
+
|
|
20
|
+
export type ResendUserInvitationResult =
|
|
21
|
+
| { ok: true; user: RegistryUser; token: string; expiresAt: Date }
|
|
22
|
+
| { ok: false; reason: 'not_found' | 'already_accepted' | 'deactivated' };
|
|
23
|
+
|
|
24
|
+
export type ConsumeAuthActionResult =
|
|
25
|
+
| { ok: true; user: RegistryUser }
|
|
26
|
+
| { ok: false; reason: 'invalid_or_expired_token' | 'invalid_password'; error?: string };
|
|
27
|
+
|
|
28
|
+
const invalidateUnusedActionTokens = async (
|
|
29
|
+
db: RegistryDatabase,
|
|
30
|
+
userId: string,
|
|
31
|
+
purpose: RegistryAuthActionPurpose,
|
|
32
|
+
now: Date
|
|
33
|
+
): Promise<void> => {
|
|
34
|
+
await db
|
|
35
|
+
.update(registryAuthActionTokens)
|
|
36
|
+
.set({ usedAt: now })
|
|
37
|
+
.where(
|
|
38
|
+
and(
|
|
39
|
+
eq(registryAuthActionTokens.userId, userId),
|
|
40
|
+
eq(registryAuthActionTokens.purpose, purpose),
|
|
41
|
+
isNull(registryAuthActionTokens.usedAt)
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const insertActionToken = async (
|
|
47
|
+
db: RegistryDatabase,
|
|
48
|
+
userId: string,
|
|
49
|
+
purpose: RegistryAuthActionPurpose,
|
|
50
|
+
expiresAt: Date,
|
|
51
|
+
now: Date
|
|
52
|
+
): Promise<string> => {
|
|
53
|
+
const token = createOpaqueSecret(32);
|
|
54
|
+
await db.insert(registryAuthActionTokens).values({
|
|
55
|
+
id: randomUUID(),
|
|
56
|
+
userId,
|
|
57
|
+
purpose,
|
|
58
|
+
tokenHash: hashSecret(token),
|
|
59
|
+
expiresAt,
|
|
60
|
+
createdAt: now,
|
|
61
|
+
});
|
|
62
|
+
return token;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const createUserInvitation = async (
|
|
66
|
+
db: RegistryDatabase,
|
|
67
|
+
input: {
|
|
68
|
+
email: string;
|
|
69
|
+
name: string;
|
|
70
|
+
role: RegistryUserRole;
|
|
71
|
+
now?: Date;
|
|
72
|
+
expiresAt?: Date;
|
|
73
|
+
}
|
|
74
|
+
): Promise<CreateUserInvitationResult> => {
|
|
75
|
+
const email = validateRegistryEmail(input.email);
|
|
76
|
+
if (!email) return { ok: false, reason: 'invalid_email' };
|
|
77
|
+
const name = validateRegistryDisplayName(input.name);
|
|
78
|
+
if (!name) return { ok: false, reason: 'invalid_name' };
|
|
79
|
+
if (input.role !== 'admin' && input.role !== 'member') return { ok: false, reason: 'invalid_role' };
|
|
80
|
+
const now = input.now ?? new Date();
|
|
81
|
+
const expiresAt = input.expiresAt ?? new Date(now.getTime() + INVITATION_LIFETIME_MS);
|
|
82
|
+
|
|
83
|
+
return withRegistryTransaction(db, async (tx) => {
|
|
84
|
+
const userId = randomUUID();
|
|
85
|
+
const rows = await tx
|
|
86
|
+
.insert(registryUsers)
|
|
87
|
+
.values({
|
|
88
|
+
id: userId,
|
|
89
|
+
email,
|
|
90
|
+
name,
|
|
91
|
+
role: input.role,
|
|
92
|
+
status: 'invited',
|
|
93
|
+
createdAt: now,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
})
|
|
96
|
+
.onConflictDoNothing()
|
|
97
|
+
.returning();
|
|
98
|
+
const user = rows[0];
|
|
99
|
+
if (!user) return { ok: false, reason: 'email_exists' };
|
|
100
|
+
|
|
101
|
+
const token = await insertActionToken(tx, userId, 'invite', expiresAt, now);
|
|
102
|
+
return { ok: true, user: toRegistryUser(user), token, expiresAt };
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const resendUserInvitation = async (
|
|
107
|
+
db: RegistryDatabase,
|
|
108
|
+
input: { userId: string; now?: Date; expiresAt?: Date }
|
|
109
|
+
): Promise<ResendUserInvitationResult> =>
|
|
110
|
+
withRegistryTransaction(db, async (tx) => {
|
|
111
|
+
const [user] = await tx.select().from(registryUsers).where(eq(registryUsers.id, input.userId)).limit(1).for('update');
|
|
112
|
+
if (!user) return { ok: false, reason: 'not_found' };
|
|
113
|
+
if (user.status === 'active') return { ok: false, reason: 'already_accepted' };
|
|
114
|
+
if (user.status === 'deactivated') return { ok: false, reason: 'deactivated' };
|
|
115
|
+
|
|
116
|
+
const now = input.now ?? new Date();
|
|
117
|
+
const expiresAt = input.expiresAt ?? new Date(now.getTime() + INVITATION_LIFETIME_MS);
|
|
118
|
+
await invalidateUnusedActionTokens(tx, user.id, 'invite', now);
|
|
119
|
+
const token = await insertActionToken(tx, user.id, 'invite', expiresAt, now);
|
|
120
|
+
return { ok: true, user: toRegistryUser(user), token, expiresAt };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const acceptUserInvitation = async (
|
|
124
|
+
db: RegistryDatabase,
|
|
125
|
+
input: { token: string; password: string; now?: Date }
|
|
126
|
+
): Promise<ConsumeAuthActionResult> => {
|
|
127
|
+
const validation = validatePassword(input.password);
|
|
128
|
+
if ('error' in validation) return { ok: false, reason: 'invalid_password', error: validation.error };
|
|
129
|
+
const passwordHash = await hashPassword(input.password);
|
|
130
|
+
const now = input.now ?? new Date();
|
|
131
|
+
|
|
132
|
+
return withRegistryTransaction(db, async (tx) => {
|
|
133
|
+
const [consumed] = await tx
|
|
134
|
+
.update(registryAuthActionTokens)
|
|
135
|
+
.set({ usedAt: now })
|
|
136
|
+
.where(
|
|
137
|
+
and(
|
|
138
|
+
eq(registryAuthActionTokens.tokenHash, hashSecret(input.token)),
|
|
139
|
+
eq(registryAuthActionTokens.purpose, 'invite'),
|
|
140
|
+
isNull(registryAuthActionTokens.usedAt),
|
|
141
|
+
gt(registryAuthActionTokens.expiresAt, now)
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
.returning({ userId: registryAuthActionTokens.userId });
|
|
145
|
+
if (!consumed) return { ok: false, reason: 'invalid_or_expired_token' };
|
|
146
|
+
|
|
147
|
+
const [user] = await tx
|
|
148
|
+
.update(registryUsers)
|
|
149
|
+
.set({
|
|
150
|
+
passwordHash,
|
|
151
|
+
status: 'active',
|
|
152
|
+
emailVerifiedAt: now,
|
|
153
|
+
updatedAt: now,
|
|
154
|
+
})
|
|
155
|
+
.where(and(eq(registryUsers.id, consumed.userId), eq(registryUsers.status, 'invited')))
|
|
156
|
+
.returning();
|
|
157
|
+
return user ? { ok: true, user: toRegistryUser(user) } : { ok: false, reason: 'invalid_or_expired_token' };
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Enumeration-safe reset request. The token is returned only to the trusted mail-delivery caller;
|
|
163
|
+
* unknown/inactive emails return the same successful shape with a null token.
|
|
164
|
+
*/
|
|
165
|
+
export const createPasswordReset = async (
|
|
166
|
+
db: RegistryDatabase,
|
|
167
|
+
email: string,
|
|
168
|
+
options: { now?: Date; expiresAt?: Date } = {}
|
|
169
|
+
): Promise<{ ok: true; user: RegistryUser | null; token: string | null; expiresAt: Date | null }> => {
|
|
170
|
+
const normalizedEmail = normalizeEmail(email);
|
|
171
|
+
const now = options.now ?? new Date();
|
|
172
|
+
const expiresAt = options.expiresAt ?? new Date(now.getTime() + PASSWORD_RESET_LIFETIME_MS);
|
|
173
|
+
return withRegistryTransaction(db, async (tx) => {
|
|
174
|
+
const [user] = await tx
|
|
175
|
+
.select()
|
|
176
|
+
.from(registryUsers)
|
|
177
|
+
.where(and(eq(registryUsers.email, normalizedEmail), eq(registryUsers.status, 'active')))
|
|
178
|
+
.limit(1)
|
|
179
|
+
.for('update');
|
|
180
|
+
if (!user) return { ok: true, user: null, token: null, expiresAt: null };
|
|
181
|
+
|
|
182
|
+
await invalidateUnusedActionTokens(tx, user.id, 'password_reset', now);
|
|
183
|
+
const token = await insertActionToken(tx, user.id, 'password_reset', expiresAt, now);
|
|
184
|
+
return { ok: true, user: toRegistryUser(user), token, expiresAt };
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const resetPassword = async (
|
|
189
|
+
db: RegistryDatabase,
|
|
190
|
+
input: { token: string; password: string; now?: Date }
|
|
191
|
+
): Promise<ConsumeAuthActionResult> => {
|
|
192
|
+
const validation = validatePassword(input.password);
|
|
193
|
+
if ('error' in validation) return { ok: false, reason: 'invalid_password', error: validation.error };
|
|
194
|
+
const passwordHash = await hashPassword(input.password);
|
|
195
|
+
const now = input.now ?? new Date();
|
|
196
|
+
|
|
197
|
+
return withRegistryTransaction(db, async (tx) => {
|
|
198
|
+
const [consumed] = await tx
|
|
199
|
+
.update(registryAuthActionTokens)
|
|
200
|
+
.set({ usedAt: now })
|
|
201
|
+
.where(
|
|
202
|
+
and(
|
|
203
|
+
eq(registryAuthActionTokens.tokenHash, hashSecret(input.token)),
|
|
204
|
+
eq(registryAuthActionTokens.purpose, 'password_reset'),
|
|
205
|
+
isNull(registryAuthActionTokens.usedAt),
|
|
206
|
+
gt(registryAuthActionTokens.expiresAt, now)
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
.returning({ userId: registryAuthActionTokens.userId });
|
|
210
|
+
if (!consumed) return { ok: false, reason: 'invalid_or_expired_token' };
|
|
211
|
+
|
|
212
|
+
const [user] = await tx
|
|
213
|
+
.update(registryUsers)
|
|
214
|
+
.set({
|
|
215
|
+
passwordHash,
|
|
216
|
+
authVersion: sql`${registryUsers.authVersion} + 1`,
|
|
217
|
+
updatedAt: now,
|
|
218
|
+
})
|
|
219
|
+
.where(and(eq(registryUsers.id, consumed.userId), eq(registryUsers.status, 'active')))
|
|
220
|
+
.returning();
|
|
221
|
+
if (!user) return { ok: false, reason: 'invalid_or_expired_token' };
|
|
222
|
+
await revokeAllRegistryAccessTokens(tx, user.id, now);
|
|
223
|
+
return { ok: true, user: toRegistryUser(user) };
|
|
224
|
+
});
|
|
225
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const hashSecret = (secret: string): string => createHash('sha256').update(secret, 'utf8').digest('hex');
|
|
4
|
+
|
|
5
|
+
export const createOpaqueSecret = (byteLength = 32): string => randomBytes(byteLength).toString('base64url');
|
|
6
|
+
|
|
7
|
+
export const secretHashMatches = (secret: string, expectedHash: string): boolean => {
|
|
8
|
+
const actual = Buffer.from(hashSecret(secret), 'hex');
|
|
9
|
+
const expected = Buffer.from(expectedHash, 'hex');
|
|
10
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import type { RegistryDatabase } from '../db/client';
|
|
3
|
+
|
|
4
|
+
export type RegistryTransaction = RegistryDatabase;
|
|
5
|
+
|
|
6
|
+
export const withRegistryTransaction = async <T>(db: RegistryDatabase, callback: (tx: RegistryTransaction) => Promise<T>): Promise<T> =>
|
|
7
|
+
(db as any).transaction((tx: unknown) => callback(tx as RegistryTransaction));
|
|
8
|
+
|
|
9
|
+
export const acquireRegistryAdvisoryLock = async (tx: RegistryTransaction, lockId: number): Promise<void> => {
|
|
10
|
+
await tx.execute(sql`select pg_advisory_xact_lock(${lockId})`);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Stable, package-owned Postgres advisory-lock ids. */
|
|
14
|
+
export const REGISTRY_INSTALL_LOCK_ID = 1_841_111_001;
|
|
15
|
+
export const REGISTRY_ADMIN_LOCK_ID = 1_841_111_002;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { and, eq, gt, lt } from 'drizzle-orm';
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import type { RegistryDatabase } from '../db/client';
|
|
4
|
+
import { registryDeviceAuthorizations, registryUsers } from '../db/schema';
|
|
5
|
+
import {
|
|
6
|
+
createRegistryAccessToken,
|
|
7
|
+
normalizeRegistryScopes,
|
|
8
|
+
registryRoleAllowsScopes,
|
|
9
|
+
type RegistryAccessTokenSummary,
|
|
10
|
+
} from './access-tokens';
|
|
11
|
+
import { hashSecret } from './crypto';
|
|
12
|
+
import { withRegistryTransaction } from './database';
|
|
13
|
+
import { scopesForRegistryRole, type RegistryAccessScope, type RegistryUser } from './types';
|
|
14
|
+
import { toRegistryUser } from './user-record';
|
|
15
|
+
|
|
16
|
+
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXYZ23456789';
|
|
17
|
+
export const DEVICE_AUTHORIZATION_LIFETIME_SECONDS = 15 * 60;
|
|
18
|
+
export const DEVICE_AUTHORIZATION_POLL_INTERVAL_SECONDS = 5;
|
|
19
|
+
|
|
20
|
+
const randomUserCodeSegment = (length: number): string => {
|
|
21
|
+
const bytes = randomBytes(length);
|
|
22
|
+
let result = '';
|
|
23
|
+
for (let index = 0; index < length; index += 1) {
|
|
24
|
+
result += USER_CODE_ALPHABET[bytes[index] % USER_CODE_ALPHABET.length];
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const generateRegistryUserCode = (): string => `${randomUserCodeSegment(4)}-${randomUserCodeSegment(4)}`;
|
|
30
|
+
|
|
31
|
+
export const normalizeRegistryUserCode = (value: string): string => {
|
|
32
|
+
const compact = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
33
|
+
return compact.length === 8 ? `${compact.slice(0, 4)}-${compact.slice(4)}` : value.trim().toUpperCase();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface CreateRegistryDeviceAuthorizationResult {
|
|
37
|
+
deviceCode: string;
|
|
38
|
+
userCode: string;
|
|
39
|
+
expiresIn: number;
|
|
40
|
+
interval: number;
|
|
41
|
+
expiresAt: Date;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const createRegistryDeviceAuthorization = async (
|
|
45
|
+
db: RegistryDatabase,
|
|
46
|
+
options: { now?: Date; expiresInSeconds?: number } = {}
|
|
47
|
+
): Promise<CreateRegistryDeviceAuthorizationResult> => {
|
|
48
|
+
const now = options.now ?? new Date();
|
|
49
|
+
const expiresIn = options.expiresInSeconds ?? DEVICE_AUTHORIZATION_LIFETIME_SECONDS;
|
|
50
|
+
const deviceCode = randomBytes(32).toString('base64url');
|
|
51
|
+
const userCode = generateRegistryUserCode();
|
|
52
|
+
const expiresAt = new Date(now.getTime() + expiresIn * 1000);
|
|
53
|
+
|
|
54
|
+
await db.insert(registryDeviceAuthorizations).values({
|
|
55
|
+
id: randomUUID(),
|
|
56
|
+
deviceCodeHash: hashSecret(deviceCode),
|
|
57
|
+
userCode,
|
|
58
|
+
status: 'pending',
|
|
59
|
+
scopes: [],
|
|
60
|
+
expiresAt,
|
|
61
|
+
createdAt: now,
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
deviceCode,
|
|
65
|
+
userCode,
|
|
66
|
+
expiresIn,
|
|
67
|
+
interval: DEVICE_AUTHORIZATION_POLL_INTERVAL_SECONDS,
|
|
68
|
+
expiresAt,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type RegistryDeviceApprovalResult =
|
|
73
|
+
| { ok: true; user: RegistryUser; scopes: RegistryAccessScope[] }
|
|
74
|
+
| { ok: false; reason: 'invalid_or_expired_code' | 'user_inactive' };
|
|
75
|
+
|
|
76
|
+
export const approveRegistryDeviceAuthorization = async (
|
|
77
|
+
db: RegistryDatabase,
|
|
78
|
+
input: { userCode: string; userId: string; now?: Date }
|
|
79
|
+
): Promise<RegistryDeviceApprovalResult> => {
|
|
80
|
+
const now = input.now ?? new Date();
|
|
81
|
+
const [user] = await db.select().from(registryUsers).where(eq(registryUsers.id, input.userId)).limit(1);
|
|
82
|
+
if (!user || user.status !== 'active') return { ok: false, reason: 'user_inactive' };
|
|
83
|
+
|
|
84
|
+
const scopes = scopesForRegistryRole(user.role);
|
|
85
|
+
const [authorization] = await db
|
|
86
|
+
.update(registryDeviceAuthorizations)
|
|
87
|
+
.set({
|
|
88
|
+
status: 'approved',
|
|
89
|
+
userId: user.id,
|
|
90
|
+
scopes,
|
|
91
|
+
approvedAt: now,
|
|
92
|
+
})
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(registryDeviceAuthorizations.userCode, normalizeRegistryUserCode(input.userCode)),
|
|
96
|
+
eq(registryDeviceAuthorizations.status, 'pending'),
|
|
97
|
+
gt(registryDeviceAuthorizations.expiresAt, now)
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
.returning({ id: registryDeviceAuthorizations.id });
|
|
101
|
+
return authorization ? { ok: true, user: toRegistryUser(user), scopes } : { ok: false, reason: 'invalid_or_expired_code' };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const denyRegistryDeviceAuthorization = async (db: RegistryDatabase, userCode: string, now = new Date()): Promise<boolean> => {
|
|
105
|
+
const rows = await db
|
|
106
|
+
.update(registryDeviceAuthorizations)
|
|
107
|
+
.set({ status: 'denied' })
|
|
108
|
+
.where(
|
|
109
|
+
and(
|
|
110
|
+
eq(registryDeviceAuthorizations.userCode, normalizeRegistryUserCode(userCode)),
|
|
111
|
+
eq(registryDeviceAuthorizations.status, 'pending'),
|
|
112
|
+
gt(registryDeviceAuthorizations.expiresAt, now)
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
.returning({ id: registryDeviceAuthorizations.id });
|
|
116
|
+
return rows.length > 0;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type ExchangeRegistryDeviceAuthorizationResult =
|
|
120
|
+
| {
|
|
121
|
+
ok: true;
|
|
122
|
+
accessToken: string;
|
|
123
|
+
tokenType: 'Bearer';
|
|
124
|
+
expiresIn: number;
|
|
125
|
+
scopes: RegistryAccessScope[];
|
|
126
|
+
user: RegistryUser;
|
|
127
|
+
record: RegistryAccessTokenSummary;
|
|
128
|
+
}
|
|
129
|
+
| {
|
|
130
|
+
ok: false;
|
|
131
|
+
error: 'invalid_grant' | 'expired_token' | 'authorization_pending' | 'access_denied';
|
|
132
|
+
errorDescription: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const exchangeRegistryDeviceAuthorization = async (
|
|
136
|
+
db: RegistryDatabase,
|
|
137
|
+
input: { deviceCode: string; tokenName?: string; now?: Date }
|
|
138
|
+
): Promise<ExchangeRegistryDeviceAuthorizationResult> => {
|
|
139
|
+
const now = input.now ?? new Date();
|
|
140
|
+
const tokenName = input.tokenName?.trim() || 'Handoff CLI';
|
|
141
|
+
|
|
142
|
+
return withRegistryTransaction(db, async (tx) => {
|
|
143
|
+
const [authorization] = await tx
|
|
144
|
+
.select()
|
|
145
|
+
.from(registryDeviceAuthorizations)
|
|
146
|
+
.where(eq(registryDeviceAuthorizations.deviceCodeHash, hashSecret(input.deviceCode)))
|
|
147
|
+
.limit(1);
|
|
148
|
+
if (!authorization) {
|
|
149
|
+
return { ok: false, error: 'invalid_grant', errorDescription: 'Unknown device code.' };
|
|
150
|
+
}
|
|
151
|
+
if (authorization.expiresAt <= now) {
|
|
152
|
+
return { ok: false, error: 'expired_token', errorDescription: 'Device authorization expired.' };
|
|
153
|
+
}
|
|
154
|
+
if (authorization.status === 'pending') {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: 'authorization_pending',
|
|
158
|
+
errorDescription: 'The authorization request is still pending.',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (authorization.status === 'denied') {
|
|
162
|
+
return { ok: false, error: 'access_denied', errorDescription: 'The authorization request was denied.' };
|
|
163
|
+
}
|
|
164
|
+
if (authorization.status === 'consumed') {
|
|
165
|
+
return { ok: false, error: 'invalid_grant', errorDescription: 'Device code has already been used.' };
|
|
166
|
+
}
|
|
167
|
+
if (!authorization.userId) {
|
|
168
|
+
return { ok: false, error: 'invalid_grant', errorDescription: 'Device authorization has no user.' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const [consumed] = await tx
|
|
172
|
+
.update(registryDeviceAuthorizations)
|
|
173
|
+
.set({ status: 'consumed', consumedAt: now })
|
|
174
|
+
.where(
|
|
175
|
+
and(
|
|
176
|
+
eq(registryDeviceAuthorizations.id, authorization.id),
|
|
177
|
+
eq(registryDeviceAuthorizations.status, 'approved'),
|
|
178
|
+
gt(registryDeviceAuthorizations.expiresAt, now)
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
.returning({ id: registryDeviceAuthorizations.id });
|
|
182
|
+
if (!consumed) {
|
|
183
|
+
return { ok: false, error: 'invalid_grant', errorDescription: 'Device code has already been used.' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const [user] = await tx.select().from(registryUsers).where(eq(registryUsers.id, authorization.userId)).limit(1);
|
|
187
|
+
if (!user || user.status !== 'active') {
|
|
188
|
+
return { ok: false, error: 'access_denied', errorDescription: 'The approving account is inactive.' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let scopes = normalizeRegistryScopes(authorization.scopes);
|
|
192
|
+
if (!registryRoleAllowsScopes(user.role, scopes)) scopes = scopesForRegistryRole(user.role);
|
|
193
|
+
const expiresAt = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
|
194
|
+
const token = await createRegistryAccessToken(tx, {
|
|
195
|
+
userId: user.id,
|
|
196
|
+
name: tokenName,
|
|
197
|
+
scopes,
|
|
198
|
+
expiresAt,
|
|
199
|
+
now,
|
|
200
|
+
});
|
|
201
|
+
if (!token.ok) {
|
|
202
|
+
return { ok: false, error: 'access_denied', errorDescription: 'The account cannot issue this token.' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
accessToken: token.token,
|
|
208
|
+
tokenType: 'Bearer',
|
|
209
|
+
expiresIn: Math.floor((expiresAt.getTime() - now.getTime()) / 1000),
|
|
210
|
+
scopes: token.record.scopes,
|
|
211
|
+
user: toRegistryUser(user),
|
|
212
|
+
record: token.record,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const purgeExpiredRegistryDeviceAuthorizations = async (db: RegistryDatabase, now = new Date()): Promise<number> => {
|
|
218
|
+
const rows = await db
|
|
219
|
+
.delete(registryDeviceAuthorizations)
|
|
220
|
+
.where(lt(registryDeviceAuthorizations.expiresAt, now))
|
|
221
|
+
.returning({ id: registryDeviceAuthorizations.id });
|
|
222
|
+
return rows.length;
|
|
223
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { hashSecret, normalizeEmail } from './crypto';
|
|
2
|
+
|
|
3
|
+
// Gravatar resolves an avatar from the SHA-256 hash of the trimmed, lowercased email.
|
|
4
|
+
// `d=identicon` guarantees a generated image renders even when the user has no Gravatar.
|
|
5
|
+
export const buildGravatarUrl = (email: string): string =>
|
|
6
|
+
`https://www.gravatar.com/avatar/${hashSecret(normalizeEmail(email))}?d=identicon`;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createRegistryAccessToken,
|
|
3
|
+
listRegistryAccessTokens,
|
|
4
|
+
revokeRegistryAccessToken,
|
|
5
|
+
revokeAllRegistryAccessTokens,
|
|
6
|
+
authenticateRegistryAccessToken,
|
|
7
|
+
formatRegistryAccessToken,
|
|
8
|
+
parseRegistryAccessToken,
|
|
9
|
+
normalizeRegistryScopes,
|
|
10
|
+
registryRoleAllowsScopes,
|
|
11
|
+
type RegistryAccessTokenSummary,
|
|
12
|
+
type CreateRegistryAccessTokenResult,
|
|
13
|
+
} from './access-tokens';
|
|
14
|
+
export {
|
|
15
|
+
createUserInvitation,
|
|
16
|
+
resendUserInvitation,
|
|
17
|
+
acceptUserInvitation,
|
|
18
|
+
createPasswordReset,
|
|
19
|
+
resetPassword,
|
|
20
|
+
INVITATION_LIFETIME_MS,
|
|
21
|
+
PASSWORD_RESET_LIFETIME_MS,
|
|
22
|
+
type CreateUserInvitationResult,
|
|
23
|
+
type ResendUserInvitationResult,
|
|
24
|
+
type ConsumeAuthActionResult,
|
|
25
|
+
} from './action-tokens';
|
|
26
|
+
export { createOpaqueSecret, hashSecret, normalizeEmail, secretHashMatches } from './crypto';
|
|
27
|
+
export { buildGravatarUrl } from './gravatar';
|
|
28
|
+
export {
|
|
29
|
+
createRegistryDeviceAuthorization,
|
|
30
|
+
approveRegistryDeviceAuthorization,
|
|
31
|
+
denyRegistryDeviceAuthorization,
|
|
32
|
+
exchangeRegistryDeviceAuthorization,
|
|
33
|
+
purgeExpiredRegistryDeviceAuthorizations,
|
|
34
|
+
generateRegistryUserCode,
|
|
35
|
+
normalizeRegistryUserCode,
|
|
36
|
+
DEVICE_AUTHORIZATION_LIFETIME_SECONDS,
|
|
37
|
+
DEVICE_AUTHORIZATION_POLL_INTERVAL_SECONDS,
|
|
38
|
+
type CreateRegistryDeviceAuthorizationResult,
|
|
39
|
+
type RegistryDeviceApprovalResult,
|
|
40
|
+
type ExchangeRegistryDeviceAuthorizationResult,
|
|
41
|
+
} from './device-authorizations';
|
|
42
|
+
export {
|
|
43
|
+
getRegistryInstallation,
|
|
44
|
+
getRegistryInstallationState,
|
|
45
|
+
installRegistry,
|
|
46
|
+
REGISTRY_AUTH_SCHEMA_VERSION,
|
|
47
|
+
REGISTRY_INSTALLATION_ID,
|
|
48
|
+
type RegistryInstallation,
|
|
49
|
+
type RegistryInstallationState,
|
|
50
|
+
type RegistryInstallInput,
|
|
51
|
+
type RegistryInstallResult,
|
|
52
|
+
} from './installation';
|
|
53
|
+
export {
|
|
54
|
+
hashPassword,
|
|
55
|
+
verifyPassword,
|
|
56
|
+
validatePassword,
|
|
57
|
+
MINIMUM_PASSWORD_LENGTH,
|
|
58
|
+
MAXIMUM_PASSWORD_LENGTH,
|
|
59
|
+
type PasswordValidationResult,
|
|
60
|
+
} from './password';
|
|
61
|
+
export {
|
|
62
|
+
consumeAuthRateLimit,
|
|
63
|
+
clearAuthRateLimit,
|
|
64
|
+
purgeExpiredAuthRateLimits,
|
|
65
|
+
hashRateLimitIdentifier,
|
|
66
|
+
type ConsumeAuthRateLimitInput,
|
|
67
|
+
type AuthRateLimitResult,
|
|
68
|
+
} from './rate-limits';
|
|
69
|
+
export {
|
|
70
|
+
getRegistryUserById,
|
|
71
|
+
listRegistryUsers,
|
|
72
|
+
authenticateRegistryCredentials,
|
|
73
|
+
updateRegistryUserProfile,
|
|
74
|
+
updateRegistryUserRole,
|
|
75
|
+
setRegistryUserStatus,
|
|
76
|
+
type RegistryUserMutationResult,
|
|
77
|
+
} from './users';
|
|
78
|
+
export {
|
|
79
|
+
REGISTRY_READ_SCOPE,
|
|
80
|
+
REGISTRY_WRITE_SCOPE,
|
|
81
|
+
scopesForRegistryRole,
|
|
82
|
+
registryPrincipalHasScope,
|
|
83
|
+
type RegistryAccessScope,
|
|
84
|
+
type RegistryPrincipal,
|
|
85
|
+
type RegistryUser,
|
|
86
|
+
type RegistryUserRole,
|
|
87
|
+
type RegistryUserStatus,
|
|
88
|
+
} from './types';
|
|
89
|
+
export { validateRegistryDisplayName, validateRegistryEmail, validateRegistryImageUrl } from './validation';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { count, eq } from 'drizzle-orm';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import type { RegistryDatabase } from '../db/client';
|
|
4
|
+
import { registryInstallations, registryUsers } from '../db/schema';
|
|
5
|
+
import { acquireRegistryAdvisoryLock, REGISTRY_INSTALL_LOCK_ID, withRegistryTransaction } from './database';
|
|
6
|
+
import { hashPassword, validatePassword } from './password';
|
|
7
|
+
import type { RegistryUser } from './types';
|
|
8
|
+
import { toRegistryUser } from './user-record';
|
|
9
|
+
import { validateRegistryDisplayName, validateRegistryEmail } from './validation';
|
|
10
|
+
|
|
11
|
+
/** Migration number that introduced the registry authentication schema. */
|
|
12
|
+
export const REGISTRY_AUTH_SCHEMA_VERSION = 5;
|
|
13
|
+
export const REGISTRY_INSTALLATION_ID = 'default';
|
|
14
|
+
|
|
15
|
+
export type RegistryInstallation = typeof registryInstallations.$inferSelect;
|
|
16
|
+
export type RegistryInstallationState =
|
|
17
|
+
| { status: 'ready'; installation: null; userCount: 0 }
|
|
18
|
+
| { status: 'installed'; installation: RegistryInstallation; userCount: number }
|
|
19
|
+
| { status: 'inconsistent'; installation: null; userCount: number };
|
|
20
|
+
|
|
21
|
+
export interface RegistryInstallInput {
|
|
22
|
+
email: string;
|
|
23
|
+
name: string;
|
|
24
|
+
password: string;
|
|
25
|
+
now?: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RegistryInstallResult =
|
|
29
|
+
| { ok: true; user: RegistryUser; installation: RegistryInstallation }
|
|
30
|
+
| {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: 'invalid_email' | 'invalid_name' | 'invalid_password' | 'already_installed' | 'users_exist';
|
|
33
|
+
error?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const getRegistryInstallation = async (db: RegistryDatabase): Promise<RegistryInstallation | null> => {
|
|
37
|
+
const [installation] = await db
|
|
38
|
+
.select()
|
|
39
|
+
.from(registryInstallations)
|
|
40
|
+
.where(eq(registryInstallations.id, REGISTRY_INSTALLATION_ID))
|
|
41
|
+
.limit(1);
|
|
42
|
+
return installation ?? null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const getRegistryInstallationState = async (db: RegistryDatabase): Promise<RegistryInstallationState> => {
|
|
46
|
+
const installation = await getRegistryInstallation(db);
|
|
47
|
+
const [{ value }] = await db.select({ value: count() }).from(registryUsers);
|
|
48
|
+
const userCount = Number(value);
|
|
49
|
+
if (installation) return { status: 'installed', installation, userCount };
|
|
50
|
+
return userCount === 0
|
|
51
|
+
? { status: 'ready', installation: null, userCount: 0 }
|
|
52
|
+
: { status: 'inconsistent', installation: null, userCount };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Complete first-visitor installation under a transaction-scoped advisory lock. Password hashing is
|
|
57
|
+
* intentionally performed before taking the lock; all state checks are repeated inside it.
|
|
58
|
+
*/
|
|
59
|
+
export const installRegistry = async (db: RegistryDatabase, input: RegistryInstallInput): Promise<RegistryInstallResult> => {
|
|
60
|
+
const email = validateRegistryEmail(input.email);
|
|
61
|
+
if (!email) return { ok: false, reason: 'invalid_email' };
|
|
62
|
+
const name = validateRegistryDisplayName(input.name);
|
|
63
|
+
if (!name) return { ok: false, reason: 'invalid_name' };
|
|
64
|
+
const passwordValidation = validatePassword(input.password);
|
|
65
|
+
if ('error' in passwordValidation) {
|
|
66
|
+
return { ok: false, reason: 'invalid_password', error: passwordValidation.error };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const passwordHash = await hashPassword(input.password);
|
|
70
|
+
const now = input.now ?? new Date();
|
|
71
|
+
|
|
72
|
+
return withRegistryTransaction(db, async (tx) => {
|
|
73
|
+
await acquireRegistryAdvisoryLock(tx, REGISTRY_INSTALL_LOCK_ID);
|
|
74
|
+
|
|
75
|
+
const [installation] = await tx
|
|
76
|
+
.select()
|
|
77
|
+
.from(registryInstallations)
|
|
78
|
+
.where(eq(registryInstallations.id, REGISTRY_INSTALLATION_ID))
|
|
79
|
+
.limit(1);
|
|
80
|
+
if (installation) return { ok: false, reason: 'already_installed' };
|
|
81
|
+
|
|
82
|
+
const [{ value }] = await tx.select({ value: count() }).from(registryUsers);
|
|
83
|
+
if (Number(value) > 0) return { ok: false, reason: 'users_exist' };
|
|
84
|
+
|
|
85
|
+
const userId = randomUUID();
|
|
86
|
+
const [userRow] = await tx
|
|
87
|
+
.insert(registryUsers)
|
|
88
|
+
.values({
|
|
89
|
+
id: userId,
|
|
90
|
+
email,
|
|
91
|
+
name,
|
|
92
|
+
role: 'admin',
|
|
93
|
+
status: 'active',
|
|
94
|
+
passwordHash,
|
|
95
|
+
emailVerifiedAt: now,
|
|
96
|
+
createdAt: now,
|
|
97
|
+
updatedAt: now,
|
|
98
|
+
})
|
|
99
|
+
.returning();
|
|
100
|
+
const [createdInstallation] = await tx
|
|
101
|
+
.insert(registryInstallations)
|
|
102
|
+
.values({
|
|
103
|
+
id: REGISTRY_INSTALLATION_ID,
|
|
104
|
+
status: 'installed',
|
|
105
|
+
schemaVersion: REGISTRY_AUTH_SCHEMA_VERSION,
|
|
106
|
+
initialAdminUserId: userId,
|
|
107
|
+
installedAt: now,
|
|
108
|
+
})
|
|
109
|
+
.returning();
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
user: toRegistryUser(userRow),
|
|
114
|
+
installation: createdInstallation,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
};
|