@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
package/src/routes/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
|
|
|
9
9
|
import { OpenApiMeta } from 'trpc-to-openapi';
|
|
10
10
|
import jwtDecode from 'jwt-decode';
|
|
11
11
|
import * as Sentry from '@sentry/serverless';
|
|
12
|
-
import { AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
|
|
12
|
+
import { ACT_AS_HEADER, AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
|
|
13
13
|
import { ContactMethodType } from '@learncard/types';
|
|
14
14
|
|
|
15
15
|
import { RegExpTransformer } from '@learncard/helpers';
|
|
@@ -48,7 +48,11 @@ export type Context = {
|
|
|
48
48
|
did: string;
|
|
49
49
|
isChallengeValid: boolean;
|
|
50
50
|
scope?: string;
|
|
51
|
+
isAuthGrant?: boolean;
|
|
52
|
+
actAsPolicy?: string;
|
|
53
|
+
onBehalfOf?: string;
|
|
51
54
|
};
|
|
55
|
+
actAs?: string;
|
|
52
56
|
contactMethod?: ContactMethodType;
|
|
53
57
|
domain: string;
|
|
54
58
|
tenant: ResolvedTenant;
|
|
@@ -84,6 +88,14 @@ export const createContext = async (
|
|
|
84
88
|
| { req: { headers: Map<string, string> } }
|
|
85
89
|
): Promise<Context> => {
|
|
86
90
|
const event = 'event' in options ? options.event : options.req;
|
|
91
|
+
const headerEntries =
|
|
92
|
+
'get' in event.headers
|
|
93
|
+
? Array.from(event.headers as Map<string, string>)
|
|
94
|
+
: Object.entries(event.headers);
|
|
95
|
+
const rawActAs = headerEntries.find(
|
|
96
|
+
([name]) => name.toLowerCase() === ACT_AS_HEADER.toLowerCase()
|
|
97
|
+
)?.[1];
|
|
98
|
+
const actAs = Array.isArray(rawActAs) ? rawActAs.join(',') : rawActAs;
|
|
87
99
|
const authHeader =
|
|
88
100
|
'get' in event.headers
|
|
89
101
|
? (event.headers as Map<string, string>).get('authorization')
|
|
@@ -141,6 +153,7 @@ export const createContext = async (
|
|
|
141
153
|
if (!challenge)
|
|
142
154
|
return {
|
|
143
155
|
user: { did, isChallengeValid: false, scope: AUTH_GRANT_NO_ACCESS_SCOPE },
|
|
156
|
+
actAs,
|
|
144
157
|
domain,
|
|
145
158
|
tenant,
|
|
146
159
|
sourceIp,
|
|
@@ -148,6 +161,8 @@ export const createContext = async (
|
|
|
148
161
|
|
|
149
162
|
let isChallengeValid = false;
|
|
150
163
|
let scope = AUTH_GRANT_FULL_ACCESS_SCOPE;
|
|
164
|
+
let isAuthGrant = false;
|
|
165
|
+
let actAsPolicy: string | undefined;
|
|
151
166
|
|
|
152
167
|
// If the user is using a provisional auth token for a contact method:
|
|
153
168
|
if (challenge?.includes(CONTACT_METHOD_SESSION_PREFIX)) {
|
|
@@ -159,6 +174,7 @@ export const createContext = async (
|
|
|
159
174
|
if (!contactMethod) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
160
175
|
return {
|
|
161
176
|
contactMethod,
|
|
177
|
+
actAs,
|
|
162
178
|
domain,
|
|
163
179
|
tenant,
|
|
164
180
|
sourceIp,
|
|
@@ -166,9 +182,14 @@ export const createContext = async (
|
|
|
166
182
|
}
|
|
167
183
|
// If the user is using a real auth grant i.e. an API Token.
|
|
168
184
|
} else if (challenge?.includes(AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX)) {
|
|
169
|
-
const {
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
const {
|
|
186
|
+
isChallengeValid: _isChallengeValid,
|
|
187
|
+
scope: _scope,
|
|
188
|
+
actAs: policy,
|
|
189
|
+
} = await isAuthGrantChallengeValidForDID(challenge, did);
|
|
190
|
+
|
|
191
|
+
isAuthGrant = true;
|
|
192
|
+
actAsPolicy = policy;
|
|
172
193
|
isChallengeValid = _isChallengeValid;
|
|
173
194
|
scope = _scope;
|
|
174
195
|
// If the user is using a real challenge signed by their private key.
|
|
@@ -182,7 +203,8 @@ export const createContext = async (
|
|
|
182
203
|
Sentry.setUser({ id: did });
|
|
183
204
|
|
|
184
205
|
return {
|
|
185
|
-
user: { did, isChallengeValid, scope },
|
|
206
|
+
user: { did, isChallengeValid, scope, isAuthGrant, actAsPolicy },
|
|
207
|
+
actAs,
|
|
186
208
|
domain,
|
|
187
209
|
tenant,
|
|
188
210
|
_guardianApprovalToken,
|
|
@@ -192,7 +214,7 @@ export const createContext = async (
|
|
|
192
214
|
}
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
return { domain, tenant, _guardianApprovalToken, sourceIp };
|
|
217
|
+
return { domain, tenant, _guardianApprovalToken, sourceIp, actAs };
|
|
196
218
|
};
|
|
197
219
|
|
|
198
220
|
export const openRoute = t.procedure
|
|
@@ -245,6 +267,55 @@ export const resolveProfileFromContextDid = async (
|
|
|
245
267
|
return getProfileByDid(did);
|
|
246
268
|
};
|
|
247
269
|
|
|
270
|
+
type ActingUser = NonNullable<Context['user']> & { profile: ProfileType | null };
|
|
271
|
+
|
|
272
|
+
const resolveActAs = async (
|
|
273
|
+
user: ActingUser,
|
|
274
|
+
actAs: string | undefined,
|
|
275
|
+
domain: string
|
|
276
|
+
): Promise<ActingUser> => {
|
|
277
|
+
const requested = actAs?.trim();
|
|
278
|
+
if (!requested) return user;
|
|
279
|
+
|
|
280
|
+
const { profile } = user;
|
|
281
|
+
const prefix = `did:web:${domain}:users:`;
|
|
282
|
+
const profileId = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
|
|
283
|
+
if (!profileId || /[:/?#]/.test(profileId)) {
|
|
284
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid act-as profile identifier' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Two DB lookups per request when the act-as header is present (target profile,
|
|
288
|
+
// then its managers) — acceptable at backend issuance volume; not worth caching.
|
|
289
|
+
const target = await getProfileByProfileId(profileId);
|
|
290
|
+
if (!target)
|
|
291
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Act-as target profile not found' });
|
|
292
|
+
|
|
293
|
+
const managers = await getProfilesThatManageAProfile(target.profileId);
|
|
294
|
+
if (!profile || !managers.some(manager => manager.profileId === profile.profileId)) {
|
|
295
|
+
throw new TRPCError({
|
|
296
|
+
code: 'FORBIDDEN',
|
|
297
|
+
message: `You do not manage profile "${profileId}".`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const policy = user.actAsPolicy;
|
|
302
|
+
const permitted =
|
|
303
|
+
policy === '*' ||
|
|
304
|
+
policy
|
|
305
|
+
?.split(',')
|
|
306
|
+
.map(id => id.trim())
|
|
307
|
+
.includes(target.profileId);
|
|
308
|
+
if (user.isAuthGrant && !permitted) {
|
|
309
|
+
throw new TRPCError({
|
|
310
|
+
code: 'FORBIDDEN',
|
|
311
|
+
message: `This API token may not act as "${profileId}". Grant actAs on the token.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
Sentry.setUser({ id: target.profileId, username: target.displayName });
|
|
316
|
+
return { ...user, profile: target, did: target.did, onBehalfOf: profile.profileId };
|
|
317
|
+
};
|
|
318
|
+
|
|
248
319
|
export const didRoute = openRoute.use(async ({ ctx, next }) => {
|
|
249
320
|
if (!ctx.user?.did) {
|
|
250
321
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
@@ -254,7 +325,11 @@ export const didRoute = openRoute.use(async ({ ctx, next }) => {
|
|
|
254
325
|
|
|
255
326
|
if (profile) Sentry.setUser({ id: profile.profileId, username: profile.displayName });
|
|
256
327
|
|
|
257
|
-
|
|
328
|
+
const user = ctx.user.isChallengeValid
|
|
329
|
+
? await resolveActAs({ ...ctx.user, profile }, ctx.actAs, ctx.domain)
|
|
330
|
+
: { ...ctx.user, profile };
|
|
331
|
+
|
|
332
|
+
return next({ ctx: { ...ctx, user } });
|
|
258
333
|
});
|
|
259
334
|
|
|
260
335
|
export const didAndChallengeRoute = didRoute.use(({ ctx, next }) => {
|
|
@@ -373,99 +448,100 @@ export const verifiedContactRoute = openRoute.use(async ({ ctx, next }) => {
|
|
|
373
448
|
export type GuardianApprovalToken = {
|
|
374
449
|
iss: string;
|
|
375
450
|
sub: string;
|
|
376
|
-
iat
|
|
451
|
+
iat?: number;
|
|
377
452
|
exp: number;
|
|
378
453
|
scope: string;
|
|
379
454
|
};
|
|
380
455
|
|
|
456
|
+
// Match the existing guardian UI's five-minute approval window.
|
|
457
|
+
const GUARDIAN_APPROVAL_MAX_TTL_SECONDS = 5 * 60;
|
|
458
|
+
const GUARDIAN_APPROVAL_CLOCK_SKEW_SECONDS = 60;
|
|
459
|
+
|
|
381
460
|
export const guardianGatedRoute = profileRoute.use(async ({ ctx, next }) => {
|
|
382
461
|
const { profile } = ctx.user;
|
|
383
462
|
const guardianApprovalToken = ctx._guardianApprovalToken;
|
|
384
|
-
|
|
385
463
|
const isChildAccount = await isProfileManaged(profile.profileId);
|
|
464
|
+
let guardianIdentity: { profileId: string; did: string } | undefined;
|
|
386
465
|
|
|
387
|
-
if (
|
|
388
|
-
return next({
|
|
389
|
-
ctx: { ...ctx, isChildAccount: false, hasGuardianApproval: false },
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (!guardianApprovalToken) {
|
|
394
|
-
return next({
|
|
395
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
try {
|
|
400
|
-
const learnCard = await getEmptyLearnCard();
|
|
401
|
-
|
|
402
|
-
const result = await learnCard.invoke.verifyPresentation(guardianApprovalToken, {
|
|
403
|
-
proofFormat: 'jwt',
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
if (result.errors.length > 0 || !result.checks.includes('JWS')) {
|
|
407
|
-
return next({
|
|
408
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const jwtPayload = jwtDecode<{ vp?: { proof?: { challenge?: string } }; nonce?: string }>(
|
|
413
|
-
guardianApprovalToken
|
|
414
|
-
);
|
|
415
|
-
|
|
416
|
-
const challengeStr = jwtPayload.vp?.proof?.challenge ?? jwtPayload.nonce;
|
|
417
|
-
if (!challengeStr) {
|
|
418
|
-
return next({
|
|
419
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
let guardianClaims: GuardianApprovalToken;
|
|
466
|
+
if (isChildAccount && guardianApprovalToken) {
|
|
424
467
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (!guardianClaims.exp || guardianClaims.exp * 1000 < Date.now()) {
|
|
433
|
-
return next({
|
|
434
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (guardianClaims.scope !== 'guardian-approval') {
|
|
439
|
-
return next({
|
|
440
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
const childDidWeb = getDidWeb(ctx.domain, profile.profileId);
|
|
445
|
-
if (guardianClaims.sub !== childDidWeb) {
|
|
446
|
-
return next({
|
|
447
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
468
|
+
const learnCard = await getEmptyLearnCard();
|
|
469
|
+
const result = await learnCard.invoke.verifyPresentation(guardianApprovalToken, {
|
|
470
|
+
proofFormat: 'jwt',
|
|
448
471
|
});
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const managers = await getProfilesThatManageAProfile(profile.profileId);
|
|
452
|
-
const guardianProfile = managers.find(manager => {
|
|
453
|
-
const managerDidWeb = getDidWeb(ctx.domain, manager.profileId);
|
|
454
|
-
return guardianClaims.iss === managerDidWeb || guardianClaims.iss === manager.did;
|
|
455
|
-
});
|
|
456
472
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
473
|
+
// Verifier warnings are advisory; errors and a missing JWS check are fatal.
|
|
474
|
+
// Identity and current manager authorization are checked independently below.
|
|
475
|
+
if (result.errors.length === 0 && result.checks.includes('JWS')) {
|
|
476
|
+
const jwtHeader = jwtDecode<{ kid?: string }>(guardianApprovalToken, {
|
|
477
|
+
header: true,
|
|
478
|
+
});
|
|
479
|
+
const jwtPayload = jwtDecode<{
|
|
480
|
+
iss?: string;
|
|
481
|
+
vp?: { holder?: string; proof?: { challenge?: string } };
|
|
482
|
+
nonce?: string;
|
|
483
|
+
}>(guardianApprovalToken);
|
|
484
|
+
const challenge = jwtPayload.vp?.proof?.challenge ?? jwtPayload.nonce;
|
|
485
|
+
const claims: GuardianApprovalToken | null =
|
|
486
|
+
typeof challenge === 'string' ? JSON.parse(challenge) : null;
|
|
487
|
+
const now = Date.now() / 1000;
|
|
488
|
+
const signerDid =
|
|
489
|
+
typeof jwtHeader.kid === 'string' ? jwtHeader.kid.split('#')[0] : undefined;
|
|
490
|
+
|
|
491
|
+
// Verification proves the key's signature; bind every identity claim to that key.
|
|
492
|
+
// Older clients omit iat. Allow clock skew without extending the signed lifetime
|
|
493
|
+
// or accepting a token whose actual expiry has passed.
|
|
494
|
+
if (
|
|
495
|
+
claims &&
|
|
496
|
+
signerDid &&
|
|
497
|
+
jwtPayload.iss === signerDid &&
|
|
498
|
+
jwtPayload.vp?.holder === signerDid &&
|
|
499
|
+
claims.iss === signerDid &&
|
|
500
|
+
typeof claims.exp === 'number' &&
|
|
501
|
+
Number.isFinite(claims.exp) &&
|
|
502
|
+
claims.exp > now &&
|
|
503
|
+
claims.exp <=
|
|
504
|
+
now +
|
|
505
|
+
GUARDIAN_APPROVAL_MAX_TTL_SECONDS +
|
|
506
|
+
GUARDIAN_APPROVAL_CLOCK_SKEW_SECONDS &&
|
|
507
|
+
(claims.iat === undefined ||
|
|
508
|
+
(typeof claims.iat === 'number' &&
|
|
509
|
+
Number.isFinite(claims.iat) &&
|
|
510
|
+
claims.iat <= now + GUARDIAN_APPROVAL_CLOCK_SKEW_SECONDS &&
|
|
511
|
+
claims.exp > claims.iat &&
|
|
512
|
+
claims.exp - claims.iat <= GUARDIAN_APPROVAL_MAX_TTL_SECONDS)) &&
|
|
513
|
+
claims.scope === 'guardian-approval' &&
|
|
514
|
+
claims.sub === getDidWeb(ctx.domain, profile.profileId)
|
|
515
|
+
) {
|
|
516
|
+
const managers = await getProfilesThatManageAProfile(profile.profileId);
|
|
517
|
+
const guardian = managers.find(
|
|
518
|
+
manager =>
|
|
519
|
+
signerDid === manager.did ||
|
|
520
|
+
signerDid === getDidWeb(ctx.domain, manager.profileId)
|
|
521
|
+
);
|
|
522
|
+
if (guardian) {
|
|
523
|
+
guardianIdentity = { profileId: guardian.profileId, did: signerDid };
|
|
524
|
+
} else {
|
|
525
|
+
console.warn('guardian_approval: unauthorized_manager');
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
console.warn('guardian_approval: invalid_claims');
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
console.warn('guardian_approval: verification_failed');
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
// Malformed or unverifiable presentations never authorize a guardian-only mutation.
|
|
535
|
+
console.warn('guardian_approval: malformed_or_unverifiable');
|
|
461
536
|
}
|
|
462
|
-
|
|
463
|
-
return next({
|
|
464
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: true },
|
|
465
|
-
});
|
|
466
|
-
} catch {
|
|
467
|
-
return next({
|
|
468
|
-
ctx: { ...ctx, isChildAccount: true, hasGuardianApproval: false },
|
|
469
|
-
});
|
|
470
537
|
}
|
|
538
|
+
|
|
539
|
+
return next({
|
|
540
|
+
ctx: {
|
|
541
|
+
...ctx,
|
|
542
|
+
isChildAccount,
|
|
543
|
+
hasGuardianApproval: guardianIdentity !== undefined,
|
|
544
|
+
guardianIdentity,
|
|
545
|
+
},
|
|
546
|
+
});
|
|
471
547
|
});
|
package/src/routes/skills.ts
CHANGED
|
@@ -241,7 +241,7 @@ export const skillsRouter = t.router({
|
|
|
241
241
|
|
|
242
242
|
if (rateCount > SEMANTIC_SEARCH_RATE_LIMIT_PER_MIN) {
|
|
243
243
|
throw new TRPCError({
|
|
244
|
-
code: 'TOO_MANY_REQUESTS'
|
|
244
|
+
code: 'TOO_MANY_REQUESTS',
|
|
245
245
|
message: `Rate limit exceeded: max ${SEMANTIC_SEARCH_RATE_LIMIT_PER_MIN} semantic searches per minute`,
|
|
246
246
|
});
|
|
247
247
|
}
|
package/src/routes/workflows.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finalizeInboxRefresh } from '@helpers/inbox-refresh.helpers';
|
|
1
2
|
import { TRPCError } from '@trpc/server';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import base64url from 'base64url';
|
|
@@ -5,6 +6,7 @@ import base64url from 'base64url';
|
|
|
5
6
|
import {
|
|
6
7
|
VC,
|
|
7
8
|
UnsignedVC,
|
|
9
|
+
JWE,
|
|
8
10
|
VP,
|
|
9
11
|
VPValidator,
|
|
10
12
|
VCValidator,
|
|
@@ -48,6 +50,7 @@ import { resolveRecipientLocale } from '@helpers/getRecipientLocale.helpers';
|
|
|
48
50
|
import { logCredentialClaimed, logCredentialFailed } from '@helpers/activity.helpers';
|
|
49
51
|
import { handleConnectionPromptsForCredentialClaim } from '@helpers/connectionPrompt.helpers';
|
|
50
52
|
import { decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
|
|
53
|
+
import { getBitstringStatusListEntries } from '@learncard/helpers';
|
|
51
54
|
import {
|
|
52
55
|
EXHAUSTED,
|
|
53
56
|
exhaustExchangeChallengeForToken,
|
|
@@ -347,6 +350,7 @@ async function handlePresentationForClaim(
|
|
|
347
350
|
const verificationResult = await learnCard.invoke.verifyPresentation(verifiablePresentation, {
|
|
348
351
|
challenge,
|
|
349
352
|
domain,
|
|
353
|
+
proofPurpose: 'authentication',
|
|
350
354
|
});
|
|
351
355
|
|
|
352
356
|
if (verificationResult.errors.length > 0 || !verificationResult.checks.includes('proof')) {
|
|
@@ -405,13 +409,15 @@ async function handlePresentationForClaim(
|
|
|
405
409
|
// Inject OBv3 skill alignments based on boost's framework/skills
|
|
406
410
|
await injectObv3AlignmentsIntoCredentialForBoost(boostCredential, boost, domain);
|
|
407
411
|
|
|
408
|
-
const vc = (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
412
|
+
const vc = (
|
|
413
|
+
await issueCredentialWithSigningAuthority(
|
|
414
|
+
{ type: 'profile', profile: saOwnerProfile },
|
|
415
|
+
boostCredential,
|
|
416
|
+
signingAuthorityForUser,
|
|
417
|
+
domain,
|
|
418
|
+
false
|
|
419
|
+
)
|
|
420
|
+
).credential as VC;
|
|
415
421
|
|
|
416
422
|
// Mark the challenge as used
|
|
417
423
|
await useClaimLinkForBoost(exchangeInfo.boostUri, exchangeInfo.challenge);
|
|
@@ -543,6 +549,7 @@ async function handleInboxClaimPresentation(
|
|
|
543
549
|
const verificationResult = await learnCard.invoke.verifyPresentation(verifiablePresentation, {
|
|
544
550
|
challenge,
|
|
545
551
|
domain: ctx.domain,
|
|
552
|
+
proofPurpose: 'authentication',
|
|
546
553
|
});
|
|
547
554
|
|
|
548
555
|
if (verificationResult.errors.length > 0 || !verificationResult.checks.includes('proof')) {
|
|
@@ -594,19 +601,34 @@ async function handleInboxClaimPresentation(
|
|
|
594
601
|
}
|
|
595
602
|
|
|
596
603
|
const claimedCredentials: VC[] = [];
|
|
604
|
+
let deliveryEncryptionFailed = false;
|
|
597
605
|
|
|
598
606
|
// Process each pending credential in parallel
|
|
599
607
|
const credentialProcessingPromises = pendingCredentials.map(async inboxCredential => {
|
|
600
608
|
try {
|
|
601
609
|
let finalCredential: VC;
|
|
602
|
-
const credentialPayload =
|
|
603
|
-
|
|
604
|
-
|
|
610
|
+
const credentialPayload = inboxCredential.refreshId
|
|
611
|
+
? undefined
|
|
612
|
+
: await decryptInboxCredential(inboxCredential.credential);
|
|
613
|
+
|
|
614
|
+
if (inboxCredential.refreshId) {
|
|
615
|
+
// A contact token authorizes delivery to the VP's DID. Never attach an unrelated
|
|
616
|
+
// contact profile's decryption key to that holder's refresh aggregate.
|
|
617
|
+
const boundProfile = await getProfileByDid(holderDid);
|
|
618
|
+
finalCredential = (
|
|
619
|
+
await finalizeInboxRefresh({
|
|
620
|
+
inboxId: inboxCredential.id,
|
|
621
|
+
holderDid,
|
|
622
|
+
holderProfile: boundProfile,
|
|
623
|
+
domain: ctx.domain,
|
|
624
|
+
})
|
|
625
|
+
).credential;
|
|
626
|
+
} else if (inboxCredential.isSigned) {
|
|
605
627
|
// Credential is already signed
|
|
606
|
-
finalCredential = JSON.parse(credentialPayload) as VC;
|
|
628
|
+
finalCredential = JSON.parse(credentialPayload!) as VC;
|
|
607
629
|
} else {
|
|
608
630
|
// Need to sign the credential using signing authority
|
|
609
|
-
const unsignedCredential = JSON.parse(credentialPayload) as UnsignedVC;
|
|
631
|
+
const unsignedCredential = JSON.parse(credentialPayload!) as UnsignedVC;
|
|
610
632
|
const inboxCredentialSigningAuthorityEndpoint =
|
|
611
633
|
(inboxCredential.signingAuthority?.endpoint as string) ?? undefined;
|
|
612
634
|
const inboxCredentialSigningAuthorityName =
|
|
@@ -658,25 +680,40 @@ async function handleInboxClaimPresentation(
|
|
|
658
680
|
unsignedCredential.issuer = signingAuthorityForUser.relationship.did;
|
|
659
681
|
|
|
660
682
|
// Sign the credential
|
|
661
|
-
finalCredential = (
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
683
|
+
finalCredential = (
|
|
684
|
+
await issueCredentialWithSigningAuthority(
|
|
685
|
+
{ type: 'profile', profile: issuerProfile },
|
|
686
|
+
unsignedCredential,
|
|
687
|
+
signingAuthorityForUser,
|
|
688
|
+
ctx.domain,
|
|
689
|
+
false // don't encrypt
|
|
690
|
+
)
|
|
691
|
+
).credential as VC;
|
|
668
692
|
}
|
|
669
693
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
694
|
+
let encryptedDelivery: JWE | undefined;
|
|
695
|
+
if (!inboxCredential.refreshId) {
|
|
696
|
+
// Avoid the seeded encryption wrapper, which adds the service as a recipient.
|
|
697
|
+
const deliveryLearnCard = await getEmptyLearnCard();
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
encryptedDelivery = await deliveryLearnCard.invoke.createDagJwe(
|
|
701
|
+
finalCredential,
|
|
702
|
+
[holderDid]
|
|
703
|
+
);
|
|
704
|
+
if (!encryptedDelivery.recipients?.length) {
|
|
705
|
+
throw new Error('No supported delivery encryption recipients');
|
|
706
|
+
}
|
|
707
|
+
} catch (error) {
|
|
708
|
+
deliveryEncryptionFailed = true;
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
const finalized = await finalizeAndWipeInboxCredential(inboxCredential.id, {
|
|
712
|
+
recipientDid: holderDid,
|
|
713
|
+
credential: encryptedDelivery,
|
|
714
|
+
});
|
|
715
|
+
if (!finalized) throw new Error('Inbox credential is no longer pending');
|
|
716
|
+
}
|
|
680
717
|
|
|
681
718
|
// Record the claim only after finalization succeeds so failed compare-and-swap
|
|
682
719
|
// attempts cannot leave a misleading audit relationship behind.
|
|
@@ -690,14 +727,18 @@ async function handleInboxClaimPresentation(
|
|
|
690
727
|
|
|
691
728
|
// Store credential and create boost relationship if this was a boost issuance
|
|
692
729
|
const boostUri = inboxCredential.boostUri;
|
|
693
|
-
if (holderProfile && boostUri) {
|
|
730
|
+
if (!inboxCredential.refreshId && holderProfile && boostUri) {
|
|
694
731
|
try {
|
|
695
732
|
const boost = await getBoostByUri(boostUri);
|
|
696
733
|
const issuerProfile = await getProfileByDid(inboxCredential.issuerDid);
|
|
697
734
|
|
|
698
735
|
if (boost && issuerProfile) {
|
|
699
736
|
// Store the credential in the database
|
|
700
|
-
const credentialInstance = await storeCredential(
|
|
737
|
+
const credentialInstance = await storeCredential({
|
|
738
|
+
kind: 'issued-credential',
|
|
739
|
+
credential: encryptedDelivery!,
|
|
740
|
+
statusEntries: getBitstringStatusListEntries(finalCredential),
|
|
741
|
+
});
|
|
701
742
|
|
|
702
743
|
// Create the boost instance relationship
|
|
703
744
|
await createBoostInstanceOfRelationship(credentialInstance, boost);
|
|
@@ -868,6 +909,14 @@ async function handleInboxClaimPresentation(
|
|
|
868
909
|
|
|
869
910
|
const settledCredentials = await Promise.all(credentialProcessingPromises);
|
|
870
911
|
const inboxDeliveries = settledCredentials.filter(c => c !== null);
|
|
912
|
+
if (inboxDeliveries.length === 0 && deliveryEncryptionFailed) {
|
|
913
|
+
// Preserve the pending credentials and challenge so a compatible holder can retry.
|
|
914
|
+
throw new TRPCError({
|
|
915
|
+
code: 'BAD_REQUEST',
|
|
916
|
+
message:
|
|
917
|
+
'Unable to encrypt inbox delivery. The holder DID must resolve to a supported X25519 key-agreement method.',
|
|
918
|
+
});
|
|
919
|
+
}
|
|
871
920
|
claimedCredentials.push(...inboxDeliveries.map(delivery => delivery.credential));
|
|
872
921
|
|
|
873
922
|
// Create response VP with all claimed credentials
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Notification } from '../delivery.service';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({ send: vi.fn(), legacySend: vi.fn(), render: vi.fn() }));
|
|
5
|
+
vi.mock('@environment', () => ({ environment: {} }));
|
|
6
|
+
vi.mock('postmark', () => ({
|
|
7
|
+
ServerClient: class {
|
|
8
|
+
sendEmail = mocks.send;
|
|
9
|
+
sendEmailWithTemplate = mocks.legacySend;
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@learncard/email-templates', () => ({
|
|
13
|
+
resolveBranding: () => ({}),
|
|
14
|
+
renderEmail: mocks.render,
|
|
15
|
+
}));
|
|
16
|
+
import { PostmarkAdapter } from './postmark.adapter';
|
|
17
|
+
|
|
18
|
+
const notification: Notification = {
|
|
19
|
+
contactMethod: { type: 'email', value: 'holder@example.com' },
|
|
20
|
+
templateId: 'credential-updated',
|
|
21
|
+
templateModel: { issuer: { name: 'School' }, credential: { name: 'Biology' } },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
mocks.render.mockResolvedValue({ html: '<p>Updated</p>', text: 'Updated', subject: 'Updated' });
|
|
27
|
+
mocks.send.mockResolvedValue({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('update email provider attempts', () => {
|
|
31
|
+
it('sends the locally rendered update once', async () => {
|
|
32
|
+
await new PostmarkAdapter('test-key').send(notification);
|
|
33
|
+
expect(mocks.send).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(mocks.legacySend).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not try a legacy template after an ambiguous provider failure', async () => {
|
|
38
|
+
mocks.send.mockRejectedValueOnce(new Error('Acknowledgement lost'));
|
|
39
|
+
await expect(new PostmarkAdapter('test-key').send(notification)).rejects.toThrow(
|
|
40
|
+
'Acknowledgement lost'
|
|
41
|
+
);
|
|
42
|
+
expect(mocks.send).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(mocks.legacySend).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('fails without sending when the local update template cannot render', async () => {
|
|
47
|
+
mocks.render.mockRejectedValueOnce(new Error('Render failed'));
|
|
48
|
+
await expect(new PostmarkAdapter('test-key').send(notification)).rejects.toThrow(
|
|
49
|
+
'Render failed'
|
|
50
|
+
);
|
|
51
|
+
expect(mocks.send).not.toHaveBeenCalled();
|
|
52
|
+
expect(mocks.legacySend).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -41,6 +41,7 @@ const LOCAL_TEMPLATE_MAP: Record<string, TemplateId> = {
|
|
|
41
41
|
'guardian-credential-approval': 'guardian-credential-approval',
|
|
42
42
|
'guardian-email-otp': 'guardian-email-otp',
|
|
43
43
|
'guardian-rejected-credential': 'guardian-rejected-credential',
|
|
44
|
+
'credential-updated': 'credential-updated',
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
export class PostmarkAdapter implements DeliveryService {
|
|
@@ -85,6 +86,8 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
85
86
|
`[PostmarkAdapter] Local render failed for "${notification.templateId}":`,
|
|
86
87
|
renderError
|
|
87
88
|
);
|
|
89
|
+
// This template is owned locally; there is no legacy provider alias.
|
|
90
|
+
if (notification.templateId === 'credential-updated') throw renderError;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
if (rendered) {
|
|
@@ -104,6 +107,9 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
104
107
|
`[PostmarkAdapter] sendEmail API failed for "${notification.templateId}":`,
|
|
105
108
|
sendError
|
|
106
109
|
);
|
|
110
|
+
// A lost provider acknowledgement may already have sent the email.
|
|
111
|
+
// Do not bypass the refresh delivery claim with a second attempt.
|
|
112
|
+
if (notification.templateId === 'credential-updated') throw sendError;
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
}
|
|
@@ -217,6 +223,12 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
217
223
|
recipient: model.recipient,
|
|
218
224
|
};
|
|
219
225
|
|
|
226
|
+
case 'credential-updated':
|
|
227
|
+
return {
|
|
228
|
+
issuer: model.issuer,
|
|
229
|
+
credential: model.credential,
|
|
230
|
+
};
|
|
231
|
+
|
|
220
232
|
default:
|
|
221
233
|
return model as TemplateDataMap[TemplateId];
|
|
222
234
|
}
|
|
@@ -7,7 +7,8 @@ export const getRegistryService = (): RegistryService => {
|
|
|
7
7
|
if (
|
|
8
8
|
environment.NODE_ENV === 'test' ||
|
|
9
9
|
environment.TRUSTED_ISSUERS_WHITELIST ||
|
|
10
|
-
environment.IS_CI
|
|
10
|
+
environment.IS_CI ||
|
|
11
|
+
environment.IS_E2E_TEST
|
|
11
12
|
) {
|
|
12
13
|
return new WhitelistAdapter();
|
|
13
14
|
}
|
package/src/types/activity.ts
CHANGED
|
@@ -33,6 +33,7 @@ export const CredentialActivityValidator = z.object({
|
|
|
33
33
|
eventType: CredentialActivityEventTypeValidator,
|
|
34
34
|
timestamp: z.string(),
|
|
35
35
|
actorProfileId: z.string().optional(),
|
|
36
|
+
onBehalfOf: z.string().optional(),
|
|
36
37
|
recipientType: CredentialActivityRecipientTypeValidator,
|
|
37
38
|
recipientIdentifier: z.string(),
|
|
38
39
|
boostUri: z.string().optional(),
|
|
@@ -85,6 +86,7 @@ export const CredentialActivityStatsValidator = z.object({
|
|
|
85
86
|
export type CredentialActivityStats = z.infer<typeof CredentialActivityStatsValidator>;
|
|
86
87
|
|
|
87
88
|
export type LogCredentialActivityParams = {
|
|
89
|
+
onBehalfOf?: string;
|
|
88
90
|
actorProfileId?: string;
|
|
89
91
|
eventType: CredentialActivityEventType;
|
|
90
92
|
recipientType: CredentialActivityRecipientType;
|