handoff-app 2.0.0-2 → 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 +140 -83
- package/dist/app-builder/build.js +21 -4
- package/dist/app-builder/client-config.js +9 -8
- 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/scaffold.js +20 -154
- 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/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/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 +1 -1
- package/dist/registry/checkout/index.js +101 -23
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/driver.d.ts +5 -4
- package/dist/registry/db/driver.js +5 -4
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +7 -1
- 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 +1 -1
- package/dist/registry/publish/index.js +9 -9
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/types/config.d.ts +4 -1
- 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/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/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 +0 -14
- 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 +4 -4
- package/src/app/next.config.mjs +12 -3
- 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/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/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 +22 -5
- package/src/app-builder/client-config.ts +10 -9
- 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/scaffold.ts +20 -197
- 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/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- 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 +114 -24
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/driver.ts +5 -4
- package/src/registry/db/index.ts +12 -0
- 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 +9 -9
- package/src/registry/publish/tokens.ts +1 -1
- package/src/types/config.ts +4 -1
- package/templates/default/README.md.tpl +18 -0
- package/templates/default/gitignore.tpl +1 -0
- package/templates/default/handoff.config.js.tpl +0 -1
- package/templates/default/handoff.config.ts.tpl +0 -1
- package/templates/default/handoff.config.with-examples.js.tpl +0 -1
- package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { and, eq, gt, isNull } from 'drizzle-orm';
|
|
2
|
+
import type { RegistryDatabase } from '../db/client';
|
|
3
|
+
import { registryAccessTokens, registryUsers, type RegistryAccessScope } from '../db/schema';
|
|
4
|
+
import { createOpaqueSecret, hashSecret, secretHashMatches } from './crypto';
|
|
5
|
+
import { withRegistryTransaction } from './database';
|
|
6
|
+
import { REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE, type RegistryPrincipal, type RegistryUserRole } from './types';
|
|
7
|
+
|
|
8
|
+
const ACCESS_TOKEN_PREFIX = 'hnd';
|
|
9
|
+
const DEFAULT_ACCESS_TOKEN_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export interface RegistryAccessTokenSummary {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
scopes: RegistryAccessScope[];
|
|
15
|
+
expiresAt: Date;
|
|
16
|
+
lastUsedAt: Date | null;
|
|
17
|
+
revokedAt: Date | null;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CreateRegistryAccessTokenResult =
|
|
22
|
+
| { ok: true; token: string; record: RegistryAccessTokenSummary }
|
|
23
|
+
| { ok: false; reason: 'user_not_found' | 'user_inactive' | 'scope_forbidden' | 'invalid_name' | 'invalid_expiry' };
|
|
24
|
+
|
|
25
|
+
const isRegistryScope = (value: unknown): value is RegistryAccessScope => value === REGISTRY_READ_SCOPE || value === REGISTRY_WRITE_SCOPE;
|
|
26
|
+
|
|
27
|
+
export const normalizeRegistryScopes = (scopes: readonly RegistryAccessScope[]): RegistryAccessScope[] => {
|
|
28
|
+
const unique = new Set(scopes.filter(isRegistryScope));
|
|
29
|
+
return [REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE].filter((scope) => unique.has(scope));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const registryRoleAllowsScopes = (role: RegistryUserRole, scopes: readonly RegistryAccessScope[]): boolean =>
|
|
33
|
+
role === 'admin' || !scopes.includes(REGISTRY_WRITE_SCOPE);
|
|
34
|
+
|
|
35
|
+
export const formatRegistryAccessToken = (id: string, secret: string): string => `${ACCESS_TOKEN_PREFIX}_${id}_${secret}`;
|
|
36
|
+
|
|
37
|
+
export const parseRegistryAccessToken = (token: string): { id: string; secret: string } | null => {
|
|
38
|
+
const match = /^hnd_([A-Za-z0-9_-]{16})_([A-Za-z0-9_-]{43})$/.exec(token.trim());
|
|
39
|
+
return match ? { id: match[1], secret: match[2] } : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const createRegistryAccessTokenInTransaction = async (
|
|
43
|
+
db: RegistryDatabase,
|
|
44
|
+
input: {
|
|
45
|
+
userId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
scopes: RegistryAccessScope[];
|
|
48
|
+
expiresAt?: Date;
|
|
49
|
+
now?: Date;
|
|
50
|
+
}
|
|
51
|
+
): Promise<CreateRegistryAccessTokenResult> => {
|
|
52
|
+
const name = input.name.trim();
|
|
53
|
+
if (!name || name.length > 120) return { ok: false, reason: 'invalid_name' };
|
|
54
|
+
|
|
55
|
+
const now = input.now ?? new Date();
|
|
56
|
+
const expiresAt = input.expiresAt ?? new Date(now.getTime() + DEFAULT_ACCESS_TOKEN_LIFETIME_MS);
|
|
57
|
+
if (!(expiresAt instanceof Date) || !Number.isFinite(expiresAt.getTime()) || expiresAt <= now) {
|
|
58
|
+
return { ok: false, reason: 'invalid_expiry' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [user] = await db
|
|
62
|
+
.select({ id: registryUsers.id, role: registryUsers.role, status: registryUsers.status })
|
|
63
|
+
.from(registryUsers)
|
|
64
|
+
.where(eq(registryUsers.id, input.userId))
|
|
65
|
+
.limit(1)
|
|
66
|
+
.for('update');
|
|
67
|
+
if (!user) return { ok: false, reason: 'user_not_found' };
|
|
68
|
+
if (user.status !== 'active') return { ok: false, reason: 'user_inactive' };
|
|
69
|
+
|
|
70
|
+
const scopes = normalizeRegistryScopes(input.scopes);
|
|
71
|
+
if (!scopes.length) scopes.push(REGISTRY_READ_SCOPE);
|
|
72
|
+
if (!registryRoleAllowsScopes(user.role, scopes)) return { ok: false, reason: 'scope_forbidden' };
|
|
73
|
+
|
|
74
|
+
const id = createOpaqueSecret(12);
|
|
75
|
+
const secret = createOpaqueSecret(32);
|
|
76
|
+
const [record] = await db
|
|
77
|
+
.insert(registryAccessTokens)
|
|
78
|
+
.values({
|
|
79
|
+
id,
|
|
80
|
+
userId: user.id,
|
|
81
|
+
name,
|
|
82
|
+
secretHash: hashSecret(secret),
|
|
83
|
+
scopes,
|
|
84
|
+
expiresAt,
|
|
85
|
+
})
|
|
86
|
+
.returning({
|
|
87
|
+
id: registryAccessTokens.id,
|
|
88
|
+
name: registryAccessTokens.name,
|
|
89
|
+
scopes: registryAccessTokens.scopes,
|
|
90
|
+
expiresAt: registryAccessTokens.expiresAt,
|
|
91
|
+
lastUsedAt: registryAccessTokens.lastUsedAt,
|
|
92
|
+
revokedAt: registryAccessTokens.revokedAt,
|
|
93
|
+
createdAt: registryAccessTokens.createdAt,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { ok: true, token: formatRegistryAccessToken(id, secret), record };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const createRegistryAccessToken = async (
|
|
100
|
+
db: RegistryDatabase,
|
|
101
|
+
input: {
|
|
102
|
+
userId: string;
|
|
103
|
+
name: string;
|
|
104
|
+
scopes: RegistryAccessScope[];
|
|
105
|
+
expiresAt?: Date;
|
|
106
|
+
now?: Date;
|
|
107
|
+
}
|
|
108
|
+
): Promise<CreateRegistryAccessTokenResult> => withRegistryTransaction(db, (tx) => createRegistryAccessTokenInTransaction(tx, input));
|
|
109
|
+
|
|
110
|
+
export const listRegistryAccessTokens = async (db: RegistryDatabase, userId: string): Promise<RegistryAccessTokenSummary[]> =>
|
|
111
|
+
db
|
|
112
|
+
.select({
|
|
113
|
+
id: registryAccessTokens.id,
|
|
114
|
+
name: registryAccessTokens.name,
|
|
115
|
+
scopes: registryAccessTokens.scopes,
|
|
116
|
+
expiresAt: registryAccessTokens.expiresAt,
|
|
117
|
+
lastUsedAt: registryAccessTokens.lastUsedAt,
|
|
118
|
+
revokedAt: registryAccessTokens.revokedAt,
|
|
119
|
+
createdAt: registryAccessTokens.createdAt,
|
|
120
|
+
})
|
|
121
|
+
.from(registryAccessTokens)
|
|
122
|
+
.where(eq(registryAccessTokens.userId, userId))
|
|
123
|
+
.orderBy(registryAccessTokens.createdAt);
|
|
124
|
+
|
|
125
|
+
export const revokeRegistryAccessToken = async (
|
|
126
|
+
db: RegistryDatabase,
|
|
127
|
+
input: { tokenId: string; userId: string; now?: Date }
|
|
128
|
+
): Promise<boolean> => {
|
|
129
|
+
const rows = await db
|
|
130
|
+
.update(registryAccessTokens)
|
|
131
|
+
.set({ revokedAt: input.now ?? new Date() })
|
|
132
|
+
.where(
|
|
133
|
+
and(eq(registryAccessTokens.id, input.tokenId), eq(registryAccessTokens.userId, input.userId), isNull(registryAccessTokens.revokedAt))
|
|
134
|
+
)
|
|
135
|
+
.returning({ id: registryAccessTokens.id });
|
|
136
|
+
return rows.length > 0;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const revokeAllRegistryAccessTokens = async (db: RegistryDatabase, userId: string, now = new Date()): Promise<number> => {
|
|
140
|
+
const rows = await db
|
|
141
|
+
.update(registryAccessTokens)
|
|
142
|
+
.set({ revokedAt: now })
|
|
143
|
+
.where(and(eq(registryAccessTokens.userId, userId), isNull(registryAccessTokens.revokedAt)))
|
|
144
|
+
.returning({ id: registryAccessTokens.id });
|
|
145
|
+
return rows.length;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve an opaque bearer credential into a live principal. User state is joined on every call,
|
|
150
|
+
* making deactivation effective even if a defensive token-revocation write was interrupted.
|
|
151
|
+
*/
|
|
152
|
+
export const authenticateRegistryAccessToken = async (
|
|
153
|
+
db: RegistryDatabase,
|
|
154
|
+
token: string,
|
|
155
|
+
now = new Date()
|
|
156
|
+
): Promise<RegistryPrincipal | null> => {
|
|
157
|
+
const parsed = parseRegistryAccessToken(token);
|
|
158
|
+
if (!parsed) return null;
|
|
159
|
+
|
|
160
|
+
const [row] = await db
|
|
161
|
+
.select({
|
|
162
|
+
tokenId: registryAccessTokens.id,
|
|
163
|
+
secretHash: registryAccessTokens.secretHash,
|
|
164
|
+
scopes: registryAccessTokens.scopes,
|
|
165
|
+
expiresAt: registryAccessTokens.expiresAt,
|
|
166
|
+
revokedAt: registryAccessTokens.revokedAt,
|
|
167
|
+
userId: registryUsers.id,
|
|
168
|
+
email: registryUsers.email,
|
|
169
|
+
name: registryUsers.name,
|
|
170
|
+
role: registryUsers.role,
|
|
171
|
+
status: registryUsers.status,
|
|
172
|
+
})
|
|
173
|
+
.from(registryAccessTokens)
|
|
174
|
+
.innerJoin(registryUsers, eq(registryUsers.id, registryAccessTokens.userId))
|
|
175
|
+
.where(eq(registryAccessTokens.id, parsed.id))
|
|
176
|
+
.limit(1);
|
|
177
|
+
|
|
178
|
+
if (!row || row.status !== 'active' || row.revokedAt || row.expiresAt <= now || !secretHashMatches(parsed.secret, row.secretHash)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const scopes = normalizeRegistryScopes(Array.isArray(row.scopes) ? row.scopes : []);
|
|
183
|
+
const updated = await db
|
|
184
|
+
.update(registryAccessTokens)
|
|
185
|
+
.set({ lastUsedAt: now })
|
|
186
|
+
.where(and(eq(registryAccessTokens.id, row.tokenId), isNull(registryAccessTokens.revokedAt), gt(registryAccessTokens.expiresAt, now)))
|
|
187
|
+
.returning({ id: registryAccessTokens.id });
|
|
188
|
+
if (!updated.length) return null;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
tokenId: row.tokenId,
|
|
192
|
+
userId: row.userId,
|
|
193
|
+
email: row.email,
|
|
194
|
+
name: row.name,
|
|
195
|
+
role: row.role,
|
|
196
|
+
scopes,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
@@ -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`;
|