@learncard/lca-api-service 1.5.2 → 1.5.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/CHANGELOG.md +18 -0
- package/dist/app.d.ts +19 -0
- package/dist/index.js +2 -2
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +19 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +17 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +10 -10
- package/src/routes/ai.test.ts +103 -0
- package/src/routes/ai.ts +2 -1
- package/src/routes/escrow.ts +15 -2
- package/src/services/escrow-enclave/logFailure.test.ts +64 -0
- package/src/services/escrow-enclave/logFailure.ts +29 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
- package/src/services/escrow-enclave/types.ts +26 -2
- package/test/keys-escrow.spec.ts +65 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/routes/escrow.d.ts
CHANGED
|
@@ -251,6 +251,25 @@ export declare const escrowRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
251
251
|
} | undefined;
|
|
252
252
|
requiresConfirmation?: boolean | undefined;
|
|
253
253
|
pinSalt?: string | undefined;
|
|
254
|
+
codeSent?: boolean | undefined;
|
|
255
|
+
delivery?: {
|
|
256
|
+
codeSent: boolean;
|
|
257
|
+
proof: {
|
|
258
|
+
version: 1;
|
|
259
|
+
keyId: string;
|
|
260
|
+
measurement: number[];
|
|
261
|
+
signature: string;
|
|
262
|
+
kind: "commit";
|
|
263
|
+
payload: string;
|
|
264
|
+
} | {
|
|
265
|
+
version: 1;
|
|
266
|
+
keyId: string;
|
|
267
|
+
measurement: number[];
|
|
268
|
+
payload: string;
|
|
269
|
+
signature: string;
|
|
270
|
+
kind: "snapshot";
|
|
271
|
+
};
|
|
272
|
+
} | undefined;
|
|
254
273
|
};
|
|
255
274
|
meta: import("trpc-to-openapi").OpenApiMeta;
|
|
256
275
|
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const logEscrowFailure: (error: unknown) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -117,6 +117,17 @@ export interface EscrowEnclave {
|
|
|
117
117
|
releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
|
|
118
118
|
createHold(input: EnclaveCreateHoldInput): Promise<{
|
|
119
119
|
holdRecord: EscrowHoldRecord;
|
|
120
|
+
delivery?: {
|
|
121
|
+
codeSent: boolean;
|
|
122
|
+
proof: AuthorityHoldReceipt;
|
|
123
|
+
};
|
|
124
|
+
}>;
|
|
125
|
+
getHoldState?(input: {
|
|
126
|
+
expectedDid: string;
|
|
127
|
+
hold: EscrowHoldRecord;
|
|
128
|
+
nonce: string;
|
|
129
|
+
}): Promise<{
|
|
130
|
+
proof: AuthorityRecord;
|
|
120
131
|
}>;
|
|
121
132
|
cancelHold(input: CancelHoldRequest): Promise<void | {
|
|
122
133
|
receipt: AuthorityRecord;
|
|
@@ -136,6 +147,10 @@ export interface EscrowEnclave {
|
|
|
136
147
|
export declare class EscrowPolicyError extends Error {
|
|
137
148
|
constructor();
|
|
138
149
|
}
|
|
150
|
+
export declare class EscrowCodeError extends Error {
|
|
151
|
+
readonly reason: 'codeMismatch' | 'codeExpired' | 'codeLocked';
|
|
152
|
+
constructor(reason: 'codeMismatch' | 'codeExpired' | 'codeLocked');
|
|
153
|
+
}
|
|
139
154
|
export declare class EscrowPinMismatchError extends Error {
|
|
140
155
|
constructor();
|
|
141
156
|
}
|
|
@@ -143,5 +158,6 @@ export declare class EscrowBlobError extends Error {
|
|
|
143
158
|
constructor();
|
|
144
159
|
}
|
|
145
160
|
export declare class EscrowUnavailableError extends Error {
|
|
146
|
-
|
|
161
|
+
readonly reason: 'unavailable' | 'ledger' | 'time' | 'transport' | 'invalid_response' | 'invalid_hold' | 'http_refusal' | 'rate_limit';
|
|
162
|
+
constructor(reason?: 'unavailable' | 'ledger' | 'time' | 'transport' | 'invalid_response' | 'invalid_hold' | 'http_refusal' | 'rate_limit');
|
|
147
163
|
}
|
package/esbuildPlugins.cjs
CHANGED
|
@@ -2,6 +2,7 @@ const path = require('node:path');
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
|
|
4
4
|
const { sentryEsbuildPlugin } = require('@sentry/esbuild-plugin');
|
|
5
|
+
const { withSentryRateLimitRetry } = require('../../../scripts/sentry-rate-limit-retry.cjs');
|
|
5
6
|
|
|
6
7
|
const forceCjsSodiumWrapperPlugin = {
|
|
7
8
|
name: 'force-cjs-sodium-wrapper',
|
|
@@ -40,10 +41,12 @@ const copyDidkitWasmPlugin = {
|
|
|
40
41
|
module.exports = [
|
|
41
42
|
forceCjsSodiumWrapperPlugin,
|
|
42
43
|
copyDidkitWasmPlugin,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
withSentryRateLimitRetry(
|
|
45
|
+
sentryEsbuildPlugin({
|
|
46
|
+
telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
|
|
47
|
+
authToken: process.env.SENTRY_AUTH_TOKEN,
|
|
48
|
+
org: process.env.SENTRY_ORG,
|
|
49
|
+
project: process.env.SENTRY_PROJECT,
|
|
50
|
+
})
|
|
51
|
+
),
|
|
49
52
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/lca-api-service",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
@@ -63,21 +63,21 @@
|
|
|
63
63
|
"@fastify/formbody": "^8.0.2",
|
|
64
64
|
"@fastify/rate-limit": "^10.2.0",
|
|
65
65
|
"@fastify/static": "^10.1.1",
|
|
66
|
-
"@learncard/core": "9.4.
|
|
67
|
-
"@learncard/did-web-plugin": "1.1.
|
|
68
|
-
"@learncard/didkit-plugin": "1.10.
|
|
69
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
66
|
+
"@learncard/core": "9.4.40",
|
|
67
|
+
"@learncard/did-web-plugin": "1.1.40",
|
|
68
|
+
"@learncard/didkit-plugin": "1.10.5",
|
|
69
|
+
"@learncard/didkit-plugin-node": "0.3.5",
|
|
70
70
|
"@learncard/email-templates": "1.2.1",
|
|
71
|
-
"@learncard/helpers": "1.
|
|
72
|
-
"@learncard/init": "2.5.
|
|
73
|
-
"@learncard/types": "5.23.
|
|
74
|
-
"@learncard/sss-key-manager": "0.2.
|
|
71
|
+
"@learncard/helpers": "1.7.0",
|
|
72
|
+
"@learncard/init": "2.5.5",
|
|
73
|
+
"@learncard/types": "5.23.2",
|
|
74
|
+
"@learncard/sss-key-manager": "0.2.3",
|
|
75
75
|
"@sentry/serverless": "^7.61.0",
|
|
76
76
|
"@trpc/server": "11.8.0",
|
|
77
77
|
"@types/jsonwebtoken": "^9.0.8",
|
|
78
78
|
"@types/lodash": "^4.14.191",
|
|
79
79
|
"@types/node-forge": "^1.3.11",
|
|
80
|
-
"axios": "^0.
|
|
80
|
+
"axios": "^0.34.0",
|
|
81
81
|
"dag-jose-utils": "2.0.0",
|
|
82
82
|
"did-jwt": "5.9.0",
|
|
83
83
|
"dotenv": "^16.0.3",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SKILL_ICON_MAX_CHARACTERS_PER_REQUEST,
|
|
4
|
+
SKILL_ICON_MAX_NAMES_PER_REQUEST,
|
|
5
|
+
} from '@learncard/types';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
create: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@environment', () => ({ environment: { OPENAI_API_KEY: 'test-key' } }));
|
|
12
|
+
vi.mock('openai', () => ({
|
|
13
|
+
default: class {
|
|
14
|
+
chat = { completions: { create: mocks.create } };
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('filestack-js', () => ({ init: () => ({}) }));
|
|
18
|
+
vi.mock('@routes', async () => {
|
|
19
|
+
const { initTRPC } = await import('@trpc/server');
|
|
20
|
+
const t = initTRPC.create();
|
|
21
|
+
return { t, didAndChallengeRoute: t.procedure };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { aiRouter } from './ai';
|
|
25
|
+
|
|
26
|
+
const caller = aiRouter.createCaller({ user: { did: 'did:example:123' } } as never);
|
|
27
|
+
|
|
28
|
+
const respondWithIcons = (): void => {
|
|
29
|
+
mocks.create.mockImplementation(async ({ messages }: { messages: { content: string }[] }) => {
|
|
30
|
+
const names = [...messages[1]!.content.matchAll(/^- (.*)$/gm)].map(match => match[1]);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
choices: [
|
|
34
|
+
{
|
|
35
|
+
message: {
|
|
36
|
+
content: JSON.stringify({
|
|
37
|
+
items: names.map(name => ({ name, icon: '🎯' })),
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('generateSkillIcons input validation', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
respondWithIcons();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('accepts a batch at both limits', async () => {
|
|
53
|
+
const names = Array.from({ length: SKILL_ICON_MAX_NAMES_PER_REQUEST }, (_, i) =>
|
|
54
|
+
`${i}`.padStart(
|
|
55
|
+
SKILL_ICON_MAX_CHARACTERS_PER_REQUEST / SKILL_ICON_MAX_NAMES_PER_REQUEST,
|
|
56
|
+
'x'
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const result = await caller.generateSkillIcons({ names });
|
|
61
|
+
|
|
62
|
+
expect(Object.keys(result)).toHaveLength(SKILL_ICON_MAX_NAMES_PER_REQUEST);
|
|
63
|
+
expect(mocks.create).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects too many names with BAD_REQUEST before calling the model', async () => {
|
|
67
|
+
const names = Array.from(
|
|
68
|
+
{ length: SKILL_ICON_MAX_NAMES_PER_REQUEST + 1 },
|
|
69
|
+
(_, i) => `s${i}`
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
|
|
73
|
+
code: 'BAD_REQUEST',
|
|
74
|
+
});
|
|
75
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects names totalling more than the character limit with BAD_REQUEST', async () => {
|
|
79
|
+
const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST), 'b'];
|
|
80
|
+
|
|
81
|
+
await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
|
|
82
|
+
code: 'BAD_REQUEST',
|
|
83
|
+
});
|
|
84
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects a single name over the character limit', async () => {
|
|
88
|
+
const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST + 1)];
|
|
89
|
+
|
|
90
|
+
await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
|
|
91
|
+
code: 'BAD_REQUEST',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('still rejects empty lists and empty names', async () => {
|
|
96
|
+
await expect(caller.generateSkillIcons({ names: [] })).rejects.toMatchObject({
|
|
97
|
+
code: 'BAD_REQUEST',
|
|
98
|
+
});
|
|
99
|
+
await expect(caller.generateSkillIcons({ names: [''] })).rejects.toMatchObject({
|
|
100
|
+
code: 'BAD_REQUEST',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/routes/ai.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
|
|
5
5
|
|
|
6
6
|
import { t, didAndChallengeRoute } from '@routes';
|
|
7
7
|
import { TRPCError } from '@trpc/server';
|
|
8
|
+
import { GenerateSkillIconsInputValidator } from '@learncard/types';
|
|
8
9
|
|
|
9
10
|
import * as filestack from 'filestack-js';
|
|
10
11
|
import { BoostSkillHierarchy, BoostSkillHierarchyValidator } from 'types/skills';
|
|
@@ -204,7 +205,7 @@ export const aiRouter = t.router({
|
|
|
204
205
|
'Given a list of skill names, returns a mapping of each name to a single unicode emoji that best represents it. No skin tones, flags, or multi-character sequences.',
|
|
205
206
|
},
|
|
206
207
|
})
|
|
207
|
-
.input(
|
|
208
|
+
.input(GenerateSkillIconsInputValidator)
|
|
208
209
|
.output(IconMapValidator)
|
|
209
210
|
.query(async ({ input, ctx }) => {
|
|
210
211
|
const {
|
package/src/routes/escrow.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual, randomUUID, randomBytes } from 'crypto';
|
|
2
|
+
import { logEscrowFailure } from '../services/escrow-enclave/logFailure';
|
|
2
3
|
import {
|
|
3
4
|
AuthorityCommitValidator,
|
|
4
5
|
AuthorityHoldReceiptValidator,
|
|
@@ -79,6 +80,7 @@ import {
|
|
|
79
80
|
getEnclaveAttestationIdentity,
|
|
80
81
|
notifyEscrowHoldEvent,
|
|
81
82
|
EscrowPolicyError,
|
|
83
|
+
EscrowCodeError,
|
|
82
84
|
EscrowBlobError,
|
|
83
85
|
EscrowUnavailableError,
|
|
84
86
|
EscrowPinMismatchError,
|
|
@@ -247,6 +249,10 @@ const enclaveOperation = async <T>(
|
|
|
247
249
|
try {
|
|
248
250
|
return await operation();
|
|
249
251
|
} catch (error) {
|
|
252
|
+
logEscrowFailure(error);
|
|
253
|
+
if (error instanceof EscrowCodeError) {
|
|
254
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: error.reason });
|
|
255
|
+
}
|
|
250
256
|
if (error instanceof EscrowUnavailableError) {
|
|
251
257
|
throw new TRPCError({
|
|
252
258
|
code: 'PRECONDITION_FAILED',
|
|
@@ -760,6 +766,10 @@ export const escrowRouter = t.router({
|
|
|
760
766
|
status: z.literal('pending'),
|
|
761
767
|
resumeToken: z.string().nullable(),
|
|
762
768
|
pinSalt: EscrowPinSaltValidator.optional(),
|
|
769
|
+
codeSent: z.boolean().optional(),
|
|
770
|
+
delivery: z
|
|
771
|
+
.object({ codeSent: z.boolean(), proof: AuthorityHoldReceiptValidator })
|
|
772
|
+
.optional(),
|
|
763
773
|
})
|
|
764
774
|
)
|
|
765
775
|
.mutation(async ({ input, ctx }) => {
|
|
@@ -872,7 +882,7 @@ export const escrowRouter = t.router({
|
|
|
872
882
|
// suppressed entirely rather than racing/duplicating it.
|
|
873
883
|
const isAuthorityManagedHold =
|
|
874
884
|
input.releasePolicy === 'hold' && Boolean(getEscrowEnclave().confirmHold);
|
|
875
|
-
const { holdRecord } = await enclaveOperation(() =>
|
|
885
|
+
const { holdRecord, delivery } = await enclaveOperation(() =>
|
|
876
886
|
getEscrowEnclave().createHold({
|
|
877
887
|
envelope: userKey.escrowBlob!.envelope,
|
|
878
888
|
holdId: randomUUID(),
|
|
@@ -959,6 +969,7 @@ export const escrowRouter = t.router({
|
|
|
959
969
|
...serializeHold(hold),
|
|
960
970
|
status: 'pending' as const,
|
|
961
971
|
resumeToken,
|
|
972
|
+
...(delivery ? { delivery, codeSent: delivery.codeSent } : {}),
|
|
962
973
|
...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
|
|
963
974
|
};
|
|
964
975
|
}),
|
|
@@ -1302,8 +1313,10 @@ export const escrowRouter = t.router({
|
|
|
1302
1313
|
};
|
|
1303
1314
|
} catch (error) {
|
|
1304
1315
|
// Keep the typed mismatch inside the wrapper; never expose enclave internals.
|
|
1305
|
-
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
|
|
1316
|
+
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError) {
|
|
1317
|
+
logEscrowFailure(error);
|
|
1306
1318
|
return { mismatch: true as const };
|
|
1319
|
+
}
|
|
1307
1320
|
if (reserved && hold.holdRecord.hold.policyVersion !== 2)
|
|
1308
1321
|
await refundEscrowPinAttempt(
|
|
1309
1322
|
hold.authProvider,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createRemoteEnclave } from './remoteEnclave';
|
|
3
|
+
import { logEscrowFailure } from './logFailure';
|
|
4
|
+
import { EscrowUnavailableError } from './types';
|
|
5
|
+
|
|
6
|
+
afterEach(() => vi.restoreAllMocks());
|
|
7
|
+
|
|
8
|
+
describe('escrow failure reasons', () => {
|
|
9
|
+
it('logs invalid outbound holds without error details', () => {
|
|
10
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
11
|
+
const error = new EscrowUnavailableError('invalid_hold');
|
|
12
|
+
error.message = 'private hold contents';
|
|
13
|
+
logEscrowFailure(error);
|
|
14
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
15
|
+
reason: 'invalid_hold',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it.each([
|
|
19
|
+
[403, 'policy', 'policy'],
|
|
20
|
+
[403, 'pinMismatch', 'pin_mismatch'],
|
|
21
|
+
[403, 'codeMismatch', 'code_mismatch'],
|
|
22
|
+
[403, 'codeExpired', 'code_expired'],
|
|
23
|
+
[403, 'codeLocked', 'code_locked'],
|
|
24
|
+
[403, 'blob', 'blob'],
|
|
25
|
+
[503, 'ledger', 'ledger'],
|
|
26
|
+
[503, 'time', 'time'],
|
|
27
|
+
[412, 'unavailable', 'unavailable'],
|
|
28
|
+
[403, 'secret-unknown-code', 'http_refusal'],
|
|
29
|
+
[429, 'unknown', 'rate_limit'],
|
|
30
|
+
[200, 'unknown', 'invalid_response'],
|
|
31
|
+
])('classifies HTTP %s / %s without logging payloads', async (status, code, reason) => {
|
|
32
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
33
|
+
const enclave = createRemoteEnclave({
|
|
34
|
+
baseUrl: 'https://example.invalid',
|
|
35
|
+
token: 'secret-token',
|
|
36
|
+
timeoutMs: 100,
|
|
37
|
+
transport: {
|
|
38
|
+
post: async () => ({ status, data: { code, message: 'secret@example.com' } }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await enclave.getAttestation().catch(logEscrowFailure);
|
|
42
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
43
|
+
reason,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('drops transport error details', async () => {
|
|
48
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
49
|
+
const enclave = createRemoteEnclave({
|
|
50
|
+
baseUrl: 'https://example.invalid',
|
|
51
|
+
token: 'secret-token',
|
|
52
|
+
timeoutMs: 100,
|
|
53
|
+
transport: {
|
|
54
|
+
post: async () => {
|
|
55
|
+
throw new Error('secret-token');
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
await enclave.getAttestation().catch(logEscrowFailure);
|
|
60
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
61
|
+
reason: 'transport',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EscrowBlobError,
|
|
3
|
+
EscrowCodeError,
|
|
4
|
+
EscrowPinMismatchError,
|
|
5
|
+
EscrowPolicyError,
|
|
6
|
+
EscrowUnavailableError,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
/** Only allowlisted classifications reach logs; never serialize remote errors or payloads. */
|
|
10
|
+
export const logEscrowFailure = (error: unknown): void => {
|
|
11
|
+
const reason =
|
|
12
|
+
error instanceof EscrowCodeError
|
|
13
|
+
? {
|
|
14
|
+
codeMismatch: 'code_mismatch',
|
|
15
|
+
codeExpired: 'code_expired',
|
|
16
|
+
codeLocked: 'code_locked',
|
|
17
|
+
}[error.reason]
|
|
18
|
+
: error instanceof EscrowUnavailableError
|
|
19
|
+
? error.reason
|
|
20
|
+
: error instanceof EscrowPolicyError
|
|
21
|
+
? 'policy'
|
|
22
|
+
: error instanceof EscrowPinMismatchError
|
|
23
|
+
? 'pin_mismatch'
|
|
24
|
+
: error instanceof EscrowBlobError
|
|
25
|
+
? 'blob'
|
|
26
|
+
: 'unexpected';
|
|
27
|
+
// lca-api uses structured console warnings rather than the browser logger.
|
|
28
|
+
console.warn('[LCA escrow] enclave operation failed', { reason });
|
|
29
|
+
};
|
|
@@ -3,6 +3,7 @@ import type { EscrowEnvelope } from '@learncard/sss-key-manager';
|
|
|
3
3
|
import { createRemoteEnclave, type RemoteEnclaveTransportResponse } from './remoteEnclave';
|
|
4
4
|
import {
|
|
5
5
|
EscrowBlobError,
|
|
6
|
+
EscrowCodeError,
|
|
6
7
|
EscrowPinMismatchError,
|
|
7
8
|
EscrowPolicyError,
|
|
8
9
|
EscrowUnavailableError,
|
|
@@ -114,6 +115,40 @@ describe('createRemoteEnclave', () => {
|
|
|
114
115
|
);
|
|
115
116
|
});
|
|
116
117
|
|
|
118
|
+
it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
|
|
119
|
+
'maps %s without forwarding diagnostic text',
|
|
120
|
+
async code => {
|
|
121
|
+
const post = vi
|
|
122
|
+
.fn()
|
|
123
|
+
.mockResolvedValue(respond(403, { code, message: 'sensitive diagnostic' }));
|
|
124
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
125
|
+
await expect(enclave.confirmHold!(confirmInput)).rejects.toEqual(
|
|
126
|
+
new EscrowCodeError(code)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it('separates delivery evidence from the signed hold before subsequent requests', async () => {
|
|
132
|
+
const delivery = { codeSent: false, proof: snapshotReceipt };
|
|
133
|
+
const post = vi.fn().mockResolvedValue(respond(200, { ...hold, delivery }));
|
|
134
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
135
|
+
await expect(enclave.createHold(createInput)).resolves.toEqual({
|
|
136
|
+
holdRecord: hold,
|
|
137
|
+
delivery,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('forwards fresh hold-state nonces under the existing bearer authentication', async () => {
|
|
142
|
+
const post = vi.fn().mockResolvedValue(respond(200, { proof: snapshotReceipt }));
|
|
143
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
144
|
+
const input = { expectedDid: hold.hold.did, hold, nonce: 'ab'.repeat(32) };
|
|
145
|
+
await expect(enclave.getHoldState!(input)).resolves.toEqual({ proof: snapshotReceipt });
|
|
146
|
+
expect(post).toHaveBeenCalledWith(`${baseUrl}/v1/hold-state`, input, {
|
|
147
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
148
|
+
timeout: timeoutMs,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
117
152
|
it('sends an empty nonce array when none is given', async () => {
|
|
118
153
|
const post = vi.fn().mockResolvedValue(respond(200, attestationBody));
|
|
119
154
|
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
@@ -305,9 +340,69 @@ describe('createRemoteEnclave', () => {
|
|
|
305
340
|
await expect(enclave.releaseEscrow(input)).resolves.toEqual({ sealed: envelope, receipt });
|
|
306
341
|
expect(post.mock.calls.at(-1)?.[1]).toMatchObject({ requestId: 'stable-id' });
|
|
307
342
|
post.mockResolvedValue(respond(200, { sealed: envelope }));
|
|
308
|
-
await expect(enclave.releaseEscrow(input)).rejects.
|
|
343
|
+
await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
|
|
344
|
+
reason: 'invalid_response',
|
|
345
|
+
});
|
|
309
346
|
post.mockResolvedValue(respond(200, { ok: true }));
|
|
310
|
-
await expect(enclave.cancelHold(input)).rejects.
|
|
347
|
+
await expect(enclave.cancelHold(input)).rejects.toMatchObject({
|
|
348
|
+
reason: 'invalid_response',
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it.each([
|
|
353
|
+
{ ...hold, ledgerSeq: -1 },
|
|
354
|
+
{ ...hold, hold: { ...hold.hold, policyVersion: 2, signature: 'invalid' } },
|
|
355
|
+
])('distinguishes invalid outbound holds from invalid returned holds', async invalidHold => {
|
|
356
|
+
const post = vi.fn().mockResolvedValue(respond(200, invalidHold));
|
|
357
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
358
|
+
const input = {
|
|
359
|
+
envelope,
|
|
360
|
+
hold: invalidHold,
|
|
361
|
+
expectedDid: hold.hold.did,
|
|
362
|
+
clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
|
|
363
|
+
};
|
|
364
|
+
await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
|
|
365
|
+
reason: 'invalid_hold',
|
|
366
|
+
});
|
|
367
|
+
await expect(enclave.cancelHold(input)).rejects.toMatchObject({ reason: 'invalid_hold' });
|
|
368
|
+
await expect(enclave.confirmHold!({ ...input, code: '12345678' })).rejects.toMatchObject({
|
|
369
|
+
reason: 'invalid_hold',
|
|
370
|
+
});
|
|
371
|
+
await expect(enclave.resendCode!(input)).rejects.toMatchObject({ reason: 'invalid_hold' });
|
|
372
|
+
expect(post).not.toHaveBeenCalled();
|
|
373
|
+
await expect(
|
|
374
|
+
enclave.createHold({
|
|
375
|
+
envelope,
|
|
376
|
+
...hold.hold,
|
|
377
|
+
expectedDid: hold.hold.did,
|
|
378
|
+
expectedShareVersion: 1,
|
|
379
|
+
})
|
|
380
|
+
).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it.each([
|
|
384
|
+
{ envelope: null, enrollmentEpoch: 1 },
|
|
385
|
+
{ envelope, enrollmentEpoch: 2 },
|
|
386
|
+
])('classifies invalid rewrap envelope/epoch as a response failure', async result => {
|
|
387
|
+
const post = vi.fn().mockResolvedValue(
|
|
388
|
+
respond(200, {
|
|
389
|
+
result: {
|
|
390
|
+
...result,
|
|
391
|
+
receipt,
|
|
392
|
+
authorityGeneration: 'g1',
|
|
393
|
+
enrollmentVersion: 2,
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
|
|
398
|
+
await expect(
|
|
399
|
+
enclave.rewrapEscrowBlob({
|
|
400
|
+
envelope,
|
|
401
|
+
expectedDid: hold.hold.did,
|
|
402
|
+
expectedShareVersion: 1,
|
|
403
|
+
sourceEnrollmentEpoch: 1,
|
|
404
|
+
})
|
|
405
|
+
).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
311
406
|
});
|
|
312
407
|
|
|
313
408
|
// [A] confirm-hold/send-release-warning: a fresh call returns a signed
|
|
@@ -625,7 +720,10 @@ describe('createRemoteEnclave', () => {
|
|
|
625
720
|
const created = await enclave.createHold(createInput);
|
|
626
721
|
expect(created).toEqual({ holdRecord: opaque });
|
|
627
722
|
const config = { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs };
|
|
628
|
-
expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput,
|
|
723
|
+
expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput, {
|
|
724
|
+
...config,
|
|
725
|
+
headers: { ...config.headers, 'X-Escrow-Delivery-Evidence': '1' },
|
|
726
|
+
});
|
|
629
727
|
await expect(
|
|
630
728
|
enclave.cancelHold({ ...cancelInput, hold: created.holdRecord })
|
|
631
729
|
).resolves.toBeUndefined();
|