@striae-org/striae 5.2.1 → 5.3.0
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/.env.example +2 -10
- package/README.md +5 -46
- package/app/components/actions/case-export/core-export.ts +2 -174
- package/app/components/actions/case-export/download-handlers.ts +83 -750
- package/app/components/actions/case-export/index.ts +6 -30
- package/app/components/actions/case-export/metadata-helpers.ts +0 -78
- package/app/components/actions/case-export/types-constants.ts +0 -43
- package/app/components/actions/case-import/confirmation-import.ts +13 -14
- package/app/components/actions/case-import/zip-processing.ts +92 -12
- package/app/components/actions/generate-pdf.ts +3 -2
- package/app/components/audit/user-audit-viewer.tsx +0 -19
- package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
- package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.module.css +35 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
- package/app/components/sidebar/notes/class-details-shared.ts +2 -2
- package/app/components/toast/toast.module.css +36 -0
- package/app/components/toast/toast.tsx +6 -2
- package/app/components/user/manage-profile.tsx +4 -3
- package/app/config-example/config.json +1 -2
- package/app/root.tsx +0 -7
- package/app/routes/_index.tsx +1 -1
- package/app/routes/auth/login.example.tsx +22 -103
- package/app/routes/auth/route.ts +1 -1
- package/app/routes/striae/striae.tsx +53 -59
- package/app/services/firebase/index.ts +0 -3
- package/app/types/export.ts +1 -2
- package/app/utils/auth/index.ts +0 -1
- package/app/utils/data/permissions.ts +3 -2
- package/package.json +9 -16
- package/public/_headers +0 -4
- package/public/_routes.json +0 -1
- package/worker-configuration.d.ts +20 -17
- package/workers/audit-worker/src/audit-worker.example.ts +9 -806
- package/workers/audit-worker/src/config.ts +7 -0
- package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
- package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
- package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
- package/workers/audit-worker/src/types.ts +56 -0
- package/workers/audit-worker/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/config.ts +11 -0
- package/workers/data-worker/src/data-worker.example.ts +21 -942
- package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
- package/workers/data-worker/src/handlers/signing.ts +174 -0
- package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
- package/workers/data-worker/src/registry/key-registry.ts +368 -0
- package/workers/data-worker/src/types.ts +46 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/worker-configuration.d.ts +2 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/auth.ts +30 -0
- package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
- package/workers/user-worker/src/config.ts +4 -0
- package/workers/user-worker/src/encryption-utils.ts +25 -0
- package/workers/user-worker/src/firebase/admin.ts +152 -0
- package/workers/user-worker/src/handlers/user-routes.ts +242 -0
- package/workers/user-worker/src/registry/user-kv.ts +172 -0
- package/workers/user-worker/src/storage/user-records.ts +34 -0
- package/workers/user-worker/src/types.ts +106 -0
- package/workers/user-worker/src/user-worker.example.ts +18 -964
- package/workers/user-worker/worker-configuration.d.ts +4 -2
- package/workers/user-worker/wrangler.jsonc.example +12 -1
- package/wrangler.toml.example +1 -1
- package/app/components/actions/case-export/data-processing.ts +0 -223
- package/app/components/sidebar/case-export/case-export.module.css +0 -418
- package/app/components/sidebar/case-export/case-export.tsx +0 -310
- package/app/types/exceljs-bare.d.ts +0 -9
- package/app/utils/auth/auth.ts +0 -11
- package/public/.well-known/security.txt +0 -6
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +0 -39
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/vendor/exceljs.LICENSE +0 -22
- package/public/vendor/exceljs.bare.min.js +0 -45
- package/scripts/deploy-all.sh +0 -166
- package/scripts/deploy-config/modules/env-utils.sh +0 -322
- package/scripts/deploy-config/modules/keys.sh +0 -404
- package/scripts/deploy-config/modules/prompt.sh +0 -372
- package/scripts/deploy-config/modules/scaffolding.sh +0 -344
- package/scripts/deploy-config/modules/validation.sh +0 -365
- package/scripts/deploy-config.sh +0 -236
- package/scripts/deploy-pages-secrets.sh +0 -231
- package/scripts/deploy-pages.sh +0 -34
- package/scripts/deploy-primershear-emails.sh +0 -167
- package/scripts/deploy-worker-secrets.sh +0 -374
- package/scripts/dev.cjs +0 -23
- package/scripts/install-workers.sh +0 -88
- package/scripts/run-eslint.cjs +0 -43
- package/scripts/update-compatibility-dates.cjs +0 -124
- package/scripts/update-markdown-versions.cjs +0 -43
- package/workers/keys-worker/package.json +0 -18
- package/workers/keys-worker/src/keys.example.ts +0 -67
- package/workers/keys-worker/src/keys.ts +0 -67
- package/workers/keys-worker/worker-configuration.d.ts +0 -7447
- package/workers/keys-worker/wrangler.jsonc.example +0 -15
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { executeUserDeletion } from '../cleanup/account-deletion';
|
|
2
|
+
import { readUserRecord, writeUserRecord } from '../storage/user-records';
|
|
3
|
+
import type {
|
|
4
|
+
AddCasesRequest,
|
|
5
|
+
AccountDeletionProgressEvent,
|
|
6
|
+
DeleteCasesRequest,
|
|
7
|
+
Env,
|
|
8
|
+
ResponseHeaders,
|
|
9
|
+
UserData,
|
|
10
|
+
UserRequestData
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
function createJsonResponse(data: unknown, headers: ResponseHeaders, status: number = 200): Response {
|
|
14
|
+
return new Response(JSON.stringify(data), {
|
|
15
|
+
status,
|
|
16
|
+
headers
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handleGetUser(
|
|
21
|
+
env: Env,
|
|
22
|
+
userUid: string,
|
|
23
|
+
corsHeaders: ResponseHeaders
|
|
24
|
+
): Promise<Response> {
|
|
25
|
+
try {
|
|
26
|
+
const userData = await readUserRecord(env, userUid);
|
|
27
|
+
if (userData === null) {
|
|
28
|
+
return new Response('User not found', {
|
|
29
|
+
status: 404,
|
|
30
|
+
headers: corsHeaders
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return createJsonResponse(userData, corsHeaders);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown user data read error';
|
|
37
|
+
console.error('Failed to get user data:', { uid: userUid, reason: errorMessage });
|
|
38
|
+
|
|
39
|
+
return new Response('Failed to get user data', {
|
|
40
|
+
status: 500,
|
|
41
|
+
headers: corsHeaders
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function handleAddUser(
|
|
47
|
+
request: Request,
|
|
48
|
+
env: Env,
|
|
49
|
+
userUid: string,
|
|
50
|
+
corsHeaders: ResponseHeaders
|
|
51
|
+
): Promise<Response> {
|
|
52
|
+
try {
|
|
53
|
+
const requestData: UserRequestData = await request.json();
|
|
54
|
+
const { email, firstName, lastName, company, badgeId, permitted } = requestData;
|
|
55
|
+
const normalizedBadgeId = typeof badgeId === 'string' ? badgeId.trim() : undefined;
|
|
56
|
+
const existingUser = await readUserRecord(env, userUid);
|
|
57
|
+
|
|
58
|
+
let userData: UserData;
|
|
59
|
+
if (existingUser !== null) {
|
|
60
|
+
userData = {
|
|
61
|
+
...existingUser,
|
|
62
|
+
email: email || existingUser.email,
|
|
63
|
+
firstName: firstName || existingUser.firstName,
|
|
64
|
+
lastName: lastName || existingUser.lastName,
|
|
65
|
+
company: company || existingUser.company,
|
|
66
|
+
badgeId: normalizedBadgeId !== undefined ? normalizedBadgeId : (existingUser.badgeId ?? ''),
|
|
67
|
+
permitted: permitted !== undefined ? permitted : existingUser.permitted,
|
|
68
|
+
updatedAt: new Date().toISOString()
|
|
69
|
+
};
|
|
70
|
+
if (requestData.readOnlyCases !== undefined) {
|
|
71
|
+
userData.readOnlyCases = requestData.readOnlyCases;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
userData = {
|
|
75
|
+
uid: userUid,
|
|
76
|
+
email: email || '',
|
|
77
|
+
firstName: firstName || '',
|
|
78
|
+
lastName: lastName || '',
|
|
79
|
+
company: company || '',
|
|
80
|
+
badgeId: normalizedBadgeId ?? '',
|
|
81
|
+
permitted: permitted !== undefined ? permitted : true,
|
|
82
|
+
cases: [],
|
|
83
|
+
createdAt: new Date().toISOString()
|
|
84
|
+
};
|
|
85
|
+
if (requestData.readOnlyCases !== undefined) {
|
|
86
|
+
userData.readOnlyCases = requestData.readOnlyCases;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await writeUserRecord(env, userUid, userData);
|
|
91
|
+
|
|
92
|
+
return createJsonResponse(userData, corsHeaders, existingUser !== null ? 200 : 201);
|
|
93
|
+
} catch {
|
|
94
|
+
return new Response('Failed to save user data', {
|
|
95
|
+
status: 500,
|
|
96
|
+
headers: corsHeaders
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function handleDeleteUser(
|
|
102
|
+
env: Env,
|
|
103
|
+
userUid: string,
|
|
104
|
+
corsHeaders: ResponseHeaders
|
|
105
|
+
): Promise<Response> {
|
|
106
|
+
try {
|
|
107
|
+
const result = await executeUserDeletion(env, userUid);
|
|
108
|
+
|
|
109
|
+
return createJsonResponse({
|
|
110
|
+
success: result.success,
|
|
111
|
+
message: result.message
|
|
112
|
+
}, corsHeaders);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Delete user error:', error);
|
|
115
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
116
|
+
|
|
117
|
+
if (errorMessage === 'User not found') {
|
|
118
|
+
return new Response('User not found', {
|
|
119
|
+
status: 404,
|
|
120
|
+
headers: corsHeaders
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return createJsonResponse({
|
|
125
|
+
success: false,
|
|
126
|
+
message: 'Failed to delete user account'
|
|
127
|
+
}, corsHeaders, 500);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function handleDeleteUserWithProgress(
|
|
132
|
+
env: Env,
|
|
133
|
+
userUid: string,
|
|
134
|
+
corsHeaders: ResponseHeaders
|
|
135
|
+
): Response {
|
|
136
|
+
const sseHeaders: ResponseHeaders = {
|
|
137
|
+
...corsHeaders,
|
|
138
|
+
'Content-Type': 'text/event-stream',
|
|
139
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
140
|
+
Connection: 'keep-alive'
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const encoder = new TextEncoder();
|
|
144
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
145
|
+
async start(controller) {
|
|
146
|
+
const sendEvent = (payload: AccountDeletionProgressEvent): void => {
|
|
147
|
+
controller.enqueue(encoder.encode(`event: ${payload.event}\ndata: ${JSON.stringify(payload)}\n\n`));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const result = await executeUserDeletion(env, userUid, sendEvent);
|
|
152
|
+
sendEvent({
|
|
153
|
+
event: 'complete',
|
|
154
|
+
totalCases: result.totalCases,
|
|
155
|
+
completedCases: result.completedCases,
|
|
156
|
+
success: result.success,
|
|
157
|
+
message: result.message
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to delete user account';
|
|
161
|
+
|
|
162
|
+
sendEvent({
|
|
163
|
+
event: 'error',
|
|
164
|
+
totalCases: 0,
|
|
165
|
+
completedCases: 0,
|
|
166
|
+
success: false,
|
|
167
|
+
message: errorMessage
|
|
168
|
+
});
|
|
169
|
+
} finally {
|
|
170
|
+
controller.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return new Response(stream, {
|
|
176
|
+
status: 200,
|
|
177
|
+
headers: sseHeaders
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function handleAddCases(
|
|
182
|
+
request: Request,
|
|
183
|
+
env: Env,
|
|
184
|
+
userUid: string,
|
|
185
|
+
corsHeaders: ResponseHeaders
|
|
186
|
+
): Promise<Response> {
|
|
187
|
+
try {
|
|
188
|
+
const { cases = [] }: AddCasesRequest = await request.json();
|
|
189
|
+
const userData = await readUserRecord(env, userUid);
|
|
190
|
+
if (!userData) {
|
|
191
|
+
return new Response('User not found', {
|
|
192
|
+
status: 404,
|
|
193
|
+
headers: corsHeaders
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const existingCases = userData.cases || [];
|
|
198
|
+
const newCases = cases.filter((newCase) =>
|
|
199
|
+
!existingCases.some((existingCase) => existingCase.caseNumber === newCase.caseNumber)
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
userData.cases = [...existingCases, ...newCases];
|
|
203
|
+
userData.updatedAt = new Date().toISOString();
|
|
204
|
+
await writeUserRecord(env, userUid, userData);
|
|
205
|
+
|
|
206
|
+
return createJsonResponse(userData, corsHeaders);
|
|
207
|
+
} catch {
|
|
208
|
+
return new Response('Failed to add cases', {
|
|
209
|
+
status: 500,
|
|
210
|
+
headers: corsHeaders
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function handleDeleteCases(
|
|
216
|
+
request: Request,
|
|
217
|
+
env: Env,
|
|
218
|
+
userUid: string,
|
|
219
|
+
corsHeaders: ResponseHeaders
|
|
220
|
+
): Promise<Response> {
|
|
221
|
+
try {
|
|
222
|
+
const { casesToDelete }: DeleteCasesRequest = await request.json();
|
|
223
|
+
const userData = await readUserRecord(env, userUid);
|
|
224
|
+
if (!userData) {
|
|
225
|
+
return new Response('User not found', {
|
|
226
|
+
status: 404,
|
|
227
|
+
headers: corsHeaders
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
userData.cases = userData.cases.filter((caseItem) => !casesToDelete.includes(caseItem.caseNumber));
|
|
232
|
+
userData.updatedAt = new Date().toISOString();
|
|
233
|
+
await writeUserRecord(env, userUid, userData);
|
|
234
|
+
|
|
235
|
+
return createJsonResponse(userData, corsHeaders);
|
|
236
|
+
} catch {
|
|
237
|
+
return new Response('Failed to delete cases', {
|
|
238
|
+
status: 500,
|
|
239
|
+
headers: corsHeaders
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { decryptJsonFromUserKv, type UserKvEncryptedRecord } from '../encryption-utils';
|
|
2
|
+
import type {
|
|
3
|
+
DecryptionTelemetryOutcome,
|
|
4
|
+
Env,
|
|
5
|
+
KeyRegistryPayload,
|
|
6
|
+
PrivateKeyRegistry
|
|
7
|
+
} from '../types';
|
|
8
|
+
|
|
9
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
10
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
14
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseUserKvPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
18
|
+
const keys: Record<string, string> = {};
|
|
19
|
+
const configuredActiveKeyId = getNonEmptyString(env.USER_KV_ENCRYPTION_ACTIVE_KEY_ID);
|
|
20
|
+
|
|
21
|
+
if (getNonEmptyString(env.USER_KV_ENCRYPTION_KEYS_JSON)) {
|
|
22
|
+
let parsedRegistry: unknown;
|
|
23
|
+
try {
|
|
24
|
+
parsedRegistry = JSON.parse(env.USER_KV_ENCRYPTION_KEYS_JSON as string) as unknown;
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error('USER_KV_ENCRYPTION_KEYS_JSON is not valid JSON');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
30
|
+
throw new Error('USER_KV_ENCRYPTION_KEYS_JSON must be an object');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
34
|
+
if (!payload.keys || typeof payload.keys !== 'object') {
|
|
35
|
+
throw new Error('USER_KV_ENCRYPTION_KEYS_JSON must include a keys object');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
|
|
39
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
40
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
41
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
49
|
+
const activeKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
50
|
+
|
|
51
|
+
if (Object.keys(keys).length === 0) {
|
|
52
|
+
throw new Error('USER_KV_ENCRYPTION_KEYS_JSON does not contain any usable keys');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (activeKeyId && !keys[activeKeyId]) {
|
|
56
|
+
throw new Error('USER_KV active key ID is not present in USER_KV_ENCRYPTION_KEYS_JSON');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
activeKeyId: activeKeyId ?? null,
|
|
61
|
+
keys
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const legacyKeyId = getNonEmptyString(env.USER_KV_ENCRYPTION_KEY_ID);
|
|
66
|
+
const legacyPrivateKey = getNonEmptyString(env.USER_KV_ENCRYPTION_PRIVATE_KEY);
|
|
67
|
+
if (!legacyKeyId || !legacyPrivateKey) {
|
|
68
|
+
throw new Error('User KV encryption private key registry is not configured');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
activeKeyId: configuredActiveKeyId ?? legacyKeyId,
|
|
75
|
+
keys
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildPrivateKeyCandidates(
|
|
80
|
+
recordKeyId: string,
|
|
81
|
+
registry: PrivateKeyRegistry
|
|
82
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
83
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
|
|
86
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
87
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
92
|
+
if (!privateKeyPem) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
seen.add(candidateKeyId);
|
|
97
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
appendCandidate(getNonEmptyString(recordKeyId));
|
|
101
|
+
appendCandidate(registry.activeKeyId);
|
|
102
|
+
|
|
103
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
104
|
+
appendCandidate(keyId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return candidates;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function logUserKvDecryptionTelemetry(input: {
|
|
111
|
+
recordKeyId: string;
|
|
112
|
+
selectedKeyId: string | null;
|
|
113
|
+
attemptCount: number;
|
|
114
|
+
outcome: DecryptionTelemetryOutcome;
|
|
115
|
+
reason?: string;
|
|
116
|
+
}): void {
|
|
117
|
+
const details = {
|
|
118
|
+
scope: 'user-kv',
|
|
119
|
+
recordKeyId: input.recordKeyId,
|
|
120
|
+
selectedKeyId: input.selectedKeyId,
|
|
121
|
+
attemptCount: input.attemptCount,
|
|
122
|
+
fallbackUsed: input.outcome === 'fallback-hit',
|
|
123
|
+
outcome: input.outcome,
|
|
124
|
+
reason: input.reason ?? null
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (input.outcome === 'all-failed') {
|
|
128
|
+
console.warn('Key registry decryption failed', details);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.info('Key registry decryption resolved', details);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function decryptUserKvRecord(
|
|
136
|
+
encryptedRecord: UserKvEncryptedRecord,
|
|
137
|
+
registry: PrivateKeyRegistry
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
const candidates = buildPrivateKeyCandidates(encryptedRecord.keyId, registry);
|
|
140
|
+
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
141
|
+
let lastError: unknown;
|
|
142
|
+
|
|
143
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
144
|
+
const candidate = candidates[index];
|
|
145
|
+
try {
|
|
146
|
+
const decryptedJson = await decryptJsonFromUserKv(encryptedRecord, candidate.privateKeyPem);
|
|
147
|
+
logUserKvDecryptionTelemetry({
|
|
148
|
+
recordKeyId: encryptedRecord.keyId,
|
|
149
|
+
selectedKeyId: candidate.keyId,
|
|
150
|
+
attemptCount: index + 1,
|
|
151
|
+
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
152
|
+
});
|
|
153
|
+
return decryptedJson;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
lastError = error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
logUserKvDecryptionTelemetry({
|
|
160
|
+
recordKeyId: encryptedRecord.keyId,
|
|
161
|
+
selectedKeyId: null,
|
|
162
|
+
attemptCount: candidates.length,
|
|
163
|
+
outcome: 'all-failed',
|
|
164
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Failed to decrypt user KV record after ${candidates.length} key attempt(s): ${
|
|
169
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
170
|
+
}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encryptJsonForUserKv,
|
|
3
|
+
tryParseEncryptedRecord,
|
|
4
|
+
validateEncryptedRecord
|
|
5
|
+
} from '../encryption-utils';
|
|
6
|
+
import { decryptUserKvRecord, parseUserKvPrivateKeyRegistry } from '../registry/user-kv';
|
|
7
|
+
import type { Env, UserData } from '../types';
|
|
8
|
+
|
|
9
|
+
export async function readUserRecord(env: Env, userUid: string): Promise<UserData | null> {
|
|
10
|
+
const storedValue = await env.USER_DB.get(userUid);
|
|
11
|
+
if (storedValue === null) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const encryptedRecord = tryParseEncryptedRecord(storedValue);
|
|
16
|
+
if (!encryptedRecord) {
|
|
17
|
+
throw new Error('User KV record is not encrypted');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
validateEncryptedRecord(encryptedRecord);
|
|
21
|
+
const keyRegistry = parseUserKvPrivateKeyRegistry(env);
|
|
22
|
+
const decryptedJson = await decryptUserKvRecord(encryptedRecord, keyRegistry);
|
|
23
|
+
return JSON.parse(decryptedJson) as UserData;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeUserRecord(env: Env, userUid: string, userData: UserData): Promise<void> {
|
|
27
|
+
const encryptedPayload = await encryptJsonForUserKv(
|
|
28
|
+
JSON.stringify(userData),
|
|
29
|
+
env.USER_KV_ENCRYPTION_PUBLIC_KEY,
|
|
30
|
+
env.USER_KV_ENCRYPTION_KEY_ID
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
await env.USER_DB.put(userUid, encryptedPayload);
|
|
34
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface Env {
|
|
2
|
+
USER_DB_AUTH: string;
|
|
3
|
+
USER_DB: KVNamespace;
|
|
4
|
+
STRIAE_DATA: R2Bucket;
|
|
5
|
+
STRIAE_FILES: R2Bucket;
|
|
6
|
+
R2_KEY_SECRET: string;
|
|
7
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
8
|
+
DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
|
|
9
|
+
DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
|
|
10
|
+
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
11
|
+
PROJECT_ID: string;
|
|
12
|
+
FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
|
|
13
|
+
FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
|
|
14
|
+
USER_KV_ENCRYPTION_PRIVATE_KEY: string;
|
|
15
|
+
USER_KV_ENCRYPTION_PUBLIC_KEY: string;
|
|
16
|
+
USER_KV_ENCRYPTION_KEY_ID: string;
|
|
17
|
+
USER_KV_ENCRYPTION_KEYS_JSON?: string;
|
|
18
|
+
USER_KV_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KeyRegistryPayload {
|
|
22
|
+
activeKeyId?: unknown;
|
|
23
|
+
keys?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PrivateKeyRegistry {
|
|
27
|
+
activeKeyId: string | null;
|
|
28
|
+
keys: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
32
|
+
|
|
33
|
+
export interface CaseItem {
|
|
34
|
+
caseNumber: string;
|
|
35
|
+
caseName?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReadOnlyCaseItem {
|
|
40
|
+
caseNumber: string;
|
|
41
|
+
caseName?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UserData {
|
|
46
|
+
uid: string;
|
|
47
|
+
email: string;
|
|
48
|
+
firstName: string;
|
|
49
|
+
lastName: string;
|
|
50
|
+
company: string;
|
|
51
|
+
badgeId?: string;
|
|
52
|
+
permitted: boolean;
|
|
53
|
+
cases: CaseItem[];
|
|
54
|
+
readOnlyCases?: ReadOnlyCaseItem[];
|
|
55
|
+
createdAt?: string;
|
|
56
|
+
updatedAt?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StoredCaseFileData {
|
|
60
|
+
id: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StoredCaseData {
|
|
64
|
+
files?: StoredCaseFileData[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface UserRequestData {
|
|
68
|
+
email?: string;
|
|
69
|
+
firstName?: string;
|
|
70
|
+
lastName?: string;
|
|
71
|
+
company?: string;
|
|
72
|
+
badgeId?: string;
|
|
73
|
+
permitted?: boolean;
|
|
74
|
+
readOnlyCases?: ReadOnlyCaseItem[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AddCasesRequest {
|
|
78
|
+
cases: CaseItem[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DeleteCasesRequest {
|
|
82
|
+
casesToDelete: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AccountDeletionProgressEvent {
|
|
86
|
+
event: 'start' | 'case-start' | 'case-complete' | 'complete' | 'error';
|
|
87
|
+
totalCases: number;
|
|
88
|
+
completedCases: number;
|
|
89
|
+
currentCaseNumber?: string;
|
|
90
|
+
success?: boolean;
|
|
91
|
+
message?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GoogleOAuthTokenResponse {
|
|
95
|
+
access_token?: string;
|
|
96
|
+
error?: string;
|
|
97
|
+
error_description?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface FirebaseDeleteAccountErrorResponse {
|
|
101
|
+
error?: {
|
|
102
|
+
message?: string;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ResponseHeaders = Record<string, string>;
|