@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
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type { TenantBranding } from '@learncard/email-templates';
|
|
1
2
|
import { TRPCError } from '@trpc/server';
|
|
2
3
|
import { UnsignedVC, VC, JWE, LCNNotificationTypeEnumValidator } from '@learncard/types';
|
|
3
4
|
|
|
4
5
|
import { storeCredential } from '@accesslayer/credential/create';
|
|
6
|
+
import type { IssuedCredential } from 'types/credential';
|
|
5
7
|
import {
|
|
6
8
|
createReceivedCredentialRelationship,
|
|
7
9
|
createSentCredentialRelationship,
|
|
@@ -36,7 +38,7 @@ export const getCredentialUri = (id: string, domain: string): string =>
|
|
|
36
38
|
export const sendCredential = async (
|
|
37
39
|
from: ProfileType,
|
|
38
40
|
to: ProfileType,
|
|
39
|
-
credential: VC | UnsignedVC | JWE,
|
|
41
|
+
credential: VC | UnsignedVC | JWE | IssuedCredential,
|
|
40
42
|
domain: string,
|
|
41
43
|
metadata?: Record<string, unknown> | undefined,
|
|
42
44
|
activityId?: string,
|
|
@@ -82,7 +84,8 @@ export const acceptCredential = async (
|
|
|
82
84
|
uri: string,
|
|
83
85
|
options: { skipNotification?: boolean; metadata?: Record<string, unknown> } = {
|
|
84
86
|
skipNotification: false,
|
|
85
|
-
}
|
|
87
|
+
},
|
|
88
|
+
branding?: Partial<TenantBranding>
|
|
86
89
|
): Promise<boolean> => {
|
|
87
90
|
const { id, type } = getUriParts(uri);
|
|
88
91
|
|
|
@@ -173,6 +176,7 @@ export const acceptCredential = async (
|
|
|
173
176
|
credentialNodeId: pendingVc.target.id,
|
|
174
177
|
issuerProfile: sourceProfile,
|
|
175
178
|
holderProfile: profile,
|
|
179
|
+
branding,
|
|
176
180
|
});
|
|
177
181
|
} catch (error) {
|
|
178
182
|
console.error(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finalizeInboxRefresh } from './inbox-refresh.helpers';
|
|
1
2
|
import {
|
|
2
3
|
LCNNotificationTypeEnumValidator,
|
|
3
4
|
LCNInboxStatusEnumValidator,
|
|
@@ -79,10 +80,21 @@ export async function finalizeInboxCredentialsForProfile(
|
|
|
79
80
|
|
|
80
81
|
try {
|
|
81
82
|
let finalCredential: VC;
|
|
82
|
-
const credentialPayload =
|
|
83
|
+
const credentialPayload = inboxCredential.refreshId
|
|
84
|
+
? undefined
|
|
85
|
+
: await decryptInboxCredential(inboxCredential.credential);
|
|
83
86
|
|
|
84
|
-
if (
|
|
85
|
-
|
|
87
|
+
if (inboxCredential.refreshId) {
|
|
88
|
+
finalCredential = (
|
|
89
|
+
await finalizeInboxRefresh({
|
|
90
|
+
inboxId: inboxCredential.id,
|
|
91
|
+
holderDid: profile.did,
|
|
92
|
+
holderProfile: profile,
|
|
93
|
+
domain,
|
|
94
|
+
})
|
|
95
|
+
).credential;
|
|
96
|
+
} else if (!inboxCredential.isSigned) {
|
|
97
|
+
const unsignedCredential = JSON.parse(credentialPayload!) as UnsignedVC;
|
|
86
98
|
|
|
87
99
|
const endpoint =
|
|
88
100
|
(inboxCredential.signingAuthority?.endpoint as string) ?? undefined;
|
|
@@ -124,28 +136,33 @@ export async function finalizeInboxCredentialsForProfile(
|
|
|
124
136
|
? getAppDidWeb(domain, listingSlug)
|
|
125
137
|
: undefined;
|
|
126
138
|
|
|
127
|
-
finalCredential = (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
finalCredential = (
|
|
140
|
+
await issueCredentialWithSigningAuthority(
|
|
141
|
+
{ type: 'profile', profile: issuerProfile },
|
|
142
|
+
unsignedCredential,
|
|
143
|
+
signingAuthorityForUser,
|
|
144
|
+
domain,
|
|
145
|
+
false,
|
|
146
|
+
ownerDidOverride
|
|
147
|
+
)
|
|
148
|
+
).credential as VC;
|
|
135
149
|
} else {
|
|
136
|
-
finalCredential = JSON.parse(credentialPayload) as VC;
|
|
150
|
+
finalCredential = JSON.parse(credentialPayload!) as VC;
|
|
137
151
|
}
|
|
138
152
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
if (!inboxCredential.refreshId) {
|
|
154
|
+
// The seeded encryption plugin adds the service DID; use explicit recipients.
|
|
155
|
+
const learnCard = await getEmptyLearnCard();
|
|
156
|
+
const recoveryCredential = await learnCard.invoke.createDagJwe(
|
|
157
|
+
finalCredential,
|
|
158
|
+
[profile.did]
|
|
159
|
+
);
|
|
160
|
+
const finalized = await finalizeAndWipeInboxCredential(inboxCredential.id, {
|
|
161
|
+
recipientDid: profile.did,
|
|
162
|
+
credential: recoveryCredential,
|
|
163
|
+
});
|
|
164
|
+
if (!finalized) throw new Error('Inbox credential is no longer pending');
|
|
165
|
+
}
|
|
149
166
|
|
|
150
167
|
// Only write a claim audit edge once the record is actually finalized.
|
|
151
168
|
await createClaimedRelationship(profile.profileId, inboxCredential.id, 'finalize');
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { initTRPC } from '@trpc/server';
|
|
3
|
+
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
|
|
4
|
+
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
|
5
|
+
import { fastifyTRPCOpenApiPlugin, type OpenApiMeta } from 'trpc-to-openapi';
|
|
6
|
+
import Fastify from 'fastify';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
|
|
9
|
+
import { Readable } from 'node:stream';
|
|
10
|
+
import {
|
|
11
|
+
configureInboxBatchBodyLimit,
|
|
12
|
+
INBOX_BATCH_MAX_BYTES,
|
|
13
|
+
withInboxBatchBodyLimit,
|
|
14
|
+
} from './inbox-batch-http.helpers';
|
|
15
|
+
import { createOpenApiAwsLambdaHandler } from './shim';
|
|
16
|
+
import { inboxBatchResponseMeta } from './inbox-batch-http.helpers';
|
|
17
|
+
|
|
18
|
+
const t = initTRPC.meta<OpenApiMeta>().create();
|
|
19
|
+
const calls = vi.fn(() => true);
|
|
20
|
+
const router = t.router({
|
|
21
|
+
inbox: t.router({
|
|
22
|
+
issueBatch: t.procedure
|
|
23
|
+
.meta({ openapi: { method: 'POST', path: '/inbox/issue-batch' } })
|
|
24
|
+
.input(z.object({ padding: z.string() }))
|
|
25
|
+
.output(z.boolean())
|
|
26
|
+
.mutation(calls),
|
|
27
|
+
issue: t.procedure
|
|
28
|
+
.meta({ openapi: { method: 'POST', path: '/inbox/issue' } })
|
|
29
|
+
.input(z.object({ padding: z.string() }))
|
|
30
|
+
.output(z.boolean())
|
|
31
|
+
.mutation(calls),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const bodyOfSize = (size: number): string => JSON.stringify({ padding: 'x'.repeat(size - 14) });
|
|
36
|
+
const event = (path: string, body: string, base64 = false): APIGatewayProxyEventV2 => ({
|
|
37
|
+
version: '2.0',
|
|
38
|
+
routeKey: path.startsWith('/trpc/') ? 'ANY /trpc/{trpc+}' : 'ANY /api/{trpc+}',
|
|
39
|
+
rawPath: path,
|
|
40
|
+
rawQueryString: '',
|
|
41
|
+
headers: { 'content-type': 'application/json', host: 'localhost' },
|
|
42
|
+
requestContext: {
|
|
43
|
+
http: {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
path,
|
|
46
|
+
protocol: 'HTTP/1.1',
|
|
47
|
+
sourceIp: '127.0.0.1',
|
|
48
|
+
userAgent: 'test',
|
|
49
|
+
},
|
|
50
|
+
accountId: 'test',
|
|
51
|
+
apiId: 'test',
|
|
52
|
+
domainName: 'localhost',
|
|
53
|
+
domainPrefix: 'test',
|
|
54
|
+
requestId: 'test',
|
|
55
|
+
routeKey: '$default',
|
|
56
|
+
stage: '$default',
|
|
57
|
+
time: '',
|
|
58
|
+
timeEpoch: 0,
|
|
59
|
+
},
|
|
60
|
+
isBase64Encoded: base64,
|
|
61
|
+
body: base64 ? Buffer.from(body).toString('base64') : body,
|
|
62
|
+
pathParameters: { trpc: path.replace(/^\/(api|trpc)\//, '') },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('batch production transports', () => {
|
|
66
|
+
it('enforces actual streamed bytes when Content-Length is absent', async () => {
|
|
67
|
+
const server = Fastify({ bodyLimit: 32 });
|
|
68
|
+
configureInboxBatchBodyLimit(server);
|
|
69
|
+
server.post('/api/*', async () => true);
|
|
70
|
+
try {
|
|
71
|
+
const response = await server.inject({
|
|
72
|
+
method: 'POST',
|
|
73
|
+
url: '/api/inbox/issue',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
payload: Readable.from([bodyOfSize(33)]),
|
|
76
|
+
});
|
|
77
|
+
expect(response.statusCode).toBe(413);
|
|
78
|
+
const batchResponse = await server.inject({
|
|
79
|
+
method: 'POST',
|
|
80
|
+
url: '/api/inbox/issue-batch',
|
|
81
|
+
headers: { 'content-type': 'application/json' },
|
|
82
|
+
payload: Readable.from([bodyOfSize(33)]),
|
|
83
|
+
});
|
|
84
|
+
expect(batchResponse.statusCode).toBe(200);
|
|
85
|
+
} finally {
|
|
86
|
+
await server.close();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it('uses the actual wildcard adapters and preserves limits on other procedures', async () => {
|
|
90
|
+
const server = Fastify();
|
|
91
|
+
configureInboxBatchBodyLimit(server);
|
|
92
|
+
await server.register(fastifyTRPCPlugin, { prefix: '/trpc', trpcOptions: { router } });
|
|
93
|
+
await server.register(fastifyTRPCOpenApiPlugin, {
|
|
94
|
+
basePath: '/api',
|
|
95
|
+
router,
|
|
96
|
+
responseMeta: inboxBatchResponseMeta,
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const payload = bodyOfSize(INBOX_BATCH_MAX_BYTES);
|
|
100
|
+
expect(Buffer.byteLength(payload)).toBe(INBOX_BATCH_MAX_BYTES);
|
|
101
|
+
for (const url of ['/api/inbox/issue-batch', '/trpc/inbox.issueBatch']) {
|
|
102
|
+
const response = await server.inject({
|
|
103
|
+
method: 'POST',
|
|
104
|
+
url,
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
payload,
|
|
107
|
+
});
|
|
108
|
+
expect(response.statusCode, response.body).toBe(
|
|
109
|
+
url.startsWith('/api/') ? 202 : 200
|
|
110
|
+
);
|
|
111
|
+
const tooLarge = await server.inject({
|
|
112
|
+
method: 'POST',
|
|
113
|
+
url,
|
|
114
|
+
headers: { 'content-type': 'application/json' },
|
|
115
|
+
payload: payload + ' ',
|
|
116
|
+
});
|
|
117
|
+
expect(tooLarge.statusCode).toBe(413);
|
|
118
|
+
}
|
|
119
|
+
for (const url of ['/api/inbox/issue', '/trpc/inbox.issue']) {
|
|
120
|
+
expect(
|
|
121
|
+
(
|
|
122
|
+
await server.inject({
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url,
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
payload,
|
|
127
|
+
})
|
|
128
|
+
).statusCode
|
|
129
|
+
).toBe(413);
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
await server.close();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each([false, true])(
|
|
137
|
+
'checks decoded Lambda bytes before context creation (base64=%s)',
|
|
138
|
+
async base64 => {
|
|
139
|
+
const createContext = vi.fn(() => ({}));
|
|
140
|
+
const rest = createOpenApiAwsLambdaHandler({
|
|
141
|
+
router,
|
|
142
|
+
createContext,
|
|
143
|
+
responseMeta: inboxBatchResponseMeta,
|
|
144
|
+
});
|
|
145
|
+
const trpc = withInboxBatchBodyLimit(
|
|
146
|
+
awsLambdaRequestHandler({ router, createContext }),
|
|
147
|
+
'trpc'
|
|
148
|
+
);
|
|
149
|
+
const context = {} as Context;
|
|
150
|
+
for (const [path, handler] of [
|
|
151
|
+
['/api/inbox/issue-batch', rest],
|
|
152
|
+
['/trpc/inbox.issueBatch', trpc],
|
|
153
|
+
] as const) {
|
|
154
|
+
createContext.mockClear();
|
|
155
|
+
calls.mockClear();
|
|
156
|
+
const valid = await handler(
|
|
157
|
+
event(path, bodyOfSize(INBOX_BATCH_MAX_BYTES), base64),
|
|
158
|
+
context
|
|
159
|
+
);
|
|
160
|
+
expect(valid.statusCode, valid.body).toBe(path.startsWith('/api/') ? 202 : 200);
|
|
161
|
+
expect(calls).toHaveBeenCalledTimes(1);
|
|
162
|
+
createContext.mockClear();
|
|
163
|
+
const response = await handler(
|
|
164
|
+
event(path, bodyOfSize(INBOX_BATCH_MAX_BYTES) + ' ', base64),
|
|
165
|
+
context
|
|
166
|
+
);
|
|
167
|
+
expect(response.statusCode).toBe(413);
|
|
168
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('*');
|
|
169
|
+
expect(createContext).not.toHaveBeenCalled();
|
|
170
|
+
expect(calls).toHaveBeenCalledTimes(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it('counts UTF-8 bytes and rejects malformed oversized JSON before parsing', async () => {
|
|
176
|
+
const handler = vi.fn(async () => ({ statusCode: 200 }));
|
|
177
|
+
const wrapped = withInboxBatchBodyLimit(handler, 'trpc');
|
|
178
|
+
const request = event(
|
|
179
|
+
'/trpc/inbox.issue,inbox.issueBatch',
|
|
180
|
+
'é'.repeat(INBOX_BATCH_MAX_BYTES / 2 + 1)
|
|
181
|
+
);
|
|
182
|
+
request.queryStringParameters = { batch: '1' };
|
|
183
|
+
const response = await wrapped(request, {} as Context);
|
|
184
|
+
expect(response.statusCode).toBe(413);
|
|
185
|
+
expect(handler).not.toHaveBeenCalled();
|
|
186
|
+
expect('body' in response && JSON.parse(response.body)).toHaveLength(2);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type {
|
|
3
|
+
APIGatewayProxyEvent,
|
|
4
|
+
APIGatewayProxyEventV2,
|
|
5
|
+
APIGatewayProxyResult,
|
|
6
|
+
Context,
|
|
7
|
+
} from 'aws-lambda';
|
|
8
|
+
import { Transform } from 'node:stream';
|
|
9
|
+
|
|
10
|
+
// This leaves headroom below Lambda's 6 MB synchronous invocation envelope. It is a byte limit,
|
|
11
|
+
// rather than an item limit, because large CLR credentials can make a small batch very large.
|
|
12
|
+
export const INBOX_BATCH_MAX_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
const MESSAGE = 'Inbox batch exceeds the 4 MiB JSON payload limit';
|
|
14
|
+
|
|
15
|
+
const requestPath = (url: string): string => {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(url.split('?')[0]!).replace(/\/$/, '');
|
|
18
|
+
} catch {
|
|
19
|
+
return url.split('?')[0]!;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Identifies only the batch procedure across REST and tRPC transports. A tRPC URL may contain
|
|
25
|
+
* comma-separated procedures when the client batches calls, so any batch-issuance member raises
|
|
26
|
+
* the parser allowance for the shared request.
|
|
27
|
+
*/
|
|
28
|
+
export const isInboxBatchRequest = (url: string): boolean => {
|
|
29
|
+
const path = requestPath(url);
|
|
30
|
+
return (
|
|
31
|
+
path === '/api/inbox/issue-batch' ||
|
|
32
|
+
path === '/inbox/issue-batch' ||
|
|
33
|
+
(path.startsWith('/trpc/') &&
|
|
34
|
+
path.slice('/trpc/'.length).split(',').includes('inbox.issueBatch'))
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gives adapter wildcard routes a 4 MiB parser ceiling, then enforces the real per-procedure
|
|
40
|
+
* limit in preParsing. trpc-to-openapi and tRPC each register one catch-all Fastify route, so
|
|
41
|
+
* setting a limit on `/api/inbox/issue-batch` would never run in the Docker server.
|
|
42
|
+
* Other procedures retain the server's default budget here. Future procedures needing a
|
|
43
|
+
* different budget must update this selector as well as the shared parser ceiling; a route
|
|
44
|
+
* bodyLimit alone cannot override this streaming guard.
|
|
45
|
+
*/
|
|
46
|
+
export const configureInboxBatchBodyLimit = (server: FastifyInstance): void => {
|
|
47
|
+
const defaultLimit = server.initialConfig.bodyLimit ?? 1024 * 1024;
|
|
48
|
+
server.addHook('onRoute', options => {
|
|
49
|
+
// Both adapters register catch-all routes, not one Fastify route per procedure.
|
|
50
|
+
if (options.url.startsWith('/api/') || options.url.startsWith('/trpc/')) {
|
|
51
|
+
options.bodyLimit = INBOX_BATCH_MAX_BYTES;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.addHook('preParsing', async (request, _reply, payload) => {
|
|
55
|
+
const path = requestPath(request.url);
|
|
56
|
+
if (!path.startsWith('/api/') && !path.startsWith('/trpc/')) return payload;
|
|
57
|
+
const limit = isInboxBatchRequest(request.url) ? INBOX_BATCH_MAX_BYTES : defaultLimit;
|
|
58
|
+
const tooLarge = (): Error =>
|
|
59
|
+
Object.assign(new Error('Request body is too large'), {
|
|
60
|
+
statusCode: 413,
|
|
61
|
+
code: 'FST_ERR_CTP_BODY_TOO_LARGE',
|
|
62
|
+
});
|
|
63
|
+
if (Number(request.headers['content-length']) > limit) throw tooLarge();
|
|
64
|
+
// Count actual bytes as well: Content-Length is optional and client controlled. Fastify
|
|
65
|
+
// requires receivedEncodedLength on replacement streams to retain its normal accounting.
|
|
66
|
+
let bytes = 0;
|
|
67
|
+
const stream = new Transform({
|
|
68
|
+
transform(chunk, _encoding, callback) {
|
|
69
|
+
bytes += chunk.length;
|
|
70
|
+
if (bytes > limit) callback(tooLarge());
|
|
71
|
+
else callback(null, chunk);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
Object.defineProperty(stream, 'receivedEncodedLength', { get: () => bytes });
|
|
75
|
+
payload.once('error', error => stream.destroy(error));
|
|
76
|
+
return payload.pipe(stream);
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** REST adapters bypass Fastify onSend hooks. Keep the documented 202 in src/openapi.ts in sync. */
|
|
81
|
+
export const inboxBatchResponseMeta = ({
|
|
82
|
+
paths,
|
|
83
|
+
errors,
|
|
84
|
+
}: {
|
|
85
|
+
paths?: readonly string[];
|
|
86
|
+
errors: readonly unknown[];
|
|
87
|
+
}): { status?: number } =>
|
|
88
|
+
!errors.length && paths?.includes('inbox.issueBatch') ? { status: 202 } : {};
|
|
89
|
+
|
|
90
|
+
type GatewayEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enforces decoded request bytes before either Lambda adapter parses JSON or creates a context.
|
|
94
|
+
* API Gateway can base64 encode binary bodies; their encoded string length is not the payload size.
|
|
95
|
+
* The wrapper also mirrors each transport's error envelope so SDK clients retain normal semantics.
|
|
96
|
+
*/
|
|
97
|
+
export const withInboxBatchBodyLimit =
|
|
98
|
+
<TEvent extends GatewayEvent, TResult>(
|
|
99
|
+
handler: (event: TEvent, context: Context) => Promise<TResult>,
|
|
100
|
+
transport: 'openapi' | 'trpc'
|
|
101
|
+
): ((event: TEvent, context: Context) => Promise<TResult | APIGatewayProxyResult>) =>
|
|
102
|
+
async (event, context) => {
|
|
103
|
+
const route = event.pathParameters?.trpc;
|
|
104
|
+
// `trpc` is a path parameter in deployed Lambda events, while local/OpenAPI events expose
|
|
105
|
+
// the concrete path. Normalize both forms before applying the same route predicate.
|
|
106
|
+
let path: string;
|
|
107
|
+
if (route) {
|
|
108
|
+
const prefix = transport === 'trpc' ? '/trpc' : '';
|
|
109
|
+
path = `${prefix}/${route.replace(/^\//, '')}`;
|
|
110
|
+
} else if ('rawPath' in event) {
|
|
111
|
+
path = event.rawPath;
|
|
112
|
+
} else {
|
|
113
|
+
path = event.path;
|
|
114
|
+
}
|
|
115
|
+
const bytes = event.isBase64Encoded
|
|
116
|
+
? Buffer.from(event.body ?? '', 'base64').length
|
|
117
|
+
: Buffer.byteLength(event.body ?? '', 'utf8');
|
|
118
|
+
if (isInboxBatchRequest(path) && bytes > INBOX_BATCH_MAX_BYTES) {
|
|
119
|
+
// A tRPC batch expects one response entry per requested procedure, including this
|
|
120
|
+
// transport-level failure, whereas REST returns its ordinary error object.
|
|
121
|
+
// REST matches trpc-to-openapi's { message, code } validation envelope, rather
|
|
122
|
+
// than the shim's { error: ... } fallback for unexpected adapter exceptions.
|
|
123
|
+
const paths = requestPath(path)
|
|
124
|
+
.replace(/^\/trpc\//, '')
|
|
125
|
+
.split(',');
|
|
126
|
+
const errors = paths.map(procedure => ({
|
|
127
|
+
error: {
|
|
128
|
+
message: MESSAGE,
|
|
129
|
+
code: -32013,
|
|
130
|
+
data: { code: 'PAYLOAD_TOO_LARGE', httpStatus: 413, path: procedure },
|
|
131
|
+
},
|
|
132
|
+
}));
|
|
133
|
+
const batched =
|
|
134
|
+
event.queryStringParameters?.batch === '1' ||
|
|
135
|
+
('rawQueryString' in event &&
|
|
136
|
+
new URLSearchParams(event.rawQueryString).get('batch') === '1');
|
|
137
|
+
return {
|
|
138
|
+
statusCode: 413,
|
|
139
|
+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
|
140
|
+
body: JSON.stringify(
|
|
141
|
+
transport === 'openapi'
|
|
142
|
+
? { message: MESSAGE, code: 'PAYLOAD_TOO_LARGE' }
|
|
143
|
+
: batched
|
|
144
|
+
? errors
|
|
145
|
+
: errors[0]
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return handler(event, context);
|
|
150
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InboxBatchStatus } from '@learncard/types';
|
|
2
|
+
|
|
3
|
+
/** Use the same state precedence for polling and submission replay receipts. */
|
|
4
|
+
export const getInboxBatchState = (
|
|
5
|
+
states: InboxBatchStatus['items'][number]['state'][]
|
|
6
|
+
): InboxBatchStatus['status'] => {
|
|
7
|
+
if (states.length === 0) return 'QUEUED';
|
|
8
|
+
if (states.includes('NEEDS_RECONCILIATION')) return 'NEEDS_RECONCILIATION';
|
|
9
|
+
if (states.every(state => state === 'COMPLETED')) return 'COMPLETED';
|
|
10
|
+
return states.every(state => state === 'QUEUED') ? 'QUEUED' : 'PROCESSING';
|
|
11
|
+
};
|