@learncard/network-brain-service 3.17.0 → 3.19.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 +4 -0
- package/AGENTS.md +177 -159
- package/CHANGELOG.md +148 -0
- package/README.md +26 -8
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.test.d.ts +2 -0
- package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
- package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
- package/dist/cache/atomic-counter.test.d.ts +2 -0
- package/dist/cache/atomic-counter.test.d.ts.map +1 -0
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/config/environment.d.ts +46 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.helpers.d.ts.map +1 -1
- package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
- package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts +3 -7
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
- package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential.helpers.d.ts +4 -2
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
- package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
- package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
- package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts +9 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
- package/dist/helpers/shim.d.ts +1 -1
- package/dist/helpers/shim.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
- package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/helpers/status-list.helpers.test.d.ts +2 -0
- package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
- package/dist/inbox-queue-local.d.ts +2 -0
- package/dist/inbox-queue-local.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AuthGrant.d.ts.map +1 -1
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +1 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialActivity.d.ts +1 -0
- package/dist/models/CredentialActivity.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -2
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/InboxCredential.d.ts +1 -0
- package/dist/models/InboxCredential.d.ts.map +1 -1
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/routes/act-as.test.d.ts +2 -0
- package/dist/routes/act-as.test.d.ts.map +1 -0
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +506 -12
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +48 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts +4 -4
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +8 -2
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +8 -8
- package/dist/routes/federation.d.ts +2 -2
- package/dist/routes/inbox.d.ts +973 -31
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +47 -127
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +6 -6
- package/dist/routes/storage.d.ts +63 -63
- package/dist/routes/test.d.ts +6 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +14 -14
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
- package/dist/services/registry/registry.factory.d.ts.map +1 -1
- package/dist/types/activity.d.ts +4 -0
- package/dist/types/activity.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +22 -8
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +7 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/did-metadata.d.ts +2 -2
- package/dist/types/inbox-batch.d.ts +38 -0
- package/dist/types/inbox-batch.d.ts.map +1 -0
- package/inbox-e2e-setup.ts +53 -1
- package/inbox-elasticmq.conf +10 -0
- package/lambda.ts +61 -16
- package/package.json +18 -16
- package/scripts/setup-inbox-queues.ts +60 -0
- package/serverless-local.yml +2 -0
- package/serverless.yml +75 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
- package/src/accesslayer/auth-grant/read.ts +2 -1
- package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
- package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
- package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
- package/src/accesslayer/credential/create.test.ts +141 -0
- package/src/accesslayer/credential/create.ts +27 -3
- package/src/accesslayer/credential-activity/create.ts +1 -0
- package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
- package/src/accesslayer/credential-refresh/index.ts +1 -0
- package/src/accesslayer/credential-refresh/read.ts +8 -2
- package/src/accesslayer/credential-refresh/update.ts +4 -0
- package/src/accesslayer/inbox-batch/store.ts +469 -0
- package/src/accesslayer/inbox-credential/create.ts +51 -19
- package/src/accesslayer/inbox-credential/refresh.ts +221 -0
- package/src/accesslayer/inbox-credential/update.ts +1 -1
- package/src/cache/atomic-counter.test-helpers.ts +27 -0
- package/src/cache/atomic-counter.test.ts +6 -0
- package/src/cache/index.ts +25 -13
- package/src/config/environment.ts +26 -0
- package/src/credential-refresh.ts +5 -1
- package/src/docker-entry.ts +8 -0
- package/src/helpers/activity.helpers.ts +4 -0
- package/src/helpers/app-counter.helpers.ts +1 -1
- package/src/helpers/boost-storage.helpers.test.ts +242 -0
- package/src/helpers/boost.helpers.ts +58 -437
- package/src/helpers/contact-method.helpers.test.ts +43 -0
- package/src/helpers/contact-method.helpers.ts +3 -4
- package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
- package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
- package/src/helpers/credential-refresh-email.helpers.ts +201 -0
- package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
- package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
- package/src/helpers/credential-refresh.helpers.ts +385 -107
- package/src/helpers/credential.helpers.ts +6 -2
- package/src/helpers/finalize-inbox.helpers.ts +39 -22
- package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
- package/src/helpers/inbox-batch-http.helpers.ts +150 -0
- package/src/helpers/inbox-batch-status.helpers.ts +11 -0
- package/src/helpers/inbox-batch.helpers.test.ts +668 -0
- package/src/helpers/inbox-batch.helpers.ts +374 -0
- package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
- package/src/helpers/inbox-queue.helpers.test.ts +253 -0
- package/src/helpers/inbox-queue.helpers.ts +372 -0
- package/src/helpers/inbox-refresh.helpers.ts +432 -0
- package/src/helpers/inbox.helpers.ts +301 -50
- package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
- package/src/helpers/notifications.helpers.ts +4 -2
- package/src/helpers/rateLimit.helpers.ts +2 -5
- package/src/helpers/refresh-send-intent.helpers.ts +273 -0
- package/src/helpers/shim.ts +32 -31
- package/src/helpers/signingAuthority.helpers.test.ts +198 -0
- package/src/helpers/signingAuthority.helpers.ts +41 -18
- package/src/helpers/status-list.helpers.test.ts +77 -0
- package/src/helpers/status-list.helpers.ts +31 -8
- package/src/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.ts +1 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/ConsentFlowTransaction.ts +4 -0
- package/src/models/Credential.ts +1 -0
- package/src/models/CredentialActivity.ts +3 -5
- package/src/models/CredentialRefresh.ts +4 -2
- package/src/models/InboxCredential.ts +2 -0
- package/src/models/credential-refresh-constraints.test.ts +8 -3
- package/src/models/credential-refresh-constraints.ts +5 -0
- package/src/openapi.ts +8 -0
- package/src/routes/act-as.test.ts +239 -0
- package/src/routes/app-store.ts +69 -52
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +943 -120
- package/src/routes/contracts.ts +55 -7
- package/src/routes/credential-refreshes.test.ts +8 -2
- package/src/routes/credential-refreshes.ts +4 -1
- package/src/routes/credentials.ts +2 -1
- package/src/routes/inbox.ts +95 -49
- package/src/routes/index.ts +164 -88
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +80 -31
- package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
- package/src/services/registry/registry.factory.ts +2 -1
- package/src/types/activity.ts +2 -0
- package/src/types/consentflowcontract.ts +21 -3
- package/src/types/credential-refresh.ts +21 -2
- package/src/types/credential.ts +13 -1
- package/src/types/inbox-batch.ts +46 -0
- package/test/app-store-listing.spec.ts +66 -30
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/bitstring-status-list.spec.ts +12 -9
- package/test/consentflow.spec.ts +453 -26
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +13 -5
- package/test/guardian-gated-routes.spec.ts +337 -180
- package/test/helpers/inbox-e2e-environment.ts +6 -0
- package/test/inbox-batch.spec.ts +1338 -0
- package/test/inbox-refresh.spec.ts +534 -0
- package/test/inbox-security.e2e.ts +48 -3
- package/test/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/test/revoke-suspend-per-instance.spec.ts +9 -1
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import type { ManagedCredentialRefreshReceipt, SendBoostResponse } from '@learncard/types';
|
|
4
|
+
import { canonicalizeCredentialJson } from '@learncard/helpers';
|
|
5
|
+
|
|
6
|
+
import { neogma } from '@instance';
|
|
7
|
+
|
|
8
|
+
import { getInitialRefreshRoot, getManagedCredentialUri } from './credential-refresh.helpers';
|
|
9
|
+
|
|
10
|
+
/** A preparation not touched for this long is considered abandoned and may be taken over. */
|
|
11
|
+
export const REFRESH_SEND_INTENT_STALE_MS = 5 * 60_000;
|
|
12
|
+
|
|
13
|
+
export type RefreshSendIntentState = 'preparing' | 'prepared' | 'delivered';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whole-call idempotency record for a managed refresh send (LC-2198 follow-up).
|
|
17
|
+
* Metadata only: never stores credential claims, subject bodies, plaintext VCs or JWEs.
|
|
18
|
+
*/
|
|
19
|
+
export type RefreshSendIntent = {
|
|
20
|
+
intentKey: string;
|
|
21
|
+
issuerProfileId: string;
|
|
22
|
+
requestDigest: string;
|
|
23
|
+
state: RefreshSendIntentState;
|
|
24
|
+
claimToken: string;
|
|
25
|
+
boostUri?: string;
|
|
26
|
+
credentialId?: string;
|
|
27
|
+
refreshId?: string;
|
|
28
|
+
holderDid?: string;
|
|
29
|
+
pendingReceipt?: ManagedCredentialRefreshReceipt;
|
|
30
|
+
pendingActivityId?: string;
|
|
31
|
+
result?: SendBoostResponse;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ClaimRefreshSendIntentResult =
|
|
36
|
+
| { kind: 'owned'; intent: RefreshSendIntent }
|
|
37
|
+
| { kind: 'prepared'; intent: RefreshSendIntent }
|
|
38
|
+
| { kind: 'delivered'; intent: RefreshSendIntent };
|
|
39
|
+
|
|
40
|
+
const getIntentKey = (issuerProfileId: string, idempotencyKey: string): string =>
|
|
41
|
+
`${issuerProfileId}:${idempotencyKey}`;
|
|
42
|
+
|
|
43
|
+
/** Digest of everything that defines a prepare request; a reused key must match it. */
|
|
44
|
+
export const computeRefreshSendRequestDigest = (request: {
|
|
45
|
+
recipientProfileId: string;
|
|
46
|
+
holderDid: string;
|
|
47
|
+
templateUri?: string;
|
|
48
|
+
template?: unknown;
|
|
49
|
+
contractUri?: string;
|
|
50
|
+
templateData?: Record<string, unknown>;
|
|
51
|
+
integrationId?: string;
|
|
52
|
+
credentialId?: string;
|
|
53
|
+
}): string =>
|
|
54
|
+
createHash('sha256')
|
|
55
|
+
.update(
|
|
56
|
+
canonicalizeCredentialJson({
|
|
57
|
+
recipientProfileId: request.recipientProfileId,
|
|
58
|
+
holderDid: request.holderDid,
|
|
59
|
+
templateUri: request.templateUri ?? null,
|
|
60
|
+
template: request.template ?? null,
|
|
61
|
+
contractUri: request.contractUri ?? null,
|
|
62
|
+
templateData: request.templateData ?? {},
|
|
63
|
+
integrationId: request.integrationId ?? null,
|
|
64
|
+
credentialId: request.credentialId ?? null,
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
.digest('base64url');
|
|
68
|
+
|
|
69
|
+
const parseIntent = (props: Record<string, unknown>): RefreshSendIntent => ({
|
|
70
|
+
intentKey: props.intentKey as string,
|
|
71
|
+
issuerProfileId: props.issuerProfileId as string,
|
|
72
|
+
requestDigest: props.requestDigest as string,
|
|
73
|
+
state: props.state as RefreshSendIntentState,
|
|
74
|
+
claimToken: props.claimToken as string,
|
|
75
|
+
boostUri: (props.boostUri as string | undefined) ?? undefined,
|
|
76
|
+
credentialId: (props.credentialId as string | undefined) ?? undefined,
|
|
77
|
+
refreshId: (props.refreshId as string | undefined) ?? undefined,
|
|
78
|
+
holderDid: (props.holderDid as string | undefined) ?? undefined,
|
|
79
|
+
pendingReceipt: props.pendingReceipt
|
|
80
|
+
? (JSON.parse(props.pendingReceipt as string) as ManagedCredentialRefreshReceipt)
|
|
81
|
+
: undefined,
|
|
82
|
+
pendingActivityId: (props.pendingActivityId as string | undefined) ?? undefined,
|
|
83
|
+
result: props.result ? (JSON.parse(props.result as string) as SendBoostResponse) : undefined,
|
|
84
|
+
updatedAt: props.updatedAt as string,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const getRefreshSendIntent = async (
|
|
88
|
+
issuerProfileId: string,
|
|
89
|
+
idempotencyKey: string
|
|
90
|
+
): Promise<RefreshSendIntent | null> => {
|
|
91
|
+
const result = await neogma.queryRunner.run(
|
|
92
|
+
'MATCH (i:RefreshSendIntent {intentKey: $intentKey}) RETURN i',
|
|
93
|
+
{ intentKey: getIntentKey(issuerProfileId, idempotencyKey) }
|
|
94
|
+
);
|
|
95
|
+
const node = result.records[0]?.get('i');
|
|
96
|
+
|
|
97
|
+
return node ? parseIntent(node.properties) : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Creates or claims the intent for (issuer, idempotencyKey). Concurrency-safe through
|
|
102
|
+
* the `refresh_send_intent_key_unique` constraint. Throws CONFLICT when the key was used
|
|
103
|
+
* for a different request, or when another live request is still preparing it.
|
|
104
|
+
*/
|
|
105
|
+
export const claimRefreshSendIntent = async (params: {
|
|
106
|
+
issuerProfileId: string;
|
|
107
|
+
idempotencyKey: string;
|
|
108
|
+
requestDigest: string;
|
|
109
|
+
}): Promise<ClaimRefreshSendIntentResult> => {
|
|
110
|
+
const { issuerProfileId, idempotencyKey, requestDigest } = params;
|
|
111
|
+
const claimToken = randomUUID();
|
|
112
|
+
const now = new Date();
|
|
113
|
+
|
|
114
|
+
const result = await neogma.queryRunner.run(
|
|
115
|
+
`MERGE (i:RefreshSendIntent {intentKey: $intentKey})
|
|
116
|
+
ON CREATE SET i.issuerProfileId = $issuerProfileId,
|
|
117
|
+
i.requestDigest = $requestDigest,
|
|
118
|
+
i.state = 'preparing',
|
|
119
|
+
i.claimToken = $claimToken,
|
|
120
|
+
i.createdAt = $now,
|
|
121
|
+
i.updatedAt = $now
|
|
122
|
+
SET i.updatedAt = i.updatedAt
|
|
123
|
+
WITH i
|
|
124
|
+
CALL {
|
|
125
|
+
WITH i
|
|
126
|
+
WITH i WHERE i.state = 'preparing'
|
|
127
|
+
AND i.claimToken <> $claimToken
|
|
128
|
+
AND i.requestDigest = $requestDigest
|
|
129
|
+
AND i.updatedAt < $staleBefore
|
|
130
|
+
SET i.claimToken = $claimToken, i.updatedAt = $now
|
|
131
|
+
}
|
|
132
|
+
RETURN i`,
|
|
133
|
+
{
|
|
134
|
+
intentKey: getIntentKey(issuerProfileId, idempotencyKey),
|
|
135
|
+
issuerProfileId,
|
|
136
|
+
requestDigest,
|
|
137
|
+
claimToken,
|
|
138
|
+
now: now.toISOString(),
|
|
139
|
+
staleBefore: new Date(now.getTime() - REFRESH_SEND_INTENT_STALE_MS).toISOString(),
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
const intent = parseIntent(result.records[0]!.get('i').properties);
|
|
143
|
+
|
|
144
|
+
if (intent.requestDigest !== requestDigest) {
|
|
145
|
+
throw new TRPCError({
|
|
146
|
+
code: 'CONFLICT',
|
|
147
|
+
message: 'This idempotencyKey was already used for a different send request.',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (intent.state === 'delivered') return { kind: 'delivered', intent };
|
|
152
|
+
if (intent.state === 'prepared') return { kind: 'prepared', intent };
|
|
153
|
+
if (intent.claimToken === claimToken) return { kind: 'owned', intent };
|
|
154
|
+
|
|
155
|
+
throw new TRPCError({
|
|
156
|
+
code: 'CONFLICT',
|
|
157
|
+
message: 'A send with this idempotencyKey is still being prepared. Retry shortly.',
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const lostIntentClaim = (): TRPCError =>
|
|
162
|
+
new TRPCError({
|
|
163
|
+
code: 'CONFLICT',
|
|
164
|
+
message:
|
|
165
|
+
'This send preparation was taken over or completed. Retry with the same idempotencyKey.',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Records progress on an intent owned by `claimToken` (boost first, then allocation).
|
|
170
|
+
* The self-dependent SET takes the node write lock before checking ownership, so
|
|
171
|
+
* a concurrent takeover cannot pass a stale check and overwrite the new owner.
|
|
172
|
+
*/
|
|
173
|
+
export const recordRefreshSendIntent = async (
|
|
174
|
+
intent: RefreshSendIntent,
|
|
175
|
+
fields: Partial<
|
|
176
|
+
Pick<RefreshSendIntent, 'boostUri' | 'credentialId' | 'refreshId' | 'holderDid'>
|
|
177
|
+
> & { state?: 'prepared' }
|
|
178
|
+
): Promise<RefreshSendIntent> => {
|
|
179
|
+
const result = await neogma.queryRunner.run(
|
|
180
|
+
`MATCH (i:RefreshSendIntent {intentKey: $intentKey})
|
|
181
|
+
SET i.updatedAt = i.updatedAt
|
|
182
|
+
WITH i WHERE i.claimToken = $claimToken AND i.state = 'preparing'
|
|
183
|
+
SET i += $fields, i.updatedAt = $now
|
|
184
|
+
RETURN i`,
|
|
185
|
+
{
|
|
186
|
+
intentKey: intent.intentKey,
|
|
187
|
+
claimToken: intent.claimToken,
|
|
188
|
+
fields: Object.fromEntries(
|
|
189
|
+
Object.entries(fields).filter(([, value]) => value !== undefined)
|
|
190
|
+
),
|
|
191
|
+
now: new Date().toISOString(),
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!result.records.length) throw lostIntentClaim();
|
|
196
|
+
return parseIntent(result.records[0]!.get('i').properties);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Stores the metadata-only receipt (and activity) right before delivery is attempted. */
|
|
200
|
+
export const recordRefreshSendIntentPending = async (
|
|
201
|
+
intent: RefreshSendIntent,
|
|
202
|
+
pendingReceipt: ManagedCredentialRefreshReceipt,
|
|
203
|
+
pendingActivityId?: string
|
|
204
|
+
): Promise<void> => {
|
|
205
|
+
const updated = await neogma.queryRunner.run(
|
|
206
|
+
`MATCH (i:RefreshSendIntent {intentKey: $intentKey})
|
|
207
|
+
SET i.updatedAt = i.updatedAt
|
|
208
|
+
WITH i WHERE i.claimToken = $claimToken AND i.state = 'prepared'
|
|
209
|
+
SET i.pendingReceipt = $pendingReceipt,
|
|
210
|
+
i.pendingActivityId = coalesce($pendingActivityId, i.pendingActivityId),
|
|
211
|
+
i.updatedAt = $now
|
|
212
|
+
RETURN i`,
|
|
213
|
+
{
|
|
214
|
+
intentKey: intent.intentKey,
|
|
215
|
+
claimToken: intent.claimToken,
|
|
216
|
+
pendingReceipt: JSON.stringify(pendingReceipt),
|
|
217
|
+
pendingActivityId: pendingActivityId ?? null,
|
|
218
|
+
now: new Date().toISOString(),
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
if (!updated.records.length) throw lostIntentClaim();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const markRefreshSendIntentDelivered = async (
|
|
225
|
+
intent: RefreshSendIntent,
|
|
226
|
+
result: SendBoostResponse
|
|
227
|
+
): Promise<void> => {
|
|
228
|
+
const updated = await neogma.queryRunner.run(
|
|
229
|
+
`MATCH (i:RefreshSendIntent {intentKey: $intentKey})
|
|
230
|
+
SET i.updatedAt = i.updatedAt
|
|
231
|
+
WITH i WHERE i.claimToken = $claimToken
|
|
232
|
+
AND (i.state = 'prepared' OR (i.state = 'delivered' AND i.result = $result))
|
|
233
|
+
SET i.state = 'delivered', i.result = $result, i.updatedAt = $now
|
|
234
|
+
RETURN i`,
|
|
235
|
+
{
|
|
236
|
+
intentKey: intent.intentKey,
|
|
237
|
+
claimToken: intent.claimToken,
|
|
238
|
+
// The atomic replay comparison must ignore object property insertion order.
|
|
239
|
+
result: canonicalizeCredentialJson(result),
|
|
240
|
+
now: new Date().toISOString(),
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
if (!updated.records.length) throw lostIntentClaim();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* When an intent's refresh is already bound (delivery succeeded but the result was never
|
|
248
|
+
* recorded), rebuild the result from the pending receipt and mark it delivered. Returns
|
|
249
|
+
* null when nothing is bound yet.
|
|
250
|
+
*/
|
|
251
|
+
export const reconcileBoundRefreshSendIntent = async (
|
|
252
|
+
intent: RefreshSendIntent,
|
|
253
|
+
domain: string
|
|
254
|
+
): Promise<SendBoostResponse | null> => {
|
|
255
|
+
if (intent.result) return intent.result;
|
|
256
|
+
if (!intent.refreshId || !intent.boostUri || !intent.pendingReceipt) return null;
|
|
257
|
+
|
|
258
|
+
const root = await getInitialRefreshRoot(intent.refreshId);
|
|
259
|
+
|
|
260
|
+
if (!root) return null;
|
|
261
|
+
|
|
262
|
+
const result: SendBoostResponse = {
|
|
263
|
+
type: 'boost',
|
|
264
|
+
uri: intent.boostUri,
|
|
265
|
+
credentialUri: getManagedCredentialUri(root.rootId, domain),
|
|
266
|
+
activityId: intent.pendingActivityId ?? '',
|
|
267
|
+
refresh: intent.pendingReceipt,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
await markRefreshSendIntentDelivered(intent, result);
|
|
271
|
+
|
|
272
|
+
return result;
|
|
273
|
+
};
|
package/src/helpers/shim.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type Context as AWSContext,
|
|
6
6
|
} from 'aws-lambda';
|
|
7
7
|
import { TRPCError } from '@trpc/server';
|
|
8
|
+
import { withInboxBatchBodyLimit } from './inbox-batch-http.helpers';
|
|
8
9
|
import { type IncomingMessage, type ServerResponse } from 'http';
|
|
9
10
|
|
|
10
11
|
import { OpenApiRouter } from 'trpc-to-openapi';
|
|
@@ -183,36 +184,36 @@ export const createOpenApiAwsLambdaHandler = <TRouter extends OpenApiRouter>(
|
|
|
183
184
|
maxBodySize: undefined, // AWS Lambda handles body size limits
|
|
184
185
|
});
|
|
185
186
|
|
|
186
|
-
return
|
|
187
|
-
event: AWSAPIGatewayEvent,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
headers: {
|
|
207
|
-
'Content-Type': 'application/json',
|
|
208
|
-
},
|
|
209
|
-
body: JSON.stringify({
|
|
210
|
-
error: {
|
|
211
|
-
message: error.message || 'Internal server error',
|
|
212
|
-
code: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
|
|
187
|
+
return withInboxBatchBodyLimit(
|
|
188
|
+
async (event: AWSAPIGatewayEvent, _context: AWSContext): Promise<APIGatewayProxyResult> => {
|
|
189
|
+
try {
|
|
190
|
+
const req = createRequestFromEvent(event);
|
|
191
|
+
const { res, promise } = createResponseHandler();
|
|
192
|
+
|
|
193
|
+
// Execute the handler
|
|
194
|
+
await openApiHttpHandler(req, res);
|
|
195
|
+
|
|
196
|
+
return await promise;
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
// Handle any uncaught errors
|
|
199
|
+
const error = cause instanceof Error ? cause : new Error('Unknown error');
|
|
200
|
+
const statusCode =
|
|
201
|
+
error instanceof TRPCError ? (error.code === 'NOT_FOUND' ? 404 : 500) : 500;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
statusCode,
|
|
205
|
+
headers: {
|
|
206
|
+
'Content-Type': 'application/json',
|
|
213
207
|
},
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
error: {
|
|
210
|
+
message: error.message || 'Internal server error',
|
|
211
|
+
code: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
} satisfies APIGatewayProxyResult;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
'openapi'
|
|
218
|
+
);
|
|
218
219
|
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { JWE, UnsignedVC } from '@learncard/types';
|
|
3
|
+
import type { CredentialIssuer } from '../types/issuer';
|
|
4
|
+
import type { SigningAuthorityForUserType } from '../types/profile';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
fetch: vi.fn(),
|
|
8
|
+
append: vi.fn(async credential => credential),
|
|
9
|
+
auth: vi.fn(async () => 'jwt'),
|
|
10
|
+
environment: { NODE_ENV: 'production' },
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@environment', () => ({ environment: mocks.environment }));
|
|
13
|
+
vi.mock('@helpers/did.helpers', () => ({
|
|
14
|
+
getDidWeb: (domain: string, profileId: string) => `did:web:${domain}:users:${profileId}`,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('undici', () => ({ Agent: class {}, fetch: mocks.fetch }));
|
|
17
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
18
|
+
getDidWebLearnCard: async () => ({
|
|
19
|
+
id: { did: () => 'did:example:brain' },
|
|
20
|
+
invoke: { getDidAuthVp: mocks.auth },
|
|
21
|
+
}),
|
|
22
|
+
getLearnCard: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('./status-list.helpers', () => ({ appendBitstringStatusListEntries: mocks.append }));
|
|
25
|
+
vi.mock('@tracing', () => ({
|
|
26
|
+
trace: async (_kind: string, _name: string, action: () => unknown) => action(),
|
|
27
|
+
traceCrypto: async (_name: string, action: () => unknown) => action(),
|
|
28
|
+
traceHttp: async (_name: string, action: () => unknown) => action(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { issueCredentialWithSigningAuthority } from './signingAuthority.helpers';
|
|
32
|
+
|
|
33
|
+
const issuer = { type: 'profile', profile: { profileId: 'org' } } as unknown as CredentialIssuer;
|
|
34
|
+
const sa = {
|
|
35
|
+
signingAuthority: { endpoint: 'https://sa.example' },
|
|
36
|
+
relationship: { name: 'sa', did: 'did:example:sa' },
|
|
37
|
+
} as unknown as SigningAuthorityForUserType;
|
|
38
|
+
const credential = {
|
|
39
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
40
|
+
type: ['VerifiableCredential'],
|
|
41
|
+
issuer: 'did:example:sa',
|
|
42
|
+
issuanceDate: '2026-01-01T00:00:00Z',
|
|
43
|
+
credentialSubject: { id: 'did:example:student' },
|
|
44
|
+
} as UnsignedVC;
|
|
45
|
+
const jwe: JWE = {
|
|
46
|
+
protected: 'header',
|
|
47
|
+
iv: 'iv',
|
|
48
|
+
ciphertext: 'ciphertext',
|
|
49
|
+
tag: 'tag',
|
|
50
|
+
recipients: [],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('SA encryption boundary', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each([
|
|
59
|
+
{ credentialSubject: {} },
|
|
60
|
+
{ credentialSubject: [] },
|
|
61
|
+
{ credentialSubject: [{ id: 'did:example:student' }, {}] },
|
|
62
|
+
])(
|
|
63
|
+
'rejects missing subject IDs before allocating status or authenticating (%j)',
|
|
64
|
+
async ({ credentialSubject }) => {
|
|
65
|
+
await expect(
|
|
66
|
+
issueCredentialWithSigningAuthority(
|
|
67
|
+
issuer,
|
|
68
|
+
{ ...credential, credentialSubject },
|
|
69
|
+
sa,
|
|
70
|
+
'network.example'
|
|
71
|
+
)
|
|
72
|
+
).rejects.toMatchObject({ kind: 'validation_error', retryable: false });
|
|
73
|
+
expect(mocks.append).not.toHaveBeenCalled();
|
|
74
|
+
expect(mocks.auth).not.toHaveBeenCalled();
|
|
75
|
+
expect(mocks.fetch).not.toHaveBeenCalled();
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
it('includes every subject and the delegated contract owner without changing SA ownership', async () => {
|
|
80
|
+
mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => jwe });
|
|
81
|
+
await issueCredentialWithSigningAuthority(
|
|
82
|
+
issuer,
|
|
83
|
+
{
|
|
84
|
+
...credential,
|
|
85
|
+
credentialSubject: [
|
|
86
|
+
{ id: 'did:example:student' },
|
|
87
|
+
{ id: 'did:example:second-student' },
|
|
88
|
+
{ id: 'did:example:student' },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
sa,
|
|
92
|
+
'network.example',
|
|
93
|
+
true,
|
|
94
|
+
undefined,
|
|
95
|
+
true,
|
|
96
|
+
['did:example:contract-owner', 'did:web:network.example:users:org']
|
|
97
|
+
);
|
|
98
|
+
const body = JSON.parse(mocks.fetch.mock.calls[0]![1].body);
|
|
99
|
+
expect(body.signingAuthority.ownerDid).toBe('did:web:network.example:users:org');
|
|
100
|
+
expect(body.encryption.recipients).toEqual([
|
|
101
|
+
'did:example:student',
|
|
102
|
+
'did:example:second-student',
|
|
103
|
+
'did:web:network.example:users:org',
|
|
104
|
+
'did:example:contract-owner',
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it.each([undefined, 'did:example:app-owner'])(
|
|
109
|
+
'encrypts only for the subject and owner (%s)',
|
|
110
|
+
async override => {
|
|
111
|
+
mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => jwe });
|
|
112
|
+
await issueCredentialWithSigningAuthority(
|
|
113
|
+
issuer,
|
|
114
|
+
credential,
|
|
115
|
+
sa,
|
|
116
|
+
'network.example',
|
|
117
|
+
true,
|
|
118
|
+
override
|
|
119
|
+
);
|
|
120
|
+
const body = JSON.parse(mocks.fetch.mock.calls[0]![1].body);
|
|
121
|
+
expect(body.encryption.recipients).toEqual([
|
|
122
|
+
'did:example:student',
|
|
123
|
+
override ?? 'did:web:network.example:users:org',
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
it('allows subject-less plaintext inbox issuance without encryption recipients', async () => {
|
|
129
|
+
mocks.fetch.mockResolvedValue({
|
|
130
|
+
ok: true,
|
|
131
|
+
status: 200,
|
|
132
|
+
json: async () => ({
|
|
133
|
+
...credential,
|
|
134
|
+
proof: {
|
|
135
|
+
type: 'Ed25519Signature2020',
|
|
136
|
+
created: '2026-01-01T00:00:00Z',
|
|
137
|
+
proofPurpose: 'assertionMethod',
|
|
138
|
+
verificationMethod: 'did:example:sa#key',
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
await issueCredentialWithSigningAuthority(
|
|
143
|
+
issuer,
|
|
144
|
+
{ ...credential, credentialSubject: {} },
|
|
145
|
+
sa,
|
|
146
|
+
'network.example',
|
|
147
|
+
false
|
|
148
|
+
);
|
|
149
|
+
expect(JSON.parse(mocks.fetch.mock.calls[0]![1].body).encryption).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not retry permanent recipient errors', async () => {
|
|
153
|
+
mocks.fetch.mockResolvedValue({
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 400,
|
|
156
|
+
statusText: 'Bad Request',
|
|
157
|
+
text: async () => 'Recipient has no usable key',
|
|
158
|
+
});
|
|
159
|
+
await expect(
|
|
160
|
+
issueCredentialWithSigningAuthority(issuer, credential, sa, 'network.example')
|
|
161
|
+
).rejects.toMatchObject({ status: 400, retryable: false, kind: 'http_4xx' });
|
|
162
|
+
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('keeps retry status metadata bound to the serialized request body', async () => {
|
|
166
|
+
const status = {
|
|
167
|
+
type: 'BitstringStatusListEntry' as const,
|
|
168
|
+
statusPurpose: 'revocation' as const,
|
|
169
|
+
statusListIndex: '42',
|
|
170
|
+
statusListCredential: 'https://network.example/status/1',
|
|
171
|
+
};
|
|
172
|
+
const unsigned = { ...credential, credentialStatus: status };
|
|
173
|
+
mocks.fetch
|
|
174
|
+
.mockImplementationOnce(async () => {
|
|
175
|
+
status.statusListIndex = '99';
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
status: 503,
|
|
179
|
+
statusText: 'Unavailable',
|
|
180
|
+
text: async () => 'retry',
|
|
181
|
+
};
|
|
182
|
+
})
|
|
183
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => jwe });
|
|
184
|
+
const issued = await issueCredentialWithSigningAuthority(
|
|
185
|
+
issuer,
|
|
186
|
+
unsigned,
|
|
187
|
+
sa,
|
|
188
|
+
'network.example'
|
|
189
|
+
);
|
|
190
|
+
expect(mocks.append).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(mocks.fetch).toHaveBeenCalledTimes(2);
|
|
192
|
+
const bodies = mocks.fetch.mock.calls.map(call => JSON.parse(call[1].body));
|
|
193
|
+
expect(bodies[0].credential).toEqual(bodies[1].credential);
|
|
194
|
+
expect(issued.statusEntries).toEqual([bodies[1].credential.credentialStatus]);
|
|
195
|
+
expect(issued.statusEntries[0]?.statusListIndex).toBe('42');
|
|
196
|
+
expect(JSON.parse(JSON.stringify(issued)).credential).toEqual(jwe);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -11,6 +11,8 @@ import { trace, traceCrypto, traceHttp } from '@tracing';
|
|
|
11
11
|
import { PerfTracker } from '@helpers/perf';
|
|
12
12
|
import { benchContextStorage } from '@helpers/bench-context.helpers';
|
|
13
13
|
import { appendBitstringStatusListEntries } from './status-list.helpers';
|
|
14
|
+
import { getBitstringStatusListEntries } from '@learncard/helpers';
|
|
15
|
+
import type { IssuedCredential } from '../types/credential';
|
|
14
16
|
|
|
15
17
|
const IS_TEST_ENVIRONMENT = environment.NODE_ENV === 'test';
|
|
16
18
|
|
|
@@ -127,17 +129,35 @@ export async function issueCredentialWithSigningAuthority(
|
|
|
127
129
|
// credential. Allocating fresh Bitstring status entries per version would leak
|
|
128
130
|
// writes and replace the descriptor the issuer supplied, so refresh publication
|
|
129
131
|
// opts out. Defaults to true so every existing caller is preserved.
|
|
130
|
-
appendCredentialStatus = true
|
|
131
|
-
|
|
132
|
+
appendCredentialStatus = true,
|
|
133
|
+
// Additional authorized readers (e.g. a contract owner delegating issuance).
|
|
134
|
+
additionalEncryptionRecipients: string[] = []
|
|
135
|
+
): Promise<IssuedCredential> {
|
|
132
136
|
const issuerEndpoint = `${signingAuthorityForUser.signingAuthority.endpoint}/credentials/issue`;
|
|
133
137
|
const saName = signingAuthorityForUser.relationship.name;
|
|
134
138
|
const saDid = signingAuthorityForUser.relationship.did;
|
|
135
139
|
const ownerProfile = getIssuerOwnerProfile(issuer);
|
|
136
140
|
const ownerDid =
|
|
137
141
|
ownerDidOverride ?? getDidWeb(domain ?? 'network.learncard.com', ownerProfile.profileId);
|
|
142
|
+
const subjects = Array.isArray(credential.credentialSubject)
|
|
143
|
+
? credential.credentialSubject
|
|
144
|
+
: [credential.credentialSubject];
|
|
145
|
+
const subjectIds = subjects.map(subject => subject?.id);
|
|
146
|
+
if (encrypt && (!subjectIds.length || subjectIds.some(id => !id))) {
|
|
147
|
+
throw new SaIssueError({
|
|
148
|
+
message: 'Encrypted credential issuance requires a DID for every subject',
|
|
149
|
+
status: 400,
|
|
150
|
+
kind: 'validation_error',
|
|
151
|
+
retryable: false,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
138
154
|
const credentialToIssue = appendCredentialStatus
|
|
139
155
|
? await appendBitstringStatusListEntries(credential, ownerProfile.profileId, domain)
|
|
140
156
|
: credential;
|
|
157
|
+
// Capture the exact unsigned body once. Retries reuse both this body and its
|
|
158
|
+
// status coordinates, even if the caller later mutates its input object.
|
|
159
|
+
const serializedCredential = JSON.stringify(credentialToIssue);
|
|
160
|
+
const statusEntries = getBitstringStatusListEntries(JSON.parse(serializedCredential));
|
|
141
161
|
|
|
142
162
|
const logContext = {
|
|
143
163
|
issuer: getIssuerProfileId(issuer),
|
|
@@ -148,7 +168,7 @@ export async function issueCredentialWithSigningAuthority(
|
|
|
148
168
|
encrypt,
|
|
149
169
|
};
|
|
150
170
|
|
|
151
|
-
|
|
171
|
+
const issuedCredential = await trace(
|
|
152
172
|
'signing-authority',
|
|
153
173
|
'issueCredentialWithSigningAuthority',
|
|
154
174
|
async () => {
|
|
@@ -156,7 +176,9 @@ export async function issueCredentialWithSigningAuthority(
|
|
|
156
176
|
|
|
157
177
|
try {
|
|
158
178
|
if (IS_TEST_ENVIRONMENT) {
|
|
159
|
-
return await _mockIssueCredentialWithSigningAuthority(
|
|
179
|
+
return await _mockIssueCredentialWithSigningAuthority(
|
|
180
|
+
JSON.parse(serializedCredential)
|
|
181
|
+
);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
console.log('[SA Helper] Initiating credential issuance', logContext);
|
|
@@ -166,8 +188,18 @@ export async function issueCredentialWithSigningAuthority(
|
|
|
166
188
|
);
|
|
167
189
|
perf.mark('initDid');
|
|
168
190
|
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
// Brain authenticates the request but must not be able to decrypt the response.
|
|
192
|
+
const encryption = encrypt
|
|
193
|
+
? {
|
|
194
|
+
recipients: [
|
|
195
|
+
...new Set([
|
|
196
|
+
...subjectIds,
|
|
197
|
+
ownerDid,
|
|
198
|
+
...additionalEncryptionRecipients,
|
|
199
|
+
]),
|
|
200
|
+
],
|
|
201
|
+
}
|
|
202
|
+
: undefined;
|
|
171
203
|
|
|
172
204
|
const didJwt = await traceCrypto('getDidAuthVp', () =>
|
|
173
205
|
learnCard.invoke.getDidAuthVp({ proofFormat: 'jwt' })
|
|
@@ -178,24 +210,14 @@ export async function issueCredentialWithSigningAuthority(
|
|
|
178
210
|
console.error('[SA Helper] Failed to generate DID Auth VP - got falsy value');
|
|
179
211
|
}
|
|
180
212
|
|
|
181
|
-
const subjectId = Array.isArray(credentialToIssue?.credentialSubject)
|
|
182
|
-
? credentialToIssue?.credentialSubject[0]?.id
|
|
183
|
-
: credentialToIssue?.credentialSubject?.id;
|
|
184
|
-
|
|
185
|
-
const encryption = encrypt
|
|
186
|
-
? {
|
|
187
|
-
recipients: [brainDid, ...(subjectId ? [subjectId] : [])],
|
|
188
|
-
}
|
|
189
|
-
: undefined;
|
|
190
|
-
|
|
191
213
|
console.log('[SA Helper] Request details:', {
|
|
192
|
-
|
|
214
|
+
subjectIds,
|
|
193
215
|
encryptionRecipients: encryption?.recipients,
|
|
194
216
|
credentialType: credentialToIssue?.type,
|
|
195
217
|
});
|
|
196
218
|
|
|
197
219
|
const requestBody = JSON.stringify({
|
|
198
|
-
credential:
|
|
220
|
+
credential: JSON.parse(serializedCredential),
|
|
199
221
|
signingAuthority: {
|
|
200
222
|
ownerDid,
|
|
201
223
|
name: saName,
|
|
@@ -402,4 +424,5 @@ export async function issueCredentialWithSigningAuthority(
|
|
|
402
424
|
saEndpoint: signingAuthorityForUser.signingAuthority.endpoint,
|
|
403
425
|
}
|
|
404
426
|
);
|
|
427
|
+
return { kind: 'issued-credential', credential: issuedCredential, statusEntries };
|
|
405
428
|
}
|