@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,374 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { mergeWith } from 'lodash';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import type {
|
|
5
|
+
InboxBatchErrorReason,
|
|
6
|
+
IssueInboxCredentialBatch,
|
|
7
|
+
IssueInboxCredentialBatchItemResult,
|
|
8
|
+
IssueInboxCredentialBatchResponse,
|
|
9
|
+
} from '@learncard/types';
|
|
10
|
+
import {
|
|
11
|
+
IssueInboxCredentialValidator,
|
|
12
|
+
IssueInboxCredentialBatchItemResultValidator,
|
|
13
|
+
} from '@learncard/types';
|
|
14
|
+
import type { Context } from '@routes';
|
|
15
|
+
import type { ProfileType } from 'types/profile';
|
|
16
|
+
import type { BatchReplayStore } from 'types/inbox-batch';
|
|
17
|
+
import { issueToInbox, resolveInboxCredentialInput } from './inbox.helpers';
|
|
18
|
+
import { assertInboxRefreshEnabled, inboxRefreshRequestDigest } from './inbox-refresh.helpers';
|
|
19
|
+
import {
|
|
20
|
+
InboxDeliveryCheckpointError,
|
|
21
|
+
InboxIssuancePreflightError,
|
|
22
|
+
} from './inbox-issuance-error.helpers';
|
|
23
|
+
|
|
24
|
+
const INTERNAL_BATCH_CONCURRENCY = 10;
|
|
25
|
+
const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60;
|
|
26
|
+
|
|
27
|
+
const unavailable = (): TRPCError =>
|
|
28
|
+
new TRPCError({
|
|
29
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
30
|
+
message: 'Batch issuance storage is unavailable. Please try again later.',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Produces a stable identity for an effective item. Objects are sorted recursively by JSON's
|
|
35
|
+
* replacer traversal so equivalent objects with different property insertion order replay, while
|
|
36
|
+
* a reused idempotency key with changed recipient, credential, or configuration is rejected.
|
|
37
|
+
*/
|
|
38
|
+
export const fingerprint = (input: unknown): string =>
|
|
39
|
+
createHash('sha256')
|
|
40
|
+
.update(
|
|
41
|
+
JSON.stringify(input, (_key, value) =>
|
|
42
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
43
|
+
? Object.fromEntries(
|
|
44
|
+
Object.keys(value)
|
|
45
|
+
.sort()
|
|
46
|
+
.map(key => [key, value[key]])
|
|
47
|
+
)
|
|
48
|
+
: value
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
.digest('hex');
|
|
52
|
+
|
|
53
|
+
class InboxBatchConflict extends TRPCError {
|
|
54
|
+
constructor(
|
|
55
|
+
readonly reason: InboxBatchErrorReason,
|
|
56
|
+
message: string
|
|
57
|
+
) {
|
|
58
|
+
super({ code: 'CONFLICT', message });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const replayResult = (
|
|
63
|
+
stored: string,
|
|
64
|
+
requestHash: string,
|
|
65
|
+
index: number
|
|
66
|
+
): IssueInboxCredentialBatchItemResult => {
|
|
67
|
+
const decoded = JSON.parse(stored);
|
|
68
|
+
// This namespace has always stored fingerprints. Missing hashes are corrupt records,
|
|
69
|
+
// not permission to replay a potentially unrelated issuance.
|
|
70
|
+
if (!decoded || typeof decoded.requestHash !== 'string') {
|
|
71
|
+
throw new TRPCError({
|
|
72
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
73
|
+
message: 'Corrupt inbox replay record',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (decoded.requestHash !== requestHash) {
|
|
77
|
+
throw new InboxBatchConflict(
|
|
78
|
+
'IDEMPOTENCY_MISMATCH',
|
|
79
|
+
'This idempotency key was used for a different issuance.'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (decoded.state === 'processing') {
|
|
83
|
+
throw new InboxBatchConflict(
|
|
84
|
+
'IN_PROGRESS',
|
|
85
|
+
'Issuance is in progress or its outcome is unconfirmed. Retry this same key later; do not issue with a new key. Contact support if it remains unconfirmed.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const result = IssueInboxCredentialBatchItemResultValidator.parse(decoded);
|
|
89
|
+
if (!result.success) {
|
|
90
|
+
throw new TRPCError({
|
|
91
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
92
|
+
message: 'Corrupt inbox replay record',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return { ...result, index, deduplicated: true };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Internal processing only: HTTP callers must use submitInboxBatch. Queue workers supply durable
|
|
100
|
+
* replay storage and an ownership checkpoint; quota was already charged at admission. Each item
|
|
101
|
+
* uses resolveInboxCredentialInput and issueToInbox, which preserves the single route's signing,
|
|
102
|
+
* claim, guardian, webhook, email, and tenant-branding behavior.
|
|
103
|
+
*/
|
|
104
|
+
export const issueInboxBatch = async (
|
|
105
|
+
profile: ProfileType,
|
|
106
|
+
batch: IssueInboxCredentialBatch,
|
|
107
|
+
ctx: Context,
|
|
108
|
+
execution: {
|
|
109
|
+
/** Queue admission already charged quota; replay records live in durable storage. */
|
|
110
|
+
cache: BatchReplayStore;
|
|
111
|
+
beforeIssue: () => Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
): Promise<IssueInboxCredentialBatchResponse> => {
|
|
114
|
+
// Payload size and quota are enforced at admission. Internal queue metadata must not make
|
|
115
|
+
// a previously accepted request fail the HTTP byte limit during background processing.
|
|
116
|
+
const replayCache = execution.cache;
|
|
117
|
+
|
|
118
|
+
// Queue workers pass one item; the bounded pool, duplicate guard, and summary also protect
|
|
119
|
+
// internal multi-item callers. Production concurrency is configured on the SQS queue.
|
|
120
|
+
// Workers claim indexes synchronously before their first await. Results are written by index,
|
|
121
|
+
// so the response remains in caller order even when template work completes out of order.
|
|
122
|
+
const results: IssueInboxCredentialBatchItemResult[] = new Array(batch.items.length);
|
|
123
|
+
// Choose the first occurrence synchronously, before worker scheduling. Later duplicates
|
|
124
|
+
// always conflict, even if the first item fails or completes before another worker starts.
|
|
125
|
+
const firstIndexByKey = new Map<string, number>();
|
|
126
|
+
batch.items.forEach((item, index) => {
|
|
127
|
+
if (item.idempotencyKey !== undefined && !firstIndexByKey.has(item.idempotencyKey)) {
|
|
128
|
+
firstIndexByKey.set(item.idempotencyKey, index);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
let nextIndex = 0;
|
|
132
|
+
const worker = async (): Promise<void> => {
|
|
133
|
+
while (nextIndex < batch.items.length) {
|
|
134
|
+
const index = nextIndex++;
|
|
135
|
+
const item = batch.items[index]!;
|
|
136
|
+
const key =
|
|
137
|
+
item.idempotencyKey === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: `inbox-batch-idem:${profile.profileId}:${item.idempotencyKey}`;
|
|
140
|
+
let reservation: string | undefined;
|
|
141
|
+
let issuanceStarted = false;
|
|
142
|
+
let issued: Extract<IssueInboxCredentialBatchItemResult, { success: true }> | undefined;
|
|
143
|
+
try {
|
|
144
|
+
if (
|
|
145
|
+
item.idempotencyKey !== undefined &&
|
|
146
|
+
firstIndexByKey.get(item.idempotencyKey) !== index
|
|
147
|
+
) {
|
|
148
|
+
throw new InboxBatchConflict(
|
|
149
|
+
'DUPLICATE_KEY',
|
|
150
|
+
'Duplicate idempotency key in this batch. Only its first occurrence is attempted.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const configuration = mergeWith(
|
|
154
|
+
{},
|
|
155
|
+
batch.configuration,
|
|
156
|
+
item.configuration,
|
|
157
|
+
(_base: unknown, override: unknown) =>
|
|
158
|
+
Array.isArray(override) ? override : undefined
|
|
159
|
+
);
|
|
160
|
+
// null is an explicit per-item escape hatch for the batch guardian default.
|
|
161
|
+
// Remove it before validating against the single-issue contract.
|
|
162
|
+
if (item.configuration?.guardianEmail === null) {
|
|
163
|
+
delete configuration.guardianEmail;
|
|
164
|
+
}
|
|
165
|
+
// Parse only after merging. Parsing each partial configuration first would apply
|
|
166
|
+
// defaults too early and make an omitted item field override the batch default.
|
|
167
|
+
// Batch idempotency predates refresh issuance and is handled by this worker's
|
|
168
|
+
// replay cache. Do not pass that key through the single-issue validator, where
|
|
169
|
+
// idempotencyKey is reserved for refresh requests.
|
|
170
|
+
const { idempotencyKey: _batchIdempotencyKey, ...issueInput } = item;
|
|
171
|
+
const parsed = IssueInboxCredentialValidator.safeParse({
|
|
172
|
+
...issueInput,
|
|
173
|
+
// Explicit item settings win over shared defaults. Keep the existing
|
|
174
|
+
// top-level item flag as a compatibility alias.
|
|
175
|
+
refresh: item.configuration?.refresh ?? item.refresh ?? configuration.refresh,
|
|
176
|
+
configuration,
|
|
177
|
+
});
|
|
178
|
+
if (!parsed.success) {
|
|
179
|
+
throw new TRPCError({
|
|
180
|
+
code: 'BAD_REQUEST',
|
|
181
|
+
message: parsed.error.issues.map(issue => issue.message).join('; '),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const input = parsed.data;
|
|
185
|
+
if (input.configuration?.signingAuthority) {
|
|
186
|
+
input.configuration.signingAuthority.name =
|
|
187
|
+
input.configuration.signingAuthority.name.toLowerCase();
|
|
188
|
+
}
|
|
189
|
+
const requestHash = key ? fingerprint(input) : '';
|
|
190
|
+
if (key) {
|
|
191
|
+
const stored = await replayCache.get(key);
|
|
192
|
+
if (stored === undefined) throw unavailable();
|
|
193
|
+
if (stored) {
|
|
194
|
+
results[index] = replayResult(stored, requestHash, index);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const marker = JSON.stringify({
|
|
198
|
+
state: 'processing',
|
|
199
|
+
owner: randomUUID(),
|
|
200
|
+
requestHash,
|
|
201
|
+
});
|
|
202
|
+
// Reserve before template resolution: it can be slow and two concurrent
|
|
203
|
+
// retries must not both reach issueToInbox. The marker is also the CAS owner.
|
|
204
|
+
const acquired = await replayCache.setIfAbsent(
|
|
205
|
+
key,
|
|
206
|
+
marker,
|
|
207
|
+
IDEMPOTENCY_TTL_SECONDS
|
|
208
|
+
);
|
|
209
|
+
if (acquired === undefined) throw unavailable();
|
|
210
|
+
if (acquired === null) {
|
|
211
|
+
const current = await replayCache.get(key);
|
|
212
|
+
if (current === undefined) throw unavailable();
|
|
213
|
+
if (current === null) {
|
|
214
|
+
// The owner may have released its marker after our failed SET NX.
|
|
215
|
+
// No issuance happened here; a retry with this key can acquire it.
|
|
216
|
+
throw new InboxBatchConflict(
|
|
217
|
+
'IN_PROGRESS',
|
|
218
|
+
'Idempotency reservation changed. Retry this same key.'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
results[index] = replayResult(current, requestHash, index);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
reservation = marker;
|
|
225
|
+
}
|
|
226
|
+
if (input.refresh) await assertInboxRefreshEnabled(ctx.user?.scope);
|
|
227
|
+
const refreshRequestDigest = input.refresh
|
|
228
|
+
? inboxRefreshRequestDigest(input)
|
|
229
|
+
: undefined;
|
|
230
|
+
const { credential, resolvedBoostUri } = await resolveInboxCredentialInput(
|
|
231
|
+
input,
|
|
232
|
+
ctx
|
|
233
|
+
);
|
|
234
|
+
const result = await issueToInbox(
|
|
235
|
+
profile,
|
|
236
|
+
input.recipient,
|
|
237
|
+
credential,
|
|
238
|
+
{
|
|
239
|
+
...input.configuration,
|
|
240
|
+
boostUri: resolvedBoostUri,
|
|
241
|
+
refresh: input.refresh,
|
|
242
|
+
refreshRequestDigest,
|
|
243
|
+
},
|
|
244
|
+
ctx,
|
|
245
|
+
async () => {
|
|
246
|
+
// Preparation (including signing) has not delivered anything yet. Signing
|
|
247
|
+
// retries can leave unused status allocations, but must not block delivery.
|
|
248
|
+
await execution.beforeIssue();
|
|
249
|
+
issuanceStarted = true;
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
const success: Extract<IssueInboxCredentialBatchItemResult, { success: true }> = {
|
|
253
|
+
success: true,
|
|
254
|
+
index,
|
|
255
|
+
issuanceId: result.inboxCredential.id,
|
|
256
|
+
status: result.status,
|
|
257
|
+
recipient: input.recipient,
|
|
258
|
+
idempotencyKey: item.idempotencyKey,
|
|
259
|
+
claimUrl: result.claimUrl,
|
|
260
|
+
recipientDid: result.recipientDid,
|
|
261
|
+
guardianStatus: result.guardianStatus,
|
|
262
|
+
refresh: result.refresh,
|
|
263
|
+
};
|
|
264
|
+
issued = success;
|
|
265
|
+
if (key && reservation) {
|
|
266
|
+
const replay = JSON.stringify({ ...success, requestHash });
|
|
267
|
+
// Retry a transient failure once, always using CAS so a lost owner cannot
|
|
268
|
+
// overwrite another worker. A lost reply may mean the first commit succeeded.
|
|
269
|
+
let saved = await replayCache.compareAndSet(
|
|
270
|
+
key,
|
|
271
|
+
reservation,
|
|
272
|
+
replay,
|
|
273
|
+
IDEMPOTENCY_TTL_SECONDS
|
|
274
|
+
);
|
|
275
|
+
if (saved === undefined) {
|
|
276
|
+
saved = await replayCache.compareAndSet(
|
|
277
|
+
key,
|
|
278
|
+
reservation,
|
|
279
|
+
replay,
|
|
280
|
+
IDEMPOTENCY_TTL_SECONDS
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (!saved) saved = (await replayCache.get(key)) === replay;
|
|
284
|
+
if (!saved)
|
|
285
|
+
throw new TRPCError({
|
|
286
|
+
code: 'CONFLICT',
|
|
287
|
+
message:
|
|
288
|
+
'Credential issued, but its replay record could not be confirmed. Keep this idempotency key and check issuance status before retrying.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
results[index] = success;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error instanceof InboxDeliveryCheckpointError) {
|
|
294
|
+
issued = { ...error.result, success: true, index, recipient: item.recipient };
|
|
295
|
+
// A write may already have happened. Persist the uncertain phase when possible,
|
|
296
|
+
// and always retain the replay reservation to prevent automatic reissuance.
|
|
297
|
+
issuanceStarted = true;
|
|
298
|
+
try {
|
|
299
|
+
await execution.beforeIssue();
|
|
300
|
+
} catch {
|
|
301
|
+
// finishBatchItem still records a terminal conflict if this lease remains.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Only an explicitly marked preflight rejection proves the helper has not
|
|
305
|
+
// written anything. A generic 4xx after a write must retain the reservation.
|
|
306
|
+
const safeToRelease =
|
|
307
|
+
!issuanceStarted || error instanceof InboxIssuancePreflightError;
|
|
308
|
+
if (key && reservation && safeToRelease) {
|
|
309
|
+
// Preparation and explicit preflight failures have no delivery side effects,
|
|
310
|
+
// so freeing the owned marker makes a corrected item retryable.
|
|
311
|
+
try {
|
|
312
|
+
await replayCache.compareAndSet(
|
|
313
|
+
key,
|
|
314
|
+
reservation,
|
|
315
|
+
null,
|
|
316
|
+
IDEMPOTENCY_TTL_SECONDS
|
|
317
|
+
);
|
|
318
|
+
} catch {
|
|
319
|
+
// A lost lease or storage outage must not replace the original outcome.
|
|
320
|
+
// Recovery owns cleanup when this worker can no longer release its marker.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
let itemError: Extract<
|
|
324
|
+
IssueInboxCredentialBatchItemResult,
|
|
325
|
+
{ success: false }
|
|
326
|
+
>['error'] = {
|
|
327
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
328
|
+
message: 'Failed to issue credential',
|
|
329
|
+
};
|
|
330
|
+
if (key && reservation && !safeToRelease) {
|
|
331
|
+
itemError = {
|
|
332
|
+
code: 'CONFLICT',
|
|
333
|
+
reason: 'UNCONFIRMED',
|
|
334
|
+
message: issued
|
|
335
|
+
? 'Credential issued, but replay storage could not be confirmed. Reconcile using issuanceId; do not issue with a new key.'
|
|
336
|
+
: 'Issuance outcome is unconfirmed. Retry this same key later; do not issue with a new key. Contact support if it remains unconfirmed.',
|
|
337
|
+
};
|
|
338
|
+
} else if (error instanceof TRPCError && error.code !== 'INTERNAL_SERVER_ERROR') {
|
|
339
|
+
itemError = {
|
|
340
|
+
code: error.code,
|
|
341
|
+
message: error.message,
|
|
342
|
+
...(error instanceof InboxBatchConflict ? { reason: error.reason } : {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
results[index] = {
|
|
346
|
+
success: false,
|
|
347
|
+
index,
|
|
348
|
+
error: itemError,
|
|
349
|
+
idempotencyKey: item.idempotencyKey,
|
|
350
|
+
recipient: item.recipient,
|
|
351
|
+
...(issued ? { issuanceId: issued.issuanceId, claimUrl: issued.claimUrl } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
await Promise.all(
|
|
357
|
+
Array.from(
|
|
358
|
+
{
|
|
359
|
+
length: Math.min(batch.items.length, INTERNAL_BATCH_CONCURRENCY),
|
|
360
|
+
},
|
|
361
|
+
worker
|
|
362
|
+
)
|
|
363
|
+
);
|
|
364
|
+
const succeeded = results.filter(result => result.success).length;
|
|
365
|
+
return {
|
|
366
|
+
results,
|
|
367
|
+
summary: {
|
|
368
|
+
total: results.length,
|
|
369
|
+
succeeded,
|
|
370
|
+
failed: results.length - succeeded,
|
|
371
|
+
deduplicated: results.filter(result => result.success && result.deduplicated).length,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import type { IssueInboxCredentialBatchItemResult } from '@learncard/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A deterministic rejection before issuance has any persistent or delivery side effects.
|
|
6
|
+
* Only throw this during preflight; batch callers can safely release their reservation.
|
|
7
|
+
* Error codes alone cannot establish this boundary: a later write can also return a 4xx.
|
|
8
|
+
*/
|
|
9
|
+
export class InboxIssuancePreflightError extends TRPCError {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A delivery path returned after writing without first running the queue ownership checkpoint.
|
|
13
|
+
* Batch processing treats this as an uncertain outcome so it can never be retried automatically.
|
|
14
|
+
*/
|
|
15
|
+
export class InboxDeliveryCheckpointError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
public readonly result: Pick<
|
|
18
|
+
Extract<IssueInboxCredentialBatchItemResult, { success: true }>,
|
|
19
|
+
'issuanceId' | 'status' | 'claimUrl' | 'recipientDid' | 'guardianStatus'
|
|
20
|
+
>
|
|
21
|
+
) {
|
|
22
|
+
super('Inbox delivery completed without an ownership checkpoint');
|
|
23
|
+
this.name = 'InboxDeliveryCheckpointError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { IssueInboxCredentialBatchValidator, InboxBatchStatusValidator } from '@learncard/types';
|
|
4
|
+
import type { Context } from '@routes';
|
|
5
|
+
import type { ProfileType } from 'types/profile';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
create: vi.fn(),
|
|
9
|
+
claim: vi.fn(),
|
|
10
|
+
finish: vi.fn(),
|
|
11
|
+
checkpoint: vi.fn(),
|
|
12
|
+
read: vi.fn(),
|
|
13
|
+
profile: vi.fn(),
|
|
14
|
+
resolve: vi.fn(),
|
|
15
|
+
issue: vi.fn(),
|
|
16
|
+
authorize: vi.fn(),
|
|
17
|
+
digest: vi.fn(),
|
|
18
|
+
replay: { get: vi.fn(), setIfAbsent: vi.fn(), compareAndSet: vi.fn() },
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@environment', () => ({
|
|
21
|
+
getInboxBatchRuntimeEnvironment: () => ({ INBOX_QUEUE_URL: 'local' }),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@accesslayer/inbox-batch/store', () => ({
|
|
24
|
+
createBatchJob: mocks.create,
|
|
25
|
+
claimBatchItem: mocks.claim,
|
|
26
|
+
finishBatchItem: mocks.finish,
|
|
27
|
+
markBatchIssuanceStarted: mocks.checkpoint,
|
|
28
|
+
readBatchJob: mocks.read,
|
|
29
|
+
batchReplayStore: () => mocks.replay,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('@accesslayer/profile/read', () => ({ getProfileByProfileId: mocks.profile }));
|
|
32
|
+
vi.mock('./inbox.helpers', () => ({
|
|
33
|
+
resolveInboxCredentialInput: mocks.resolve,
|
|
34
|
+
issueToInbox: async (...args: Parameters<typeof import('./inbox.helpers').issueToInbox>) => {
|
|
35
|
+
await args[5]?.();
|
|
36
|
+
return mocks.issue(...args);
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('./inbox-refresh.helpers', () => ({
|
|
40
|
+
assertInboxRefreshEnabled: mocks.authorize,
|
|
41
|
+
inboxRefreshRequestDigest: mocks.digest,
|
|
42
|
+
}));
|
|
43
|
+
vi.mock('./inbox-encryption.helpers', () => ({
|
|
44
|
+
encryptInboxCredential: async (value: string) => value,
|
|
45
|
+
decryptInboxCredential: async (value: string) => value,
|
|
46
|
+
}));
|
|
47
|
+
import { submitInboxBatch, processInboxQueueMessage, getInboxBatch } from './inbox-queue.helpers';
|
|
48
|
+
|
|
49
|
+
describe('refresh through submission, queue processing and polling', () => {
|
|
50
|
+
const profile = { profileId: 'issuer', did: 'did:example:issuer' } as ProfileType;
|
|
51
|
+
const refresh = {
|
|
52
|
+
refreshId: 'refresh-id',
|
|
53
|
+
credentialId: 'credential-id',
|
|
54
|
+
issuerDid: profile.did,
|
|
55
|
+
refreshService: {
|
|
56
|
+
id: 'https://example.test/refresh',
|
|
57
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const credential = {
|
|
61
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
62
|
+
type: ['VerifiableCredential'],
|
|
63
|
+
issuer: profile.did,
|
|
64
|
+
issuanceDate: '2026-01-01T00:00:00Z',
|
|
65
|
+
credentialSubject: {},
|
|
66
|
+
};
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.resetAllMocks();
|
|
69
|
+
mocks.profile.mockResolvedValue(profile);
|
|
70
|
+
mocks.replay.get.mockResolvedValue(null);
|
|
71
|
+
mocks.replay.setIfAbsent.mockResolvedValue('OK');
|
|
72
|
+
mocks.replay.compareAndSet.mockResolvedValue(true);
|
|
73
|
+
mocks.resolve.mockResolvedValue({ credential, resolvedBoostUri: 'boost:resolved' });
|
|
74
|
+
mocks.digest.mockReturnValue('request-digest');
|
|
75
|
+
mocks.authorize.mockImplementation(async scope => {
|
|
76
|
+
if (scope !== 'inbox:write credentials:write') {
|
|
77
|
+
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
mocks.issue.mockImplementation(async (_profile, _recipient, _credential, options) => ({
|
|
81
|
+
inboxCredential: { id: 'issuance-id' },
|
|
82
|
+
status: 'PENDING',
|
|
83
|
+
...(options.refresh ? { refresh } : {}),
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each([
|
|
88
|
+
{ batchRefresh: true, itemRefresh: undefined, expected: true },
|
|
89
|
+
{ batchRefresh: true, itemRefresh: false, expected: false },
|
|
90
|
+
{ batchRefresh: false, itemRefresh: true, expected: true },
|
|
91
|
+
{ batchRefresh: undefined, itemRefresh: true, expected: true },
|
|
92
|
+
{ batchRefresh: undefined, itemRefresh: undefined, expected: undefined },
|
|
93
|
+
])(
|
|
94
|
+
'preserves batch $batchRefresh / item $itemRefresh through the queue',
|
|
95
|
+
async ({ batchRefresh, itemRefresh, expected }) => {
|
|
96
|
+
const batch = IssueInboxCredentialBatchValidator.parse({
|
|
97
|
+
configuration: { refresh: batchRefresh },
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
recipient: { type: 'email', value: 'first@example.test' },
|
|
101
|
+
templateUri: 'template',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
recipient: { type: 'email', value: 'second@example.test' },
|
|
105
|
+
templateUri: 'template',
|
|
106
|
+
idempotencyKey: 'client-key',
|
|
107
|
+
configuration: { refresh: itemRefresh },
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
await submitInboxBatch(profile, batch, {
|
|
112
|
+
domain: 'example.test',
|
|
113
|
+
user: {
|
|
114
|
+
did: profile.did,
|
|
115
|
+
isChallengeValid: true,
|
|
116
|
+
scope: 'inbox:write credentials:write',
|
|
117
|
+
},
|
|
118
|
+
} as Context);
|
|
119
|
+
const job = {
|
|
120
|
+
id: 'batch-id',
|
|
121
|
+
issuer: profile.profileId,
|
|
122
|
+
createdAt: Date.now(),
|
|
123
|
+
payload: mocks.create.mock.lastCall![0].payload,
|
|
124
|
+
};
|
|
125
|
+
mocks.claim.mockResolvedValue({
|
|
126
|
+
job,
|
|
127
|
+
item: {
|
|
128
|
+
id: 'queued-item',
|
|
129
|
+
index: 1,
|
|
130
|
+
replayKey: 'replay-key',
|
|
131
|
+
duplicate: false,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
await processInboxQueueMessage(JSON.stringify({ itemId: 'queued-item' }));
|
|
135
|
+
expect(mocks.issue).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(mocks.issue.mock.lastCall![1]).toEqual(batch.items[1]!.recipient);
|
|
137
|
+
expect(mocks.issue.mock.lastCall![3]).toMatchObject({
|
|
138
|
+
refresh: expected,
|
|
139
|
+
boostUri: 'boost:resolved',
|
|
140
|
+
refreshRequestDigest: expected ? 'request-digest' : undefined,
|
|
141
|
+
});
|
|
142
|
+
expect(mocks.checkpoint).toHaveBeenCalledWith('queued-item', expect.any(String));
|
|
143
|
+
if (expected) {
|
|
144
|
+
expect(mocks.authorize).toHaveBeenCalledWith('inbox:write credentials:write');
|
|
145
|
+
} else {
|
|
146
|
+
expect(mocks.authorize).not.toHaveBeenCalled();
|
|
147
|
+
expect(mocks.digest).not.toHaveBeenCalled();
|
|
148
|
+
}
|
|
149
|
+
const result = mocks.finish.mock.lastCall![2];
|
|
150
|
+
expect(result).toMatchObject({ success: true, index: 1, idempotencyKey: 'client-key' });
|
|
151
|
+
expect(result.refresh).toEqual(expected ? refresh : undefined);
|
|
152
|
+
mocks.read.mockResolvedValue({
|
|
153
|
+
job,
|
|
154
|
+
items: [{ index: 1, state: 'COMPLETED', result: JSON.stringify(result) }],
|
|
155
|
+
});
|
|
156
|
+
const polled = InboxBatchStatusValidator.parse(
|
|
157
|
+
await getInboxBatch(profile.profileId, job.id)
|
|
158
|
+
);
|
|
159
|
+
expect(polled.done).toBe(true);
|
|
160
|
+
expect(polled.items[0]!.result).toEqual(JSON.parse(JSON.stringify(result)));
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
it.each([undefined, 'inbox:write'])(
|
|
165
|
+
'does not grant refresh permissions to queued jobs with scope %s',
|
|
166
|
+
async scope => {
|
|
167
|
+
mocks.claim.mockResolvedValue({
|
|
168
|
+
job: {
|
|
169
|
+
id: 'batch-id',
|
|
170
|
+
issuer: profile.profileId,
|
|
171
|
+
createdAt: Date.now(),
|
|
172
|
+
payload: JSON.stringify({
|
|
173
|
+
scope,
|
|
174
|
+
context: { domain: 'example.test' },
|
|
175
|
+
batch: {
|
|
176
|
+
configuration: { refresh: true },
|
|
177
|
+
items: [
|
|
178
|
+
{
|
|
179
|
+
recipient: { type: 'email', value: 'a@example.test' },
|
|
180
|
+
templateUri: 'template',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
item: { id: 'queued-item', index: 0, replayKey: 'replay-key', duplicate: false },
|
|
187
|
+
});
|
|
188
|
+
await processInboxQueueMessage(JSON.stringify({ itemId: 'queued-item' }));
|
|
189
|
+
expect(mocks.authorize).toHaveBeenCalledWith(scope);
|
|
190
|
+
expect(mocks.finish.mock.lastCall![2]).toMatchObject({
|
|
191
|
+
success: false,
|
|
192
|
+
error: { code: 'UNAUTHORIZED' },
|
|
193
|
+
});
|
|
194
|
+
expect(mocks.resolve).not.toHaveBeenCalled();
|
|
195
|
+
expect(mocks.issue).not.toHaveBeenCalled();
|
|
196
|
+
expect(mocks.checkpoint).not.toHaveBeenCalled();
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('batch request identity', () => {
|
|
202
|
+
it('retains submitting permissions in the encrypted job for refresh authorization', async () => {
|
|
203
|
+
const profile = { profileId: 'issuer' } as ProfileType;
|
|
204
|
+
await submitInboxBatch(
|
|
205
|
+
profile,
|
|
206
|
+
{
|
|
207
|
+
configuration: { refresh: true },
|
|
208
|
+
items: [
|
|
209
|
+
{
|
|
210
|
+
recipient: { type: 'email', value: 'a@example.test' },
|
|
211
|
+
templateUri: 'template',
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
domain: 'example.test',
|
|
217
|
+
user: { did: 'did:example:issuer', scope: 'inbox:write credentials:write' },
|
|
218
|
+
} as Context
|
|
219
|
+
);
|
|
220
|
+
const payload = JSON.parse(mocks.create.mock.lastCall![0].payload);
|
|
221
|
+
expect(payload.scope).toBe('inbox:write credentials:write');
|
|
222
|
+
expect(payload.batch.configuration.refresh).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('ignores tenant resolution and branding changes, but keeps tenant ID and domain in the hash', async () => {
|
|
226
|
+
const profile = { profileId: 'issuer' } as ProfileType;
|
|
227
|
+
const batch = {
|
|
228
|
+
requestId: 'request',
|
|
229
|
+
items: [
|
|
230
|
+
{
|
|
231
|
+
recipient: { type: 'email' as const, value: 'a@example.test' },
|
|
232
|
+
templateUri: 'template',
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
const submit = async (
|
|
237
|
+
id: string,
|
|
238
|
+
resolvedVia: string,
|
|
239
|
+
brandName: string,
|
|
240
|
+
domain = 'example.test'
|
|
241
|
+
): Promise<string> => {
|
|
242
|
+
await submitInboxBatch(profile, batch, {
|
|
243
|
+
domain,
|
|
244
|
+
tenant: { id, resolvedVia, emailBranding: { brandName } },
|
|
245
|
+
} as Context);
|
|
246
|
+
return mocks.create.mock.lastCall![0].requestHash;
|
|
247
|
+
};
|
|
248
|
+
const original = await submit('tenant', 'header', 'Before');
|
|
249
|
+
expect(await submit('tenant', 'origin', 'After')).toBe(original);
|
|
250
|
+
expect(await submit('other', 'header', 'Before')).not.toBe(original);
|
|
251
|
+
expect(await submit('tenant', 'header', 'Before', 'other.test')).not.toBe(original);
|
|
252
|
+
});
|
|
253
|
+
});
|