@learncard/lca-api-service 1.3.2 → 1.5.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 +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +13 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Collection } from 'mongodb';
|
|
3
|
+
import mongodb from '@mongo';
|
|
4
|
+
|
|
5
|
+
export const AUTH_SUBJECTS_COLLECTION = 'authsubjects';
|
|
6
|
+
|
|
7
|
+
export interface AuthSubjectAttributes {
|
|
8
|
+
email?: string;
|
|
9
|
+
emailVerified: boolean;
|
|
10
|
+
displayName?: string;
|
|
11
|
+
pictureUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MongoAuthSubjectType extends AuthSubjectAttributes {
|
|
15
|
+
subject: string;
|
|
16
|
+
identityKey: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
lastLoginAt: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const getAuthSubjectsCollection = (): Collection<MongoAuthSubjectType> =>
|
|
22
|
+
mongodb.collection<MongoAuthSubjectType>(AUTH_SUBJECTS_COLLECTION);
|
|
23
|
+
|
|
24
|
+
const MONGO_NAMESPACE_NOT_FOUND = 26;
|
|
25
|
+
const MONGO_INDEX_NOT_FOUND = 27;
|
|
26
|
+
|
|
27
|
+
const mongoErrorCode = (error: unknown): unknown =>
|
|
28
|
+
typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `identityKey` is unique: each sign-in method maps to exactly one subject.
|
|
32
|
+
* `subject` is deliberately NOT unique — linked sign-in methods for the same
|
|
33
|
+
* person (email code + native Google/Apple with the same verified email) share
|
|
34
|
+
* one subject, so Keycloak and the wallet see a single account. Databases
|
|
35
|
+
* created before linking existed carry a unique `subject` index; drop it.
|
|
36
|
+
*/
|
|
37
|
+
export const createAuthSubjectIndexes = async (): Promise<void> => {
|
|
38
|
+
const collection = getAuthSubjectsCollection();
|
|
39
|
+
|
|
40
|
+
const existing = await collection.indexes().catch(error => {
|
|
41
|
+
if (mongoErrorCode(error) === MONGO_NAMESPACE_NOT_FOUND) return [];
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
44
|
+
const legacyUniqueSubject = existing.find(
|
|
45
|
+
index =>
|
|
46
|
+
index.unique === true && index.key?.subject === 1 && Object.keys(index.key).length === 1
|
|
47
|
+
);
|
|
48
|
+
if (legacyUniqueSubject?.name) {
|
|
49
|
+
await collection.dropIndex(legacyUniqueSubject.name).catch(error => {
|
|
50
|
+
if (mongoErrorCode(error) !== MONGO_INDEX_NOT_FOUND) throw error;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await collection.createIndex({ subject: 1 });
|
|
55
|
+
await collection.createIndex({ identityKey: 1 }, { unique: true });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let authSubjectIndexesReady: Promise<void> | undefined;
|
|
59
|
+
|
|
60
|
+
/** Create the indexes once per process (startup in Docker, first login in Lambda). */
|
|
61
|
+
export const ensureAuthSubjectIndexes = (): Promise<void> => {
|
|
62
|
+
if (!authSubjectIndexesReady) {
|
|
63
|
+
authSubjectIndexesReady = createAuthSubjectIndexes().catch(error => {
|
|
64
|
+
authSubjectIndexesReady = undefined;
|
|
65
|
+
console.error('Unable to create AuthSubject indexes:', error);
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return authSubjectIndexesReady;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const MONGO_DUPLICATE_KEY = 11000;
|
|
73
|
+
|
|
74
|
+
const isDuplicateKeyError = (error: unknown): boolean =>
|
|
75
|
+
typeof error === 'object' &&
|
|
76
|
+
error !== null &&
|
|
77
|
+
(error as { code?: unknown }).code === MONGO_DUPLICATE_KEY;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Shared upsert for both `getOrCreateAuthSubject` and `getOrCreateAuthSubjectLinkedTo`.
|
|
81
|
+
*
|
|
82
|
+
* Two concurrent first logins for the same identity can both miss the lookup
|
|
83
|
+
* and race on the insert; the unique `identityKey` index rejects the loser
|
|
84
|
+
* with E11000. Retrying once then finds the winner's document and only runs
|
|
85
|
+
* the `$set` half of the upsert. `subjectForInsert` only takes effect when this
|
|
86
|
+
* `identityKey` doesn't exist yet (`$setOnInsert`) — an existing record always
|
|
87
|
+
* keeps its own `subject`, regardless of what's passed here.
|
|
88
|
+
*/
|
|
89
|
+
const upsertAuthSubject = async (
|
|
90
|
+
identityKey: string,
|
|
91
|
+
subjectForInsert: string,
|
|
92
|
+
attrs: AuthSubjectAttributes
|
|
93
|
+
): Promise<MongoAuthSubjectType> => {
|
|
94
|
+
await ensureAuthSubjectIndexes();
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const fields: Partial<MongoAuthSubjectType> = {
|
|
97
|
+
lastLoginAt: now,
|
|
98
|
+
emailVerified: attrs.emailVerified,
|
|
99
|
+
};
|
|
100
|
+
if (attrs.email !== undefined) fields.email = attrs.email;
|
|
101
|
+
if (attrs.displayName !== undefined) fields.displayName = attrs.displayName;
|
|
102
|
+
if (attrs.pictureUrl !== undefined) fields.pictureUrl = attrs.pictureUrl;
|
|
103
|
+
|
|
104
|
+
const upsert = () =>
|
|
105
|
+
getAuthSubjectsCollection().findOneAndUpdate(
|
|
106
|
+
{ identityKey },
|
|
107
|
+
{
|
|
108
|
+
$setOnInsert: { subject: subjectForInsert, identityKey, createdAt: now },
|
|
109
|
+
$set: fields,
|
|
110
|
+
},
|
|
111
|
+
{ upsert: true, returnDocument: 'after' }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
let record: MongoAuthSubjectType | null;
|
|
115
|
+
try {
|
|
116
|
+
record = await upsert();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!isDuplicateKeyError(error)) throw error;
|
|
119
|
+
record = await upsert();
|
|
120
|
+
}
|
|
121
|
+
if (!record) throw new Error('Unable to persist authentication subject');
|
|
122
|
+
return record;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Persist a random, permanent subject independently of Firebase and UserKey. */
|
|
126
|
+
export const getOrCreateAuthSubject = (
|
|
127
|
+
identityKey: string,
|
|
128
|
+
attrs: AuthSubjectAttributes
|
|
129
|
+
): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, randomUUID(), attrs);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Same as `getOrCreateAuthSubject`, but a brand-new `identityKey` adopts the given
|
|
133
|
+
* `subject` instead of minting a random one — used to unify a native social sign-in
|
|
134
|
+
* onto an already-existing email-code identity for the same verified email. If
|
|
135
|
+
* `identityKey` already has its own record (e.g. this provider/sub was seen before),
|
|
136
|
+
* that record's existing `subject` is kept untouched; it is never overwritten.
|
|
137
|
+
*/
|
|
138
|
+
export const getOrCreateAuthSubjectLinkedTo = (
|
|
139
|
+
identityKey: string,
|
|
140
|
+
subject: string,
|
|
141
|
+
attrs: AuthSubjectAttributes
|
|
142
|
+
): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, subject, attrs);
|
|
143
|
+
|
|
144
|
+
/** Read-only lookup used to find an existing identity to link a new one to. */
|
|
145
|
+
export const findAuthSubjectByIdentityKey = (
|
|
146
|
+
identityKey: string
|
|
147
|
+
): Promise<MongoAuthSubjectType | null> => getAuthSubjectsCollection().findOne({ identityKey });
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from 'crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { Collection } from 'mongodb';
|
|
4
|
+
import mongodb from '@mongo';
|
|
5
|
+
import { environment } from '@environment';
|
|
6
|
+
import {
|
|
7
|
+
AuthProviderMappingValidator,
|
|
8
|
+
EscrowEnvelopeValidator,
|
|
9
|
+
type AuthProviderMapping,
|
|
10
|
+
} from './UserKey';
|
|
11
|
+
|
|
12
|
+
export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
|
|
13
|
+
export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
14
|
+
export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
|
|
15
|
+
export const ESCROW_HOLD_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
// Opaque JSON validated for shape, not signature authenticity. Preserve signed extensions.
|
|
17
|
+
export const EscrowHoldRecordValidator = z
|
|
18
|
+
.object({
|
|
19
|
+
hold: z
|
|
20
|
+
.object({
|
|
21
|
+
holdId: z.string().min(1),
|
|
22
|
+
did: z.string().min(1),
|
|
23
|
+
shareVersion: z.number().int().positive(),
|
|
24
|
+
blobHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
25
|
+
enrollmentEpoch: z.number().int().positive(),
|
|
26
|
+
releasePolicy: z.enum(['hold', 'pin']),
|
|
27
|
+
clientEphemeralPublicKey: z.string().min(1),
|
|
28
|
+
createdLo: z.number().int().nonnegative(),
|
|
29
|
+
createdHi: z.number().int().nonnegative(),
|
|
30
|
+
policyVersion: z.number().int().positive(),
|
|
31
|
+
signature: z.string().min(1),
|
|
32
|
+
})
|
|
33
|
+
.passthrough(),
|
|
34
|
+
holdDurationMs: z.number().int().nonnegative(),
|
|
35
|
+
ledgerSeq: z.number().int().nonnegative(),
|
|
36
|
+
})
|
|
37
|
+
.passthrough();
|
|
38
|
+
export const EscrowHoldValidator = z.object({
|
|
39
|
+
// Required for new holds before escrow production launch. Raw Mongo reads can
|
|
40
|
+
// still return legacy rows without it; cancellation/release must check at runtime.
|
|
41
|
+
holdRecord: EscrowHoldRecordValidator,
|
|
42
|
+
_id: z.string().uuid(),
|
|
43
|
+
authProvider: AuthProviderMappingValidator,
|
|
44
|
+
primaryDid: z.string(),
|
|
45
|
+
shareVersion: z.number().int().positive(),
|
|
46
|
+
status: z.enum(['pending', 'cancelled', 'completed', 'expired']),
|
|
47
|
+
identityProofType: z.enum(['auth-token', 'recovery-session']),
|
|
48
|
+
requestedAt: z.date(),
|
|
49
|
+
releaseAfter: z.date(),
|
|
50
|
+
releasePolicy: z.enum(['hold', 'pin']).default('hold'),
|
|
51
|
+
cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
|
|
52
|
+
cancelledAt: z.date().optional(),
|
|
53
|
+
enclaveCancelPendingAt: z.date().optional(),
|
|
54
|
+
enclaveCancelConfirmedAt: z.date().optional(),
|
|
55
|
+
enclaveCancelAttempts: z.number().int().nonnegative().optional(),
|
|
56
|
+
// Retain ciphertext for revocation even after account removal or re-enrollment.
|
|
57
|
+
enclaveEnvelope: EscrowEnvelopeValidator.optional(),
|
|
58
|
+
cancelledBy: z.enum(['did', 'system', 'link']).optional(),
|
|
59
|
+
completedAt: z.date().optional(),
|
|
60
|
+
clientEphemeralPublicKey: z.string().min(1).max(512),
|
|
61
|
+
resumeTokenHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
62
|
+
cancelTokenHash: z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
65
|
+
.optional(),
|
|
66
|
+
cancelTokenUsedAt: z.date().optional(),
|
|
67
|
+
cancelTokenHashes: z
|
|
68
|
+
.array(z.string().regex(/^[0-9a-f]{64}$/))
|
|
69
|
+
.max(4)
|
|
70
|
+
.optional(),
|
|
71
|
+
startedNotificationPending: z.boolean().optional(),
|
|
72
|
+
reminderClaimedUntil: z.date().optional(),
|
|
73
|
+
startedNotificationAttemptedAt: z.date().optional(),
|
|
74
|
+
reminderAttemptedAt: z.date().optional(),
|
|
75
|
+
notifications: z
|
|
76
|
+
.array(
|
|
77
|
+
z.object({
|
|
78
|
+
kind: z.enum(['started', 'reminder', 'completed', 'cancelled', 'pin-locked']),
|
|
79
|
+
sentAt: z.date(),
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
.default([]),
|
|
83
|
+
requestIp: z.string().optional(),
|
|
84
|
+
/**
|
|
85
|
+
* Resolved tenant id (`ResolvedTenant.id`) at the moment `startRecovery`
|
|
86
|
+
* created this hold. Optional because holds created before this field
|
|
87
|
+
* existed have none; the reminders job (P5.5) falls back to the default
|
|
88
|
+
* tenant via the email-templates registry when absent.
|
|
89
|
+
*/
|
|
90
|
+
tenantId: z.string().optional(),
|
|
91
|
+
createdAt: z.date(),
|
|
92
|
+
updatedAt: z.date(),
|
|
93
|
+
});
|
|
94
|
+
export type EscrowHold = z.infer<typeof EscrowHoldValidator>;
|
|
95
|
+
export type CreateEscrowHoldInput = Omit<
|
|
96
|
+
z.input<typeof EscrowHoldValidator>,
|
|
97
|
+
| '_id'
|
|
98
|
+
| 'status'
|
|
99
|
+
| 'createdAt'
|
|
100
|
+
| 'updatedAt'
|
|
101
|
+
| 'notifications'
|
|
102
|
+
| 'cancelledAt'
|
|
103
|
+
| 'cancelledBy'
|
|
104
|
+
| 'completedAt'
|
|
105
|
+
| 'cancelReason'
|
|
106
|
+
| 'cancelTokenUsedAt'
|
|
107
|
+
| 'enclaveCancelPendingAt'
|
|
108
|
+
| 'enclaveCancelConfirmedAt'
|
|
109
|
+
| 'enclaveCancelAttempts'
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
export const getEscrowHoldsCollection = (): Collection<EscrowHold> =>
|
|
113
|
+
mongodb.collection<EscrowHold>(ESCROW_HOLDS_COLLECTION);
|
|
114
|
+
|
|
115
|
+
const runIndexMigrationOperation = async (operation: () => Promise<unknown>): Promise<void> => {
|
|
116
|
+
try {
|
|
117
|
+
await operation();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const code =
|
|
120
|
+
typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
|
121
|
+
// Unlike lookup indexes, uniqueness is a security invariant: fail closed on duplicates.
|
|
122
|
+
if (code === 27 || code === 85 || code === 86) {
|
|
123
|
+
const indexes = await getEscrowHoldsCollection().listIndexes().toArray();
|
|
124
|
+
if (
|
|
125
|
+
indexes.some(
|
|
126
|
+
index =>
|
|
127
|
+
index.unique &&
|
|
128
|
+
Object.keys(index.key ?? {}).length === 3 &&
|
|
129
|
+
index.key?.['authProvider.type'] === 1 &&
|
|
130
|
+
index.key?.['authProvider.id'] === 1 &&
|
|
131
|
+
index.key?.releasePolicy === 1 &&
|
|
132
|
+
Object.keys(index.partialFilterExpression ?? {}).length === 1 &&
|
|
133
|
+
index.partialFilterExpression?.status === 'pending'
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error('Escrow hold index initialization failed', { cause: error });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const createEscrowHoldsIndexes = async (): Promise<void> => {
|
|
143
|
+
const collection = getEscrowHoldsCollection();
|
|
144
|
+
// Normalize legacy rows while the old, stricter index still protects them.
|
|
145
|
+
await collection.updateMany(
|
|
146
|
+
{ releasePolicy: { $exists: false } },
|
|
147
|
+
{ $set: { releasePolicy: 'hold' } }
|
|
148
|
+
);
|
|
149
|
+
await runIndexMigrationOperation(() =>
|
|
150
|
+
collection.createIndex(
|
|
151
|
+
{ 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
|
|
152
|
+
{
|
|
153
|
+
name: 'pending_escrow_identity_policy_unique',
|
|
154
|
+
unique: true,
|
|
155
|
+
partialFilterExpression: { status: 'pending' },
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
// Install the replacement before dropping the old index: no uniqueness gap.
|
|
160
|
+
try {
|
|
161
|
+
await collection.dropIndex('pending_escrow_identity_unique');
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 27))
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
await runIndexMigrationOperation(() =>
|
|
167
|
+
collection.createIndex({ 'authProvider.type': 1, 'authProvider.id': 1, status: 1 })
|
|
168
|
+
);
|
|
169
|
+
await runIndexMigrationOperation(() => collection.createIndex({ releaseAfter: 1 }));
|
|
170
|
+
// Backs findEscrowHoldsDueForReminder's { status:'pending', releaseAfter: {$gt,$lte} } scan.
|
|
171
|
+
await collection.createIndex(
|
|
172
|
+
{ status: 1, releaseAfter: 1 },
|
|
173
|
+
{ name: 'pending_escrow_hold_reminder_lookup' }
|
|
174
|
+
);
|
|
175
|
+
await collection.createIndex(
|
|
176
|
+
{ status: 1, enclaveCancelConfirmedAt: 1, enclaveCancelAttempts: 1, releaseAfter: 1 },
|
|
177
|
+
{ name: 'escrow_enclave_cancel_retry_lookup' }
|
|
178
|
+
);
|
|
179
|
+
await collection.createIndex(
|
|
180
|
+
{ primaryDid: 1, status: 1, enclaveCancelConfirmedAt: 1 },
|
|
181
|
+
{ name: 'escrow_enclave_cancel_identity_lookup' }
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const createEscrowHold = async (input: CreateEscrowHoldInput): Promise<EscrowHold> => {
|
|
186
|
+
const now = new Date();
|
|
187
|
+
const parsed = EscrowHoldValidator.safeParse({
|
|
188
|
+
...input,
|
|
189
|
+
_id: input.holdRecord?.hold?.holdId,
|
|
190
|
+
status: 'pending',
|
|
191
|
+
notifications: [],
|
|
192
|
+
createdAt: now,
|
|
193
|
+
updatedAt: now,
|
|
194
|
+
});
|
|
195
|
+
if (!parsed.success) throw new Error('Invalid escrow hold');
|
|
196
|
+
const hold = parsed.data;
|
|
197
|
+
await getEscrowHoldsCollection().insertOne(hold);
|
|
198
|
+
return hold;
|
|
199
|
+
};
|
|
200
|
+
export const findPendingEscrowHoldByAuthProvider = async (
|
|
201
|
+
authProvider: AuthProviderMapping,
|
|
202
|
+
releasePolicy?: EscrowHold['releasePolicy']
|
|
203
|
+
): Promise<EscrowHold | null> =>
|
|
204
|
+
getEscrowHoldsCollection().findOne({
|
|
205
|
+
'authProvider.type': authProvider.type,
|
|
206
|
+
'authProvider.id': authProvider.id,
|
|
207
|
+
status: 'pending',
|
|
208
|
+
...(releasePolicy === 'hold'
|
|
209
|
+
? { $or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }] }
|
|
210
|
+
: releasePolicy
|
|
211
|
+
? { releasePolicy }
|
|
212
|
+
: {}),
|
|
213
|
+
});
|
|
214
|
+
export const findEscrowHoldById = async (id: string): Promise<EscrowHold | null> =>
|
|
215
|
+
getEscrowHoldsCollection().findOne({ _id: id });
|
|
216
|
+
/** Fail closed across linked providers and enrollment changes until revocation is confirmed. */
|
|
217
|
+
export const hasUnconfirmedEscrowCancellation = async (primaryDid: string): Promise<boolean> =>
|
|
218
|
+
(await getEscrowHoldsCollection().countDocuments(
|
|
219
|
+
{
|
|
220
|
+
primaryDid,
|
|
221
|
+
status: 'cancelled',
|
|
222
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
223
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
224
|
+
},
|
|
225
|
+
{ limit: 1 }
|
|
226
|
+
)) > 0;
|
|
227
|
+
|
|
228
|
+
export const findEscrowHoldsPendingEnclaveCancellation = async (
|
|
229
|
+
limit: number
|
|
230
|
+
): Promise<EscrowHold[]> =>
|
|
231
|
+
getEscrowHoldsCollection()
|
|
232
|
+
.find({
|
|
233
|
+
status: 'cancelled',
|
|
234
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
235
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
236
|
+
})
|
|
237
|
+
.sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
|
|
238
|
+
.limit(limit)
|
|
239
|
+
.toArray();
|
|
240
|
+
|
|
241
|
+
export const countEscrowCancellationsNearRelease = async (now: Date): Promise<number> =>
|
|
242
|
+
getEscrowHoldsCollection().countDocuments({
|
|
243
|
+
status: 'cancelled',
|
|
244
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
245
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
246
|
+
releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export const recordEscrowEnclaveCancelAttempt = async (id: string): Promise<boolean> => {
|
|
250
|
+
const result = await getEscrowHoldsCollection().updateOne(
|
|
251
|
+
{
|
|
252
|
+
_id: id,
|
|
253
|
+
status: 'cancelled',
|
|
254
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
255
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
256
|
+
},
|
|
257
|
+
{ $inc: { enclaveCancelAttempts: 1 }, $set: { updatedAt: new Date() } }
|
|
258
|
+
);
|
|
259
|
+
return result.modifiedCount > 0;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void> => {
|
|
263
|
+
const now = new Date();
|
|
264
|
+
await getEscrowHoldsCollection().updateOne(
|
|
265
|
+
{
|
|
266
|
+
_id: id,
|
|
267
|
+
status: 'cancelled',
|
|
268
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
269
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
$set: { enclaveCancelConfirmedAt: now, updatedAt: now },
|
|
273
|
+
$unset: { enclaveCancelPendingAt: '', enclaveEnvelope: '' },
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const cancelEscrowHold = async (
|
|
279
|
+
id: string,
|
|
280
|
+
cancelledBy: 'did' | 'system',
|
|
281
|
+
cancelReason?: EscrowHold['cancelReason']
|
|
282
|
+
): Promise<EscrowHold | null> => {
|
|
283
|
+
const now = new Date();
|
|
284
|
+
return getEscrowHoldsCollection().findOneAndUpdate(
|
|
285
|
+
{ _id: id, status: 'pending' },
|
|
286
|
+
{
|
|
287
|
+
$set: {
|
|
288
|
+
status: 'cancelled',
|
|
289
|
+
cancelledBy,
|
|
290
|
+
cancelledAt: now,
|
|
291
|
+
enclaveCancelPendingAt: now,
|
|
292
|
+
enclaveCancelAttempts: 0,
|
|
293
|
+
updatedAt: now,
|
|
294
|
+
...(cancelReason ? { cancelReason } : {}),
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
{ returnDocument: 'after' }
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Cancels a hold via its single-use cancel-link token. The status transition
|
|
302
|
+
* and burning the token happen in one conditional update — matching on the
|
|
303
|
+
* exact `cancelTokenHash` plus `cancelTokenUsedAt` being unset — so two
|
|
304
|
+
* concurrent clicks on the same link can never both succeed; only the first
|
|
305
|
+
* `findOneAndUpdate` observes the pre-burn state and wins the CAS.
|
|
306
|
+
*/
|
|
307
|
+
export const cancelEscrowHoldByCancelToken = async (
|
|
308
|
+
id: string,
|
|
309
|
+
cancelTokenHash: string
|
|
310
|
+
): Promise<EscrowHold | null> => {
|
|
311
|
+
const now = new Date();
|
|
312
|
+
return getEscrowHoldsCollection().findOneAndUpdate(
|
|
313
|
+
{
|
|
314
|
+
_id: id,
|
|
315
|
+
status: 'pending',
|
|
316
|
+
$or: [{ cancelTokenHash }, { cancelTokenHashes: cancelTokenHash }],
|
|
317
|
+
cancelTokenUsedAt: { $exists: false },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
$set: {
|
|
321
|
+
status: 'cancelled',
|
|
322
|
+
cancelledBy: 'link',
|
|
323
|
+
cancelledAt: now,
|
|
324
|
+
enclaveCancelPendingAt: now,
|
|
325
|
+
enclaveCancelAttempts: 0,
|
|
326
|
+
cancelTokenUsedAt: now,
|
|
327
|
+
updatedAt: now,
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{ returnDocument: 'after' }
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
|
|
334
|
+
const now = new Date();
|
|
335
|
+
return getEscrowHoldsCollection().findOneAndUpdate(
|
|
336
|
+
{
|
|
337
|
+
_id: id,
|
|
338
|
+
status: 'pending',
|
|
339
|
+
releaseAfter: { $gte: new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS) },
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
$set: { status: 'completed', completedAt: now, updatedAt: now },
|
|
343
|
+
},
|
|
344
|
+
{ returnDocument: 'after' }
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
/** Whether escrow material at this share version has already been released. */
|
|
348
|
+
export const hasCompletedEscrowHoldForVersion = async (
|
|
349
|
+
authProvider: AuthProviderMapping,
|
|
350
|
+
shareVersion: number
|
|
351
|
+
): Promise<boolean> =>
|
|
352
|
+
(await getEscrowHoldsCollection().countDocuments(
|
|
353
|
+
{
|
|
354
|
+
'authProvider.type': authProvider.type,
|
|
355
|
+
'authProvider.id': authProvider.id,
|
|
356
|
+
shareVersion,
|
|
357
|
+
status: 'completed',
|
|
358
|
+
},
|
|
359
|
+
{ limit: 1 }
|
|
360
|
+
)) > 0;
|
|
361
|
+
export const expireStaleEscrowHolds = async (now: Date): Promise<number> => {
|
|
362
|
+
const result = await getEscrowHoldsCollection().updateMany(
|
|
363
|
+
{
|
|
364
|
+
status: 'pending',
|
|
365
|
+
releaseAfter: { $lt: new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS) },
|
|
366
|
+
},
|
|
367
|
+
{ $set: { status: 'expired', updatedAt: now } }
|
|
368
|
+
);
|
|
369
|
+
return result.modifiedCount;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/** Only rewrite the completed row claimed by this request; never reopen a burned hold. */
|
|
373
|
+
export const markClaimedEscrowHoldFailed = async (
|
|
374
|
+
id: string,
|
|
375
|
+
reason: 'pin-mismatch' | 'pin-locked' | 'release-failed',
|
|
376
|
+
completedAt: Date
|
|
377
|
+
): Promise<void> => {
|
|
378
|
+
const now = new Date();
|
|
379
|
+
await getEscrowHoldsCollection().updateOne(
|
|
380
|
+
{ _id: id, status: 'completed', completedAt },
|
|
381
|
+
{
|
|
382
|
+
$set: {
|
|
383
|
+
status: 'cancelled',
|
|
384
|
+
cancelledBy: 'system',
|
|
385
|
+
cancelReason: reason,
|
|
386
|
+
cancelledAt: now,
|
|
387
|
+
enclaveCancelPendingAt: now,
|
|
388
|
+
enclaveCancelAttempts: 0,
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
};
|
|
394
|
+
/** Reopen only our own failed release claim, never another request's terminal row. */
|
|
395
|
+
export const retryClaimedEscrowHold = async (id: string, completedAt: Date): Promise<void> => {
|
|
396
|
+
try {
|
|
397
|
+
await getEscrowHoldsCollection().updateOne(
|
|
398
|
+
{ _id: id, status: 'completed', completedAt },
|
|
399
|
+
{ $set: { status: 'pending', updatedAt: new Date() }, $unset: { completedAt: '' } }
|
|
400
|
+
);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
// A replacement hold may have won the pending unique index while we released.
|
|
403
|
+
if (!(
|
|
404
|
+
typeof error === 'object' &&
|
|
405
|
+
error !== null &&
|
|
406
|
+
'code' in error &&
|
|
407
|
+
error.code === 11000
|
|
408
|
+
))
|
|
409
|
+
throw error;
|
|
410
|
+
await markClaimedEscrowHoldFailed(id, 'release-failed', completedAt);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const findEscrowHoldsPendingStartNotification = async (
|
|
415
|
+
limit: number
|
|
416
|
+
): Promise<EscrowHold[]> =>
|
|
417
|
+
getEscrowHoldsCollection()
|
|
418
|
+
.find({ status: 'pending', startedNotificationPending: true })
|
|
419
|
+
.sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
|
|
420
|
+
.limit(limit)
|
|
421
|
+
.toArray();
|
|
422
|
+
|
|
423
|
+
export const recordEscrowStartNotificationAttempt = async (
|
|
424
|
+
id: string,
|
|
425
|
+
now: Date
|
|
426
|
+
): Promise<void> => {
|
|
427
|
+
await getEscrowHoldsCollection().updateOne(
|
|
428
|
+
{ _id: id, status: 'pending', startedNotificationPending: true },
|
|
429
|
+
{ $set: { startedNotificationAttemptedAt: now } }
|
|
430
|
+
);
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* Pending, hold-policy holds whose waiting period ends within the next 24h
|
|
434
|
+
* (and hasn't ended yet) that have never been reminded. `pin`-policy holds
|
|
435
|
+
* release immediately at creation (`releaseAfter` set to the creation time,
|
|
436
|
+
* not a future date — see `startRecovery`), so `releaseAfter > now` already
|
|
437
|
+
* excludes them in practice within a few ms of job execution; the explicit
|
|
438
|
+
* releasePolicy filter documents that exclusion instead of relying on
|
|
439
|
+
* timing alone. Legacy rows predating `releasePolicy` are treated as `hold`,
|
|
440
|
+
* matching `findPendingEscrowHoldByAuthProvider`'s own `$or`.
|
|
441
|
+
*/
|
|
442
|
+
export const findEscrowHoldsDueForReminder = async (
|
|
443
|
+
now: Date,
|
|
444
|
+
limit: number
|
|
445
|
+
): Promise<EscrowHold[]> =>
|
|
446
|
+
getEscrowHoldsCollection()
|
|
447
|
+
.find({
|
|
448
|
+
status: 'pending',
|
|
449
|
+
$or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }],
|
|
450
|
+
releaseAfter: {
|
|
451
|
+
$gt: now,
|
|
452
|
+
$lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS),
|
|
453
|
+
},
|
|
454
|
+
'notifications.kind': { $ne: 'reminder' },
|
|
455
|
+
})
|
|
456
|
+
.sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
|
|
457
|
+
.limit(limit)
|
|
458
|
+
.toArray();
|
|
459
|
+
|
|
460
|
+
/** A short delivery lease avoids concurrent sends without recording false success.
|
|
461
|
+
* A crashed Lambda's lease expires before the next hourly run. */
|
|
462
|
+
export const claimEscrowHoldForReminder = async (
|
|
463
|
+
id: string,
|
|
464
|
+
now: Date
|
|
465
|
+
): Promise<EscrowHold | null> =>
|
|
466
|
+
getEscrowHoldsCollection().findOneAndUpdate(
|
|
467
|
+
{
|
|
468
|
+
_id: id,
|
|
469
|
+
status: 'pending',
|
|
470
|
+
'notifications.kind': { $ne: 'reminder' },
|
|
471
|
+
$or: [
|
|
472
|
+
{ reminderClaimedUntil: { $exists: false } },
|
|
473
|
+
{ reminderClaimedUntil: { $lte: now } },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
$set: {
|
|
478
|
+
reminderClaimedUntil: new Date(now.getTime() + 5 * 60_000),
|
|
479
|
+
reminderAttemptedAt: now,
|
|
480
|
+
updatedAt: now,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{ returnDocument: 'after' }
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
export const releaseEscrowReminderClaim = async (hold: EscrowHold): Promise<void> => {
|
|
487
|
+
await getEscrowHoldsCollection().updateOne(
|
|
488
|
+
{ _id: hold._id, reminderClaimedUntil: hold.reminderClaimedUntil },
|
|
489
|
+
{ $unset: { reminderClaimedUntil: '' } }
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
export const hashEscrowResumeToken = (token: string): string =>
|
|
494
|
+
createHash('sha256').update(token).digest('hex');
|
|
495
|
+
export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
|
|
496
|
+
|
|
497
|
+
/** Appends a delivery record; a missing holdId is a no-op rather than a throw. */
|
|
498
|
+
export const recordEscrowHoldNotification = async (
|
|
499
|
+
holdId: string,
|
|
500
|
+
kind: EscrowHold['notifications'][number]['kind']
|
|
501
|
+
): Promise<void> => {
|
|
502
|
+
const now = new Date();
|
|
503
|
+
await getEscrowHoldsCollection().updateOne(
|
|
504
|
+
{ _id: holdId, 'notifications.kind': { $ne: kind } },
|
|
505
|
+
{
|
|
506
|
+
$push: { notifications: { kind, sentAt: now } },
|
|
507
|
+
$set: {
|
|
508
|
+
updatedAt: now,
|
|
509
|
+
...(kind === 'started' ? { startedNotificationPending: false } : {}),
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Adds a retry-stable, domain-separated token without revoking earlier links.
|
|
517
|
+
* HMAC allows delivery retries without storing plaintext or exhausting the
|
|
518
|
+
* bounded hash set. The original random hash remains valid until terminal.
|
|
519
|
+
* At most four additional hashes (plus the legacy original) are accepted;
|
|
520
|
+
* never evict a previously delivered link, even after a server seed rotation.
|
|
521
|
+
*/
|
|
522
|
+
export const rotateEscrowCancelToken = async (holdId: string): Promise<string | null> => {
|
|
523
|
+
const token = createHmac('sha256', environment.SEED)
|
|
524
|
+
.update(`escrow-cancel-notification:v1:${holdId}`)
|
|
525
|
+
.digest('hex');
|
|
526
|
+
const cancelTokenHash = createHash('sha256').update(token).digest('hex');
|
|
527
|
+
const now = new Date();
|
|
528
|
+
const result = await getEscrowHoldsCollection().findOneAndUpdate(
|
|
529
|
+
{
|
|
530
|
+
_id: holdId,
|
|
531
|
+
status: 'pending',
|
|
532
|
+
$or: [
|
|
533
|
+
{ cancelTokenHashes: cancelTokenHash },
|
|
534
|
+
{ 'cancelTokenHashes.3': { $exists: false } },
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
{ $addToSet: { cancelTokenHashes: cancelTokenHash }, $set: { updatedAt: now } },
|
|
538
|
+
{ returnDocument: 'after' }
|
|
539
|
+
);
|
|
540
|
+
return result ? token : null;
|
|
541
|
+
};
|