@learncard/network-brain-service 3.18.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/CHANGELOG.md +80 -0
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/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/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.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 +2 -1
- 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/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/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/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/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +494 -0
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +953 -11
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +42 -126
- package/dist/routes/index.d.ts.map +1 -1
- 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/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- 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/auth-grant/read.ts +2 -1
- 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/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 +375 -99
- package/src/helpers/credential.helpers.ts +4 -1
- package/src/helpers/finalize-inbox.helpers.ts +29 -14
- 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 +268 -32
- 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/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.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 +2 -2
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +914 -92
- 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 +82 -7
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +43 -24
- 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/credential-refresh.ts +21 -2
- package/src/types/inbox-batch.ts +46 -0
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +2 -0
- 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/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/vitest.config.ts +9 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import type { ManagedCredentialRefreshReceipt, SendBoostResponse } from '@learncard/types';
|
|
4
|
+
import { canonicalizeCredentialJson } from '@learncard/helpers';
|
|
5
|
+
|
|
6
|
+
import { neogma } from '@instance';
|
|
7
|
+
|
|
8
|
+
import { getInitialRefreshRoot, getManagedCredentialUri } from './credential-refresh.helpers';
|
|
9
|
+
|
|
10
|
+
/** A preparation not touched for this long is considered abandoned and may be taken over. */
|
|
11
|
+
export const REFRESH_SEND_INTENT_STALE_MS = 5 * 60_000;
|
|
12
|
+
|
|
13
|
+
export type RefreshSendIntentState = 'preparing' | 'prepared' | 'delivered';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whole-call idempotency record for a managed refresh send (LC-2198 follow-up).
|
|
17
|
+
* Metadata only: never stores credential claims, subject bodies, plaintext VCs or JWEs.
|
|
18
|
+
*/
|
|
19
|
+
export type RefreshSendIntent = {
|
|
20
|
+
intentKey: string;
|
|
21
|
+
issuerProfileId: string;
|
|
22
|
+
requestDigest: string;
|
|
23
|
+
state: RefreshSendIntentState;
|
|
24
|
+
claimToken: string;
|
|
25
|
+
boostUri?: string;
|
|
26
|
+
credentialId?: string;
|
|
27
|
+
refreshId?: string;
|
|
28
|
+
holderDid?: string;
|
|
29
|
+
pendingReceipt?: ManagedCredentialRefreshReceipt;
|
|
30
|
+
pendingActivityId?: string;
|
|
31
|
+
result?: SendBoostResponse;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ClaimRefreshSendIntentResult =
|
|
36
|
+
| { kind: 'owned'; intent: RefreshSendIntent }
|
|
37
|
+
| { kind: 'prepared'; intent: RefreshSendIntent }
|
|
38
|
+
| { kind: 'delivered'; intent: RefreshSendIntent };
|
|
39
|
+
|
|
40
|
+
const getIntentKey = (issuerProfileId: string, idempotencyKey: string): string =>
|
|
41
|
+
`${issuerProfileId}:${idempotencyKey}`;
|
|
42
|
+
|
|
43
|
+
/** Digest of everything that defines a prepare request; a reused key must match it. */
|
|
44
|
+
export const computeRefreshSendRequestDigest = (request: {
|
|
45
|
+
recipientProfileId: string;
|
|
46
|
+
holderDid: string;
|
|
47
|
+
templateUri?: string;
|
|
48
|
+
template?: unknown;
|
|
49
|
+
contractUri?: string;
|
|
50
|
+
templateData?: Record<string, unknown>;
|
|
51
|
+
integrationId?: string;
|
|
52
|
+
credentialId?: string;
|
|
53
|
+
}): string =>
|
|
54
|
+
createHash('sha256')
|
|
55
|
+
.update(
|
|
56
|
+
canonicalizeCredentialJson({
|
|
57
|
+
recipientProfileId: request.recipientProfileId,
|
|
58
|
+
holderDid: request.holderDid,
|
|
59
|
+
templateUri: request.templateUri ?? null,
|
|
60
|
+
template: request.template ?? null,
|
|
61
|
+
contractUri: request.contractUri ?? null,
|
|
62
|
+
templateData: request.templateData ?? {},
|
|
63
|
+
integrationId: request.integrationId ?? null,
|
|
64
|
+
credentialId: request.credentialId ?? null,
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
.digest('base64url');
|
|
68
|
+
|
|
69
|
+
const parseIntent = (props: Record<string, unknown>): RefreshSendIntent => ({
|
|
70
|
+
intentKey: props.intentKey as string,
|
|
71
|
+
issuerProfileId: props.issuerProfileId as string,
|
|
72
|
+
requestDigest: props.requestDigest as string,
|
|
73
|
+
state: props.state as RefreshSendIntentState,
|
|
74
|
+
claimToken: props.claimToken as string,
|
|
75
|
+
boostUri: (props.boostUri as string | undefined) ?? undefined,
|
|
76
|
+
credentialId: (props.credentialId as string | undefined) ?? undefined,
|
|
77
|
+
refreshId: (props.refreshId as string | undefined) ?? undefined,
|
|
78
|
+
holderDid: (props.holderDid as string | undefined) ?? undefined,
|
|
79
|
+
pendingReceipt: props.pendingReceipt
|
|
80
|
+
? (JSON.parse(props.pendingReceipt as string) as ManagedCredentialRefreshReceipt)
|
|
81
|
+
: undefined,
|
|
82
|
+
pendingActivityId: (props.pendingActivityId as string | undefined) ?? undefined,
|
|
83
|
+
result: props.result ? (JSON.parse(props.result as string) as SendBoostResponse) : undefined,
|
|
84
|
+
updatedAt: props.updatedAt as string,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const getRefreshSendIntent = async (
|
|
88
|
+
issuerProfileId: string,
|
|
89
|
+
idempotencyKey: string
|
|
90
|
+
): Promise<RefreshSendIntent | null> => {
|
|
91
|
+
const result = await neogma.queryRunner.run(
|
|
92
|
+
'MATCH (i:RefreshSendIntent {intentKey: $intentKey}) RETURN i',
|
|
93
|
+
{ intentKey: getIntentKey(issuerProfileId, idempotencyKey) }
|
|
94
|
+
);
|
|
95
|
+
const node = result.records[0]?.get('i');
|
|
96
|
+
|
|
97
|
+
return node ? parseIntent(node.properties) : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Creates or claims the intent for (issuer, idempotencyKey). Concurrency-safe through
|
|
102
|
+
* the `refresh_send_intent_key_unique` constraint. Throws CONFLICT when the key was used
|
|
103
|
+
* for a different request, or when another live request is still preparing it.
|
|
104
|
+
*/
|
|
105
|
+
export const claimRefreshSendIntent = async (params: {
|
|
106
|
+
issuerProfileId: string;
|
|
107
|
+
idempotencyKey: string;
|
|
108
|
+
requestDigest: string;
|
|
109
|
+
}): Promise<ClaimRefreshSendIntentResult> => {
|
|
110
|
+
const { issuerProfileId, idempotencyKey, requestDigest } = params;
|
|
111
|
+
const claimToken = randomUUID();
|
|
112
|
+
const now = new Date();
|
|
113
|
+
|
|
114
|
+
const result = await neogma.queryRunner.run(
|
|
115
|
+
`MERGE (i:RefreshSendIntent {intentKey: $intentKey})
|
|
116
|
+
ON CREATE SET i.issuerProfileId = $issuerProfileId,
|
|
117
|
+
i.requestDigest = $requestDigest,
|
|
118
|
+
i.state = 'preparing',
|
|
119
|
+
i.claimToken = $claimToken,
|
|
120
|
+
i.createdAt = $now,
|
|
121
|
+
i.updatedAt = $now
|
|
122
|
+
SET i.updatedAt = i.updatedAt
|
|
123
|
+
WITH i
|
|
124
|
+
CALL {
|
|
125
|
+
WITH i
|
|
126
|
+
WITH i WHERE i.state = 'preparing'
|
|
127
|
+
AND i.claimToken <> $claimToken
|
|
128
|
+
AND i.requestDigest = $requestDigest
|
|
129
|
+
AND i.updatedAt < $staleBefore
|
|
130
|
+
SET i.claimToken = $claimToken, i.updatedAt = $now
|
|
131
|
+
}
|
|
132
|
+
RETURN i`,
|
|
133
|
+
{
|
|
134
|
+
intentKey: getIntentKey(issuerProfileId, idempotencyKey),
|
|
135
|
+
issuerProfileId,
|
|
136
|
+
requestDigest,
|
|
137
|
+
claimToken,
|
|
138
|
+
now: now.toISOString(),
|
|
139
|
+
staleBefore: new Date(now.getTime() - REFRESH_SEND_INTENT_STALE_MS).toISOString(),
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
const intent = parseIntent(result.records[0]!.get('i').properties);
|
|
143
|
+
|
|
144
|
+
if (intent.requestDigest !== requestDigest) {
|
|
145
|
+
throw new TRPCError({
|
|
146
|
+
code: 'CONFLICT',
|
|
147
|
+
message: 'This idempotencyKey was already used for a different send request.',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (intent.state === 'delivered') return { kind: 'delivered', intent };
|
|
152
|
+
if (intent.state === 'prepared') return { kind: 'prepared', intent };
|
|
153
|
+
if (intent.claimToken === claimToken) return { kind: 'owned', intent };
|
|
154
|
+
|
|
155
|
+
throw new TRPCError({
|
|
156
|
+
code: 'CONFLICT',
|
|
157
|
+
message: 'A send with this idempotencyKey is still being prepared. Retry shortly.',
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const lostIntentClaim = (): TRPCError =>
|
|
162
|
+
new TRPCError({
|
|
163
|
+
code: 'CONFLICT',
|
|
164
|
+
message:
|
|
165
|
+
'This send preparation was taken over or completed. Retry with the same idempotencyKey.',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Records progress on an intent owned by `claimToken` (boost first, then allocation).
|
|
170
|
+
* The self-dependent SET takes the node write lock before checking ownership, so
|
|
171
|
+
* a concurrent takeover cannot pass a stale check and overwrite the new owner.
|
|
172
|
+
*/
|
|
173
|
+
export const recordRefreshSendIntent = async (
|
|
174
|
+
intent: RefreshSendIntent,
|
|
175
|
+
fields: Partial<
|
|
176
|
+
Pick<RefreshSendIntent, 'boostUri' | 'credentialId' | 'refreshId' | 'holderDid'>
|
|
177
|
+
> & { state?: 'prepared' }
|
|
178
|
+
): Promise<RefreshSendIntent> => {
|
|
179
|
+
const result = await neogma.queryRunner.run(
|
|
180
|
+
`MATCH (i:RefreshSendIntent {intentKey: $intentKey})
|
|
181
|
+
SET i.updatedAt = i.updatedAt
|
|
182
|
+
WITH i WHERE i.claimToken = $claimToken AND i.state = 'preparing'
|
|
183
|
+
SET i += $fields, i.updatedAt = $now
|
|
184
|
+
RETURN i`,
|
|
185
|
+
{
|
|
186
|
+
intentKey: intent.intentKey,
|
|
187
|
+
claimToken: intent.claimToken,
|
|
188
|
+
fields: Object.fromEntries(
|
|
189
|
+
Object.entries(fields).filter(([, value]) => value !== undefined)
|
|
190
|
+
),
|
|
191
|
+
now: new Date().toISOString(),
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!result.records.length) throw lostIntentClaim();
|
|
196
|
+
return parseIntent(result.records[0]!.get('i').properties);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Stores the metadata-only receipt (and activity) right before delivery is attempted. */
|
|
200
|
+
export const recordRefreshSendIntentPending = async (
|
|
201
|
+
intent: RefreshSendIntent,
|
|
202
|
+
pendingReceipt: ManagedCredentialRefreshReceipt,
|
|
203
|
+
pendingActivityId?: string
|
|
204
|
+
): Promise<void> => {
|
|
205
|
+
const updated = await neogma.queryRunner.run(
|
|
206
|
+
`MATCH (i:RefreshSendIntent {intentKey: $intentKey})
|
|
207
|
+
SET i.updatedAt = i.updatedAt
|
|
208
|
+
WITH i WHERE i.claimToken = $claimToken AND i.state = 'prepared'
|
|
209
|
+
SET i.pendingReceipt = $pendingReceipt,
|
|
210
|
+
i.pendingActivityId = coalesce($pendingActivityId, i.pendingActivityId),
|
|
211
|
+
i.updatedAt = $now
|
|
212
|
+
RETURN i`,
|
|
213
|
+
{
|
|
214
|
+
intentKey: intent.intentKey,
|
|
215
|
+
claimToken: intent.claimToken,
|
|
216
|
+
pendingReceipt: JSON.stringify(pendingReceipt),
|
|
217
|
+
pendingActivityId: pendingActivityId ?? null,
|
|
218
|
+
now: new Date().toISOString(),
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
if (!updated.records.length) throw lostIntentClaim();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const markRefreshSendIntentDelivered = async (
|
|
225
|
+
intent: RefreshSendIntent,
|
|
226
|
+
result: SendBoostResponse
|
|
227
|
+
): Promise<void> => {
|
|
228
|
+
const updated = await neogma.queryRunner.run(
|
|
229
|
+
`MATCH (i:RefreshSendIntent {intentKey: $intentKey})
|
|
230
|
+
SET i.updatedAt = i.updatedAt
|
|
231
|
+
WITH i WHERE i.claimToken = $claimToken
|
|
232
|
+
AND (i.state = 'prepared' OR (i.state = 'delivered' AND i.result = $result))
|
|
233
|
+
SET i.state = 'delivered', i.result = $result, i.updatedAt = $now
|
|
234
|
+
RETURN i`,
|
|
235
|
+
{
|
|
236
|
+
intentKey: intent.intentKey,
|
|
237
|
+
claimToken: intent.claimToken,
|
|
238
|
+
// The atomic replay comparison must ignore object property insertion order.
|
|
239
|
+
result: canonicalizeCredentialJson(result),
|
|
240
|
+
now: new Date().toISOString(),
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
if (!updated.records.length) throw lostIntentClaim();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* When an intent's refresh is already bound (delivery succeeded but the result was never
|
|
248
|
+
* recorded), rebuild the result from the pending receipt and mark it delivered. Returns
|
|
249
|
+
* null when nothing is bound yet.
|
|
250
|
+
*/
|
|
251
|
+
export const reconcileBoundRefreshSendIntent = async (
|
|
252
|
+
intent: RefreshSendIntent,
|
|
253
|
+
domain: string
|
|
254
|
+
): Promise<SendBoostResponse | null> => {
|
|
255
|
+
if (intent.result) return intent.result;
|
|
256
|
+
if (!intent.refreshId || !intent.boostUri || !intent.pendingReceipt) return null;
|
|
257
|
+
|
|
258
|
+
const root = await getInitialRefreshRoot(intent.refreshId);
|
|
259
|
+
|
|
260
|
+
if (!root) return null;
|
|
261
|
+
|
|
262
|
+
const result: SendBoostResponse = {
|
|
263
|
+
type: 'boost',
|
|
264
|
+
uri: intent.boostUri,
|
|
265
|
+
credentialUri: getManagedCredentialUri(root.rootId, domain),
|
|
266
|
+
activityId: intent.pendingActivityId ?? '',
|
|
267
|
+
refresh: intent.pendingReceipt,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
await markRefreshSendIntentDelivered(intent, result);
|
|
271
|
+
|
|
272
|
+
return result;
|
|
273
|
+
};
|
package/src/helpers/shim.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type Context as AWSContext,
|
|
6
6
|
} from 'aws-lambda';
|
|
7
7
|
import { TRPCError } from '@trpc/server';
|
|
8
|
+
import { withInboxBatchBodyLimit } from './inbox-batch-http.helpers';
|
|
8
9
|
import { type IncomingMessage, type ServerResponse } from 'http';
|
|
9
10
|
|
|
10
11
|
import { OpenApiRouter } from 'trpc-to-openapi';
|
|
@@ -183,36 +184,36 @@ export const createOpenApiAwsLambdaHandler = <TRouter extends OpenApiRouter>(
|
|
|
183
184
|
maxBodySize: undefined, // AWS Lambda handles body size limits
|
|
184
185
|
});
|
|
185
186
|
|
|
186
|
-
return
|
|
187
|
-
event: AWSAPIGatewayEvent,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
headers: {
|
|
207
|
-
'Content-Type': 'application/json',
|
|
208
|
-
},
|
|
209
|
-
body: JSON.stringify({
|
|
210
|
-
error: {
|
|
211
|
-
message: error.message || 'Internal server error',
|
|
212
|
-
code: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
|
|
187
|
+
return withInboxBatchBodyLimit(
|
|
188
|
+
async (event: AWSAPIGatewayEvent, _context: AWSContext): Promise<APIGatewayProxyResult> => {
|
|
189
|
+
try {
|
|
190
|
+
const req = createRequestFromEvent(event);
|
|
191
|
+
const { res, promise } = createResponseHandler();
|
|
192
|
+
|
|
193
|
+
// Execute the handler
|
|
194
|
+
await openApiHttpHandler(req, res);
|
|
195
|
+
|
|
196
|
+
return await promise;
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
// Handle any uncaught errors
|
|
199
|
+
const error = cause instanceof Error ? cause : new Error('Unknown error');
|
|
200
|
+
const statusCode =
|
|
201
|
+
error instanceof TRPCError ? (error.code === 'NOT_FOUND' ? 404 : 500) : 500;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
statusCode,
|
|
205
|
+
headers: {
|
|
206
|
+
'Content-Type': 'application/json',
|
|
213
207
|
},
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
error: {
|
|
210
|
+
message: error.message || 'Internal server error',
|
|
211
|
+
code: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
} satisfies APIGatewayProxyResult;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
'openapi'
|
|
218
|
+
);
|
|
218
219
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
consumeInboxQueueOnce,
|
|
3
|
+
consumeInboxDeadLettersOnce,
|
|
4
|
+
dispatchInboxJobs,
|
|
5
|
+
} from './helpers/inbox-queue.helpers';
|
|
6
|
+
|
|
7
|
+
let running = true;
|
|
8
|
+
process.on('SIGINT', () => {
|
|
9
|
+
running = false;
|
|
10
|
+
});
|
|
11
|
+
process.on('SIGTERM', () => {
|
|
12
|
+
running = false;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Run separately from the HTTP process; restarting either process leaves durable jobs intact. */
|
|
16
|
+
const run = async (): Promise<void> => {
|
|
17
|
+
while (running) {
|
|
18
|
+
try {
|
|
19
|
+
await dispatchInboxJobs();
|
|
20
|
+
await consumeInboxQueueOnce(10);
|
|
21
|
+
await consumeInboxDeadLettersOnce();
|
|
22
|
+
} catch {
|
|
23
|
+
console.error('Inbox queue iteration failed; durable jobs will be retried.');
|
|
24
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
void run();
|
package/src/models/AuthGrant.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const AuthGrant = ModelFactory<FlatAuthGrantType, AuthGrantRelationships>
|
|
|
21
21
|
challenge: { type: 'string', required: true, uniqueItems: true },
|
|
22
22
|
status: { type: 'string', required: true },
|
|
23
23
|
scope: { type: 'string', required: true },
|
|
24
|
+
actAs: { type: 'string', required: false },
|
|
24
25
|
createdAt: { type: 'string', required: true },
|
|
25
26
|
expiresAt: { type: 'string', required: false },
|
|
26
27
|
},
|
|
@@ -7,11 +7,7 @@ import { Boost, BoostInstance } from './Boost';
|
|
|
7
7
|
import { AppStoreListing, AppStoreListingInstance } from './AppStoreListing';
|
|
8
8
|
|
|
9
9
|
export type CredentialActivityEventType =
|
|
10
|
-
| '
|
|
11
|
-
| 'DELIVERED'
|
|
12
|
-
| 'CLAIMED'
|
|
13
|
-
| 'EXPIRED'
|
|
14
|
-
| 'FAILED';
|
|
10
|
+
'CREATED' | 'DELIVERED' | 'CLAIMED' | 'EXPIRED' | 'FAILED';
|
|
15
11
|
|
|
16
12
|
export type CredentialActivityRecipientType = 'profile' | 'email' | 'phone';
|
|
17
13
|
|
|
@@ -32,6 +28,7 @@ export type CredentialActivityType = {
|
|
|
32
28
|
eventType: CredentialActivityEventType;
|
|
33
29
|
timestamp: string;
|
|
34
30
|
actorProfileId?: string;
|
|
31
|
+
onBehalfOf?: string;
|
|
35
32
|
recipientType: CredentialActivityRecipientType;
|
|
36
33
|
recipientIdentifier: string;
|
|
37
34
|
boostUri?: string;
|
|
@@ -70,6 +67,7 @@ export const CredentialActivity = ModelFactory<
|
|
|
70
67
|
},
|
|
71
68
|
timestamp: { type: 'string', required: true },
|
|
72
69
|
actorProfileId: { type: 'string', required: false },
|
|
70
|
+
onBehalfOf: { type: 'string', required: false },
|
|
73
71
|
recipientType: {
|
|
74
72
|
type: 'string',
|
|
75
73
|
required: true,
|
|
@@ -36,16 +36,18 @@ export const CredentialRefresh = ModelFactory<
|
|
|
36
36
|
{
|
|
37
37
|
label: 'CredentialRefresh',
|
|
38
38
|
schema: {
|
|
39
|
+
inboxCredentialId: { type: 'string', required: false },
|
|
40
|
+
inboxCredentialStatus: { type: 'string', required: false },
|
|
39
41
|
refreshId: { type: 'string', required: true },
|
|
40
42
|
issuerProfileId: { type: 'string', required: true },
|
|
41
43
|
issuerDid: { type: 'string', required: true },
|
|
42
44
|
holderProfileId: { type: 'string', required: false },
|
|
43
|
-
holderDid: { type: 'string', required:
|
|
45
|
+
holderDid: { type: 'string', required: false },
|
|
44
46
|
credentialId: { type: 'string', required: true },
|
|
45
47
|
state: {
|
|
46
48
|
type: 'string',
|
|
47
49
|
required: true,
|
|
48
|
-
enum: ['awaiting_claim', 'active', 'revoked'],
|
|
50
|
+
enum: ['pending_holder', 'awaiting_claim', 'active', 'revoked'],
|
|
49
51
|
},
|
|
50
52
|
currentVersion: { type: 'number', required: true },
|
|
51
53
|
etag: { type: 'string', required: false },
|
|
@@ -6,6 +6,7 @@ import { Profile, ProfileInstance } from './Profile';
|
|
|
6
6
|
import ContactMethod, { ContactMethodInstance } from './ContactMethod';
|
|
7
7
|
|
|
8
8
|
export type InboxCredentialType = {
|
|
9
|
+
refreshId?: string;
|
|
9
10
|
id: string;
|
|
10
11
|
credential?: string; // Versioned JWE; removed immediately after successful finalization
|
|
11
12
|
isSigned: boolean;
|
|
@@ -86,6 +87,7 @@ export const InboxCredential = ModelFactory<InboxCredentialType, InboxCredential
|
|
|
86
87
|
{
|
|
87
88
|
label: 'InboxCredential',
|
|
88
89
|
schema: {
|
|
90
|
+
refreshId: { type: 'string', required: false },
|
|
89
91
|
id: { type: 'string', required: true, uniqueItems: true },
|
|
90
92
|
credential: { type: 'string', required: false },
|
|
91
93
|
isSigned: { type: 'boolean', required: true },
|
|
@@ -38,11 +38,16 @@ describe('credential refresh constraint readiness', () => {
|
|
|
38
38
|
releaseFirstQuery();
|
|
39
39
|
await Promise.all([first, second]);
|
|
40
40
|
|
|
41
|
-
expect(mocks.run).toHaveBeenCalledTimes(
|
|
41
|
+
expect(mocks.run).toHaveBeenCalledTimes(8);
|
|
42
42
|
expect(mocks.run.mock.calls.map(([query]) => query)).toEqual([
|
|
43
|
+
'CREATE CONSTRAINT inbox_refresh_issue_key_unique IF NOT EXISTS FOR (i:InboxCredential) REQUIRE (i.refreshIssueKey) IS UNIQUE',
|
|
44
|
+
'CREATE CONSTRAINT inbox_refresh_publication_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshIdempotencyKey) IS UNIQUE',
|
|
45
|
+
'CREATE CONSTRAINT inbox_refresh_version_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshVersionKey) IS UNIQUE',
|
|
43
46
|
'CREATE CONSTRAINT credential_refresh_id_unique IF NOT EXISTS FOR (r:CredentialRefresh) REQUIRE (r.refreshId) IS UNIQUE',
|
|
44
47
|
'CREATE CONSTRAINT credential_refresh_version_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshVersionKey) IS UNIQUE',
|
|
45
48
|
'CREATE CONSTRAINT credential_refresh_idempotency_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshIdempotencyKey) IS UNIQUE',
|
|
49
|
+
'CREATE CONSTRAINT credential_refresh_email_delivery_key_unique IF NOT EXISTS FOR (d:CredentialRefreshEmailDelivery) REQUIRE (d.deliveryKey) IS UNIQUE',
|
|
50
|
+
'CREATE CONSTRAINT refresh_send_intent_key_unique IF NOT EXISTS FOR (i:RefreshSendIntent) REQUIRE (i.intentKey) IS UNIQUE',
|
|
46
51
|
]);
|
|
47
52
|
});
|
|
48
53
|
|
|
@@ -57,7 +62,7 @@ describe('credential refresh constraint readiness', () => {
|
|
|
57
62
|
await import('./credential-refresh-constraints');
|
|
58
63
|
|
|
59
64
|
await expect(ensureCredentialRefreshConstraints()).resolves.toBeUndefined();
|
|
60
|
-
expect(mocks.run).toHaveBeenCalledTimes(
|
|
65
|
+
expect(mocks.run).toHaveBeenCalledTimes(8);
|
|
61
66
|
});
|
|
62
67
|
|
|
63
68
|
it('clears failed readiness so a later request can retry', async () => {
|
|
@@ -70,6 +75,6 @@ describe('credential refresh constraint readiness', () => {
|
|
|
70
75
|
await expect(ensureCredentialRefreshConstraints()).rejects.toBe(setupFailure);
|
|
71
76
|
await expect(ensureCredentialRefreshConstraints()).resolves.toBeUndefined();
|
|
72
77
|
|
|
73
|
-
expect(mocks.run).toHaveBeenCalledTimes(
|
|
78
|
+
expect(mocks.run).toHaveBeenCalledTimes(9);
|
|
74
79
|
});
|
|
75
80
|
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { neogma } from '@instance';
|
|
2
2
|
|
|
3
3
|
const CREDENTIAL_REFRESH_CONSTRAINT_QUERIES = [
|
|
4
|
+
'CREATE CONSTRAINT inbox_refresh_issue_key_unique IF NOT EXISTS FOR (i:InboxCredential) REQUIRE (i.refreshIssueKey) IS UNIQUE',
|
|
5
|
+
'CREATE CONSTRAINT inbox_refresh_publication_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshIdempotencyKey) IS UNIQUE',
|
|
6
|
+
'CREATE CONSTRAINT inbox_refresh_version_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshVersionKey) IS UNIQUE',
|
|
4
7
|
'CREATE CONSTRAINT credential_refresh_id_unique IF NOT EXISTS FOR (r:CredentialRefresh) REQUIRE (r.refreshId) IS UNIQUE',
|
|
5
8
|
'CREATE CONSTRAINT credential_refresh_version_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshVersionKey) IS UNIQUE',
|
|
6
9
|
'CREATE CONSTRAINT credential_refresh_idempotency_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshIdempotencyKey) IS UNIQUE',
|
|
10
|
+
'CREATE CONSTRAINT credential_refresh_email_delivery_key_unique IF NOT EXISTS FOR (d:CredentialRefreshEmailDelivery) REQUIRE (d.deliveryKey) IS UNIQUE',
|
|
11
|
+
'CREATE CONSTRAINT refresh_send_intent_key_unique IF NOT EXISTS FOR (i:RefreshSendIntent) REQUIRE (i.intentKey) IS UNIQUE',
|
|
7
12
|
];
|
|
8
13
|
|
|
9
14
|
type Neo4jSchemaError = {
|
package/src/openapi.ts
CHANGED
|
@@ -25,6 +25,14 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
|
|
|
25
25
|
],
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// Keep this path/status aligned with inboxBatchResponseMeta in helpers/inbox-batch-http.helpers.ts.
|
|
29
|
+
// The adapter's responseMeta returns 202 for durable batch acceptance.
|
|
30
|
+
const batchResponses = openApiDocument.paths?.['/inbox/issue-batch']?.post?.responses;
|
|
31
|
+
if (batchResponses?.['200']) {
|
|
32
|
+
batchResponses['202'] = batchResponses['200'];
|
|
33
|
+
delete batchResponses['200'];
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
// trpc-to-openapi requires a top-level ZodObject and cannot render the validator's
|
|
29
37
|
// cross-field refinement. Preserve the runtime-safe object parser while documenting
|
|
30
38
|
// the two mutually exclusive publication shapes for generated OpenAPI clients.
|