@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.
Files changed (199) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +80 -0
  3. package/compose.inbox.yml +7 -0
  4. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  5. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  6. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  7. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  8. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  9. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  10. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  11. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  12. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  13. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  14. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  15. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  16. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  18. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  19. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  20. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  21. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  22. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  23. package/dist/cache/atomic-counter.test.d.ts +2 -0
  24. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  25. package/dist/cache/index.d.ts +1 -1
  26. package/dist/cache/index.d.ts.map +1 -1
  27. package/dist/config/environment.d.ts +46 -0
  28. package/dist/config/environment.d.ts.map +1 -1
  29. package/dist/credential-refresh.d.ts.map +1 -1
  30. package/dist/helpers/activity.helpers.d.ts +4 -0
  31. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  32. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  33. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  34. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  35. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  36. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  37. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  38. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  39. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  40. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  41. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  42. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  43. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  44. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  45. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  46. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  47. package/dist/helpers/credential.helpers.d.ts +2 -1
  48. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  49. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  50. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  51. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  52. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  53. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  54. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  55. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  56. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  57. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  58. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  59. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  60. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  61. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  62. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  63. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  64. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  65. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  66. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  67. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  68. package/dist/helpers/inbox.helpers.d.ts +9 -1
  69. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  71. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  72. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  73. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  74. package/dist/helpers/shim.d.ts +1 -1
  75. package/dist/helpers/shim.d.ts.map +1 -1
  76. package/dist/inbox-queue-local.d.ts +2 -0
  77. package/dist/inbox-queue-local.d.ts.map +1 -0
  78. package/dist/index.js +2 -2
  79. package/dist/models/AuthGrant.d.ts.map +1 -1
  80. package/dist/models/CredentialActivity.d.ts +1 -0
  81. package/dist/models/CredentialActivity.d.ts.map +1 -1
  82. package/dist/models/CredentialRefresh.d.ts +5 -2
  83. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  84. package/dist/models/InboxCredential.d.ts +1 -0
  85. package/dist/models/InboxCredential.d.ts.map +1 -1
  86. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  87. package/dist/openapi.d.ts.map +1 -1
  88. package/dist/routes/act-as.test.d.ts +2 -0
  89. package/dist/routes/act-as.test.d.ts.map +1 -0
  90. package/dist/routes/activity.d.ts +3 -0
  91. package/dist/routes/activity.d.ts.map +1 -1
  92. package/dist/routes/auth-grants.d.ts +10 -0
  93. package/dist/routes/auth-grants.d.ts.map +1 -1
  94. package/dist/routes/boosts.d.ts +494 -0
  95. package/dist/routes/boosts.d.ts.map +1 -1
  96. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  97. package/dist/routes/credentials.d.ts.map +1 -1
  98. package/dist/routes/inbox.d.ts +953 -11
  99. package/dist/routes/inbox.d.ts.map +1 -1
  100. package/dist/routes/index.d.ts +42 -126
  101. package/dist/routes/index.d.ts.map +1 -1
  102. package/dist/routes/workflows.d.ts.map +1 -1
  103. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  104. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  105. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  106. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  107. package/dist/types/activity.d.ts +4 -0
  108. package/dist/types/activity.d.ts.map +1 -1
  109. package/dist/types/credential-refresh.d.ts +10 -1
  110. package/dist/types/credential-refresh.d.ts.map +1 -1
  111. package/dist/types/inbox-batch.d.ts +38 -0
  112. package/dist/types/inbox-batch.d.ts.map +1 -0
  113. package/inbox-e2e-setup.ts +53 -1
  114. package/inbox-elasticmq.conf +10 -0
  115. package/lambda.ts +61 -16
  116. package/package.json +18 -16
  117. package/scripts/setup-inbox-queues.ts +60 -0
  118. package/serverless-local.yml +2 -0
  119. package/serverless.yml +75 -0
  120. package/src/accesslayer/auth-grant/read.ts +2 -1
  121. package/src/accesslayer/credential-activity/create.ts +1 -0
  122. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  123. package/src/accesslayer/credential-refresh/index.ts +1 -0
  124. package/src/accesslayer/credential-refresh/read.ts +8 -2
  125. package/src/accesslayer/credential-refresh/update.ts +4 -0
  126. package/src/accesslayer/inbox-batch/store.ts +469 -0
  127. package/src/accesslayer/inbox-credential/create.ts +51 -19
  128. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  129. package/src/accesslayer/inbox-credential/update.ts +1 -1
  130. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  131. package/src/cache/atomic-counter.test.ts +6 -0
  132. package/src/cache/index.ts +25 -13
  133. package/src/config/environment.ts +26 -0
  134. package/src/credential-refresh.ts +5 -1
  135. package/src/docker-entry.ts +8 -0
  136. package/src/helpers/activity.helpers.ts +4 -0
  137. package/src/helpers/app-counter.helpers.ts +1 -1
  138. package/src/helpers/contact-method.helpers.test.ts +43 -0
  139. package/src/helpers/contact-method.helpers.ts +3 -4
  140. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  141. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  142. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  143. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  144. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  145. package/src/helpers/credential-refresh.helpers.ts +375 -99
  146. package/src/helpers/credential.helpers.ts +4 -1
  147. package/src/helpers/finalize-inbox.helpers.ts +29 -14
  148. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  149. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  150. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  151. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  152. package/src/helpers/inbox-batch.helpers.ts +374 -0
  153. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  154. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  155. package/src/helpers/inbox-queue.helpers.ts +372 -0
  156. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  157. package/src/helpers/inbox.helpers.ts +268 -32
  158. package/src/helpers/notifications.helpers.ts +4 -2
  159. package/src/helpers/rateLimit.helpers.ts +2 -5
  160. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  161. package/src/helpers/shim.ts +32 -31
  162. package/src/inbox-queue-local.ts +28 -0
  163. package/src/models/AuthGrant.ts +1 -0
  164. package/src/models/CredentialActivity.ts +3 -5
  165. package/src/models/CredentialRefresh.ts +4 -2
  166. package/src/models/InboxCredential.ts +2 -0
  167. package/src/models/credential-refresh-constraints.test.ts +8 -3
  168. package/src/models/credential-refresh-constraints.ts +5 -0
  169. package/src/openapi.ts +8 -0
  170. package/src/routes/act-as.test.ts +239 -0
  171. package/src/routes/app-store.ts +2 -2
  172. package/src/routes/auth-grants.ts +13 -11
  173. package/src/routes/boosts.ts +914 -92
  174. package/src/routes/credential-refreshes.test.ts +8 -2
  175. package/src/routes/credential-refreshes.ts +4 -1
  176. package/src/routes/credentials.ts +2 -1
  177. package/src/routes/inbox.ts +95 -49
  178. package/src/routes/index.ts +82 -7
  179. package/src/routes/skills.ts +1 -1
  180. package/src/routes/workflows.ts +43 -24
  181. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  182. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  183. package/src/services/registry/registry.factory.ts +2 -1
  184. package/src/types/activity.ts +2 -0
  185. package/src/types/credential-refresh.ts +21 -2
  186. package/src/types/inbox-batch.ts +46 -0
  187. package/test/atomic-counter.e2e.ts +12 -0
  188. package/test/auth-grant.spec.ts +21 -21
  189. package/test/credential-refresh-email.spec.ts +663 -0
  190. package/test/credential-refresh-publication.spec.ts +2 -0
  191. package/test/helpers/inbox-e2e-environment.ts +6 -0
  192. package/test/inbox-batch.spec.ts +1338 -0
  193. package/test/inbox-refresh.spec.ts +534 -0
  194. package/test/openapi.spec.ts +41 -1
  195. package/test/refresh-send.spec.ts +1799 -0
  196. package/test/registry.spec.ts +56 -11
  197. package/vitest.config.ts +9 -0
  198. package/vitest.inbox-e2e.config.ts +5 -1
  199. package/vitest.integration.config.ts +2 -0
package/lambda.ts CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  import { environment } from './src/config/environment';
26
26
  import { toServerlessApplication } from './src/helpers/serverlessApplication';
27
27
  import { runInboxMaintenance } from './src/helpers/inbox-maintenance.helpers';
28
+ import {
29
+ inboxBatchResponseMeta,
30
+ withInboxBatchBodyLimit,
31
+ } from './src/helpers/inbox-batch-http.helpers';
28
32
 
29
33
  Sentry.AWSLambda.init({
30
34
  dsn: environment.SENTRY_DSN,
@@ -61,8 +65,9 @@ export const credentialRefreshHandler: typeof credentialRefreshProxy = async (ev
61
65
 
62
66
  export const _openApiHandler = createOpenApiAwsLambdaHandler({
63
67
  router: appRouter,
64
- responseMeta: () => {
68
+ responseMeta: meta => {
65
69
  return {
70
+ ...inboxBatchResponseMeta(meta),
66
71
  headers: {
67
72
  'Access-Control-Allow-Origin': '*',
68
73
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
@@ -74,21 +79,24 @@ export const _openApiHandler = createOpenApiAwsLambdaHandler({
74
79
  onError: handleTrpcError,
75
80
  });
76
81
 
77
- export const _trpcHandler = awsLambdaRequestHandler({
78
- allowMethodOverride: true,
79
- router: appRouter,
80
- createContext,
81
- onError: handleTrpcError,
82
- responseMeta: () => {
83
- return {
84
- headers: {
85
- 'Access-Control-Allow-Origin': '*',
86
- 'Access-Control-Allow-Methods': '*',
87
- 'Access-Control-Allow-Headers': 'authorization',
88
- },
89
- };
90
- },
91
- });
82
+ export const _trpcHandler = withInboxBatchBodyLimit(
83
+ awsLambdaRequestHandler({
84
+ allowMethodOverride: true,
85
+ router: appRouter,
86
+ createContext,
87
+ onError: handleTrpcError,
88
+ responseMeta: () => {
89
+ return {
90
+ headers: {
91
+ 'Access-Control-Allow-Origin': '*',
92
+ 'Access-Control-Allow-Methods': '*',
93
+ 'Access-Control-Allow-Headers': 'authorization',
94
+ },
95
+ };
96
+ },
97
+ }),
98
+ 'trpc'
99
+ );
92
100
 
93
101
  export const openApiHandler = Sentry.AWSLambda.wrapHandler(
94
102
  async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
@@ -124,6 +132,43 @@ export const trpcHandler = Sentry.AWSLambda.wrapHandler(
124
132
  }
125
133
  );
126
134
 
135
+ export const inboxQueueWorker: SQSHandler = Sentry.AWSLambda.wrapHandler(async event => {
136
+ const { processInboxQueueMessage } = await import('./src/helpers/inbox-queue.helpers');
137
+ const batchItemFailures = [];
138
+ for (const record of event.Records) {
139
+ try {
140
+ await processInboxQueueMessage(record.body);
141
+ } catch {
142
+ console.error('Inbox worker message failed', { messageId: record.messageId });
143
+ batchItemFailures.push({ itemIdentifier: record.messageId });
144
+ }
145
+ }
146
+ return { batchItemFailures } satisfies SQSBatchResponse;
147
+ });
148
+
149
+ export const inboxQueueDispatcher = Sentry.AWSLambda.wrapHandler(
150
+ async (_event: unknown, context: Context): Promise<void> => {
151
+ const { dispatchInboxJobs } = await import('./src/helpers/inbox-queue.helpers');
152
+ // Share one clock across publication and recovery, retaining five seconds for shutdown.
153
+ const deadline = Date.now() + Math.max(0, context.getRemainingTimeInMillis() - 5_000);
154
+ await dispatchInboxJobs(deadline);
155
+ }
156
+ );
157
+
158
+ export const inboxDeadLetterWorker: SQSHandler = Sentry.AWSLambda.wrapHandler(async event => {
159
+ const { processInboxDeadLetter } = await import('./src/helpers/inbox-queue.helpers');
160
+ const batchItemFailures = [];
161
+ for (const record of event.Records) {
162
+ try {
163
+ await processInboxDeadLetter(record.body);
164
+ } catch {
165
+ console.error('Inbox dead-letter processing failed', { messageId: record.messageId });
166
+ batchItemFailures.push({ itemIdentifier: record.messageId });
167
+ }
168
+ }
169
+ return { batchItemFailures } satisfies SQSBatchResponse;
170
+ });
171
+
127
172
  export const notificationsWorker: SQSHandler = Sentry.AWSLambda.wrapHandler(async event => {
128
173
  const batchItemFailures = await Promise.all(
129
174
  event.Records.map(async record => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/network-brain-service",
3
- "version": "3.18.0",
3
+ "version": "3.19.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -9,6 +9,8 @@
9
9
  "main": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
11
11
  "scripts": {
12
+ "inbox:setup-queues": "bun scripts/setup-inbox-queues.ts",
13
+ "inbox:worker": "bun --conditions=development src/inbox-queue-local.ts",
12
14
  "test": "vitest run --config vitest.config.ts",
13
15
  "test:watch": "vitest --config vitest.config.ts",
14
16
  "test:coverage": "vitest run --coverage --config vitest.config.ts",
@@ -59,21 +61,21 @@
59
61
  "@digitalcredentials/issuer-registry-client": "^3.2.0-beta.5",
60
62
  "@fastify/cors": "^11.2.0",
61
63
  "@google/genai": "^1.27.0",
62
- "@learncard/core": "9.4.35",
63
- "@learncard/crypto-plugin": "1.1.35",
64
- "@learncard/did-web-plugin": "1.1.35",
65
- "@learncard/didkey-plugin": "1.1.35",
66
- "@learncard/didkit-plugin": "1.10.0",
67
- "@learncard/didkit-plugin-node": "0.3.0",
68
- "@learncard/dynamic-loader-plugin": "1.1.35",
69
- "@learncard/encryption-plugin": "1.1.35",
70
- "@learncard/expiration-plugin": "1.2.35",
71
- "@learncard/email-templates": "1.0.6",
72
- "@learncard/helpers": "1.5.1",
73
- "@learncard/learn-card-plugin": "1.2.35",
74
- "@learncard/types": "5.20.0",
75
- "@learncard/vc-plugin": "1.6.1",
76
- "@learncard/vc-templates-plugin": "1.1.35",
64
+ "@learncard/core": "9.4.36",
65
+ "@learncard/crypto-plugin": "1.1.36",
66
+ "@learncard/did-web-plugin": "1.1.36",
67
+ "@learncard/didkey-plugin": "1.1.36",
68
+ "@learncard/didkit-plugin": "1.10.1",
69
+ "@learncard/didkit-plugin-node": "0.3.1",
70
+ "@learncard/dynamic-loader-plugin": "1.1.36",
71
+ "@learncard/encryption-plugin": "1.1.36",
72
+ "@learncard/expiration-plugin": "1.2.36",
73
+ "@learncard/email-templates": "1.1.0",
74
+ "@learncard/helpers": "1.6.0",
75
+ "@learncard/learn-card-plugin": "1.2.36",
76
+ "@learncard/types": "5.21.0",
77
+ "@learncard/vc-plugin": "1.6.2",
78
+ "@learncard/vc-templates-plugin": "1.1.36",
77
79
  "@sentry/serverless": "7.61.0",
78
80
  "@trpc/server": "11.8.0",
79
81
  "@types/lodash": "^4.14.191",
@@ -0,0 +1,60 @@
1
+ import {
2
+ SQSClient,
3
+ CreateQueueCommand,
4
+ GetQueueUrlCommand,
5
+ GetQueueAttributesCommand,
6
+ SetQueueAttributesCommand,
7
+ } from '@aws-sdk/client-sqs';
8
+
9
+ const endpoint = process.env.INBOX_QUEUE_ENDPOINT ?? 'http://localhost:9324';
10
+ const client = new SQSClient({
11
+ region: process.env.AWS_REGION ?? 'us-east-1',
12
+ endpoint,
13
+ credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
14
+ });
15
+
16
+ const ensureQueue = async (name: string): Promise<string> => {
17
+ let url: string | undefined;
18
+ try {
19
+ url = (await client.send(new GetQueueUrlCommand({ QueueName: name }))).QueueUrl;
20
+ } catch (error) {
21
+ if (
22
+ !(error instanceof Error) ||
23
+ !['QueueDoesNotExist', 'AWS.SimpleQueueService.NonExistentQueue'].includes(error.name)
24
+ )
25
+ throw error;
26
+ url = (await client.send(new CreateQueueCommand({ QueueName: name }))).QueueUrl;
27
+ }
28
+ if (!url) throw new Error(`Missing URL for ${name}`);
29
+ // ElasticMQ may advertise its container hostname; use the caller's reachable endpoint.
30
+ return `${endpoint.replace(/\/$/, '')}${new URL(url).pathname}`;
31
+ };
32
+
33
+ try {
34
+ const dlqUrl = await ensureQueue('inbox-dlq');
35
+ const attributes = await client.send(
36
+ new GetQueueAttributesCommand({
37
+ QueueUrl: dlqUrl,
38
+ AttributeNames: ['QueueArn'],
39
+ })
40
+ );
41
+ if (!attributes.Attributes?.QueueArn)
42
+ throw new Error('Inbox dead-letter queue ARN is missing.');
43
+ const queueUrl = await ensureQueue('inbox');
44
+ await client.send(
45
+ new SetQueueAttributesCommand({
46
+ QueueUrl: queueUrl,
47
+ Attributes: {
48
+ VisibilityTimeout: '1800',
49
+ RedrivePolicy: JSON.stringify({
50
+ deadLetterTargetArn: attributes.Attributes.QueueArn,
51
+ maxReceiveCount: '5',
52
+ }),
53
+ },
54
+ })
55
+ );
56
+ console.log(`INBOX_QUEUE_URL=${queueUrl}`);
57
+ console.log(`INBOX_DEAD_LETTER_QUEUE_URL=${dlqUrl}`);
58
+ } finally {
59
+ client.destroy();
60
+ }
@@ -47,6 +47,8 @@ provider:
47
47
  stage: ${opt:stage, "dev"}
48
48
  region: ${opt:region, "us-east-1"}
49
49
  environment:
50
+ INBOX_QUEUE_URL: ${env:INBOX_QUEUE_URL, ''}
51
+ INBOX_QUEUE_ENDPOINT: ${env:INBOX_QUEUE_ENDPOINT, ''}
50
52
  PORT: ${env:PORT, 3000}
51
53
  LAMBDA_STAGE: ${opt:stage, "dev"}
52
54
  SEED: ${env:SEED}
package/serverless.yml CHANGED
@@ -15,6 +15,20 @@ plugins:
15
15
  - serverless-prune-plugin
16
16
 
17
17
  constructs:
18
+ inbox-queue:
19
+ type: queue
20
+ batchSize: 1
21
+ maxConcurrency: 10
22
+ maxRetries: 5
23
+ worker:
24
+ handler: lambda.inboxQueueWorker
25
+ timeout: 300
26
+ reservedConcurrency: 10
27
+ vpc:
28
+ securityGroupIds:
29
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
30
+ subnetIds:
31
+ - Ref: PrivateSubnetA
18
32
  notifications-queue:
19
33
  type: queue
20
34
  worker:
@@ -128,6 +142,7 @@ provider:
128
142
  PORT: ${env:PORT, 3000}
129
143
  INBOX_DELETE_EXPIRED_RECORDS: ${env:INBOX_DELETE_EXPIRED_RECORDS, "false"}
130
144
  LAMBDA_STAGE: ${opt:stage, "dev"}
145
+ INBOX_BATCH_ITEMS_PER_HOUR: ${env:INBOX_BATCH_ITEMS_PER_HOUR, '10000'}
131
146
  SEED: ${env:SEED}
132
147
  DOMAIN_NAME: ${env:DOMAIN_NAME}
133
148
  CLIENT_APP_DOMAIN_NAME: ${env:CLIENT_APP_DOMAIN_NAME}
@@ -153,6 +168,7 @@ provider:
153
168
  REDIS_PORT:
154
169
  'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Port]
155
170
  NOTIFICATIONS_QUEUE_URL: ${construct:notifications-queue.queueUrl}
171
+ INBOX_QUEUE_URL: ${construct:inbox-queue.queueUrl}
156
172
  SKILL_EMBEDDING_GOOGLE_API_KEY: ${env:SKILL_EMBEDDING_GOOGLE_API_KEY, ""}
157
173
  SKILL_EMBEDDING_GOOGLE_MODEL: ${env:SKILL_EMBEDDING_GOOGLE_MODEL, "gemini-embedding-001"}
158
174
  SKILL_EMBEDDING_BATCH_SIZE: ${env:SKILL_EMBEDDING_BATCH_SIZE, "25"}
@@ -190,6 +206,31 @@ provider:
190
206
  - ETag
191
207
 
192
208
  functions:
209
+ inboxDeadLetterWorker:
210
+ handler: lambda.inboxDeadLetterWorker
211
+ timeout: 30
212
+ reservedConcurrency: 2
213
+ vpc:
214
+ securityGroupIds:
215
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
216
+ subnetIds:
217
+ - Ref: PrivateSubnetA
218
+ events:
219
+ - sqs:
220
+ arn: ${construct:inbox-queue.dlqArn}
221
+ batchSize: 1
222
+ functionResponseType: ReportBatchItemFailures
223
+ inboxQueueDispatcher:
224
+ handler: lambda.inboxQueueDispatcher
225
+ timeout: 60
226
+ reservedConcurrency: 1
227
+ vpc:
228
+ securityGroupIds:
229
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
230
+ subnetIds:
231
+ - Ref: PrivateSubnetA
232
+ events:
233
+ - schedule: rate(1 minute)
193
234
  inboxMaintenance:
194
235
  handler: lambda.inboxMaintenanceHandler
195
236
  timeout: 300
@@ -288,6 +329,40 @@ functions:
288
329
 
289
330
  resources:
290
331
  Resources:
332
+ InboxQueueAgeAlarm:
333
+ Type: AWS::CloudWatch::Alarm
334
+ Properties:
335
+ AlarmDescription: Inbox issuance has waited over five minutes
336
+ Namespace: AWS/SQS
337
+ MetricName: ApproximateAgeOfOldestMessage
338
+ Dimensions:
339
+ - Name: QueueName
340
+ Value:
341
+ Fn::Select:
342
+ - 4
343
+ - Fn::Split: ['/', '${construct:inbox-queue.queueUrl}']
344
+ Statistic: Maximum
345
+ Period: 60
346
+ EvaluationPeriods: 2
347
+ Threshold: 300
348
+ ComparisonOperator: GreaterThanThreshold
349
+ TreatMissingData: notBreaching
350
+ InboxDispatcherErrorsAlarm:
351
+ Type: AWS::CloudWatch::Alarm
352
+ Properties:
353
+ AlarmDescription: Durable inbox dispatch or recovery failed in three of five minutes
354
+ Namespace: AWS/Lambda
355
+ MetricName: Errors
356
+ Dimensions:
357
+ - Name: FunctionName
358
+ Value: { Ref: InboxQueueDispatcherLambdaFunction }
359
+ Statistic: Sum
360
+ Period: 60
361
+ EvaluationPeriods: 5
362
+ DatapointsToAlarm: 3
363
+ Threshold: 0
364
+ ComparisonOperator: GreaterThanThreshold
365
+ TreatMissingData: notBreaching
291
366
  VPC:
292
367
  Type: AWS::EC2::VPC
293
368
  Properties:
@@ -98,7 +98,7 @@ export const isAuthGrantAssociatedWithProfile = async (
98
98
  export const isAuthGrantChallengeValidForDID = async (
99
99
  challenge: string,
100
100
  did: string
101
- ): Promise<{ isChallengeValid: boolean; scope: string }> => {
101
+ ): Promise<{ isChallengeValid: boolean; scope: string; actAs?: string }> => {
102
102
  // TODO: Ideally, this transformation isn't required and we could use DID directly.
103
103
  const profileId = getProfileIdFromDid(did);
104
104
  if (!profileId) return { isChallengeValid: false, scope: AUTH_GRANT_NO_ACCESS_SCOPE };
@@ -126,5 +126,6 @@ export const isAuthGrantChallengeValidForDID = async (
126
126
  return {
127
127
  isChallengeValid: result.records.length > 0,
128
128
  scope: authGrant?.scope || AUTH_GRANT_NO_ACCESS_SCOPE,
129
+ actAs: authGrant?.actAs,
129
130
  };
130
131
  };
@@ -23,6 +23,7 @@ export const createCredentialActivity = async (
23
23
  eventType: params.eventType,
24
24
  timestamp,
25
25
  actorProfileId: params.actorProfileId,
26
+ onBehalfOf: params.onBehalfOf,
26
27
  recipientType: params.recipientType,
27
28
  recipientIdentifier: params.recipientIdentifier,
28
29
  boostUri: params.boostUri,
@@ -0,0 +1,116 @@
1
+ import { neogma } from '@instance';
2
+
3
+ /**
4
+ * Persisted, unique claim per (refreshId, delivery window) for the managed
5
+ * credential-refresh update email (LC-2198).
6
+ *
7
+ * Why a dedicated node instead of a conditional property write: Neo4j's
8
+ * read-committed isolation does not re-evaluate a `WHERE` after a write lock is
9
+ * acquired, so two concurrent `SET ... WHERE windowKey <> $key` statements can
10
+ * both "win" and send two emails. A unique constraint on `deliveryKey` makes the
11
+ * claim a hard single-writer: exactly one `CREATE` succeeds, every loser observes
12
+ * a constraint violation and skips.
13
+ *
14
+ * This gives at-most-once email delivery per configured window. A send that fails
15
+ * after the claim is recorded as `failed` and is NOT retried inside the same
16
+ * window (see `credential-refresh-email.helpers.ts` for the explicit policy).
17
+ */
18
+
19
+ export type CredentialRefreshEmailDeliveryState = 'claimed' | 'delivered' | 'failed';
20
+
21
+ const deliveryKeyFor = (refreshId: string, windowKey: string): string =>
22
+ `${refreshId}:${windowKey}`;
23
+
24
+ const isUniqueConstraintViolation = (error: unknown): boolean => {
25
+ const code = (error as { code?: string })?.code;
26
+
27
+ return (
28
+ code === 'Neo.ClientError.Schema.ConstraintValidationFailed' ||
29
+ (error instanceof Error && error.message.includes('already exists'))
30
+ );
31
+ };
32
+
33
+ /**
34
+ * Attempts to claim the email delivery window. Returns `'claimed'` for the single
35
+ * winner and `'duplicate'` for every concurrent or retried caller.
36
+ */
37
+ export const claimCredentialRefreshEmailDelivery = async (params: {
38
+ refreshId: string;
39
+ windowKey: string;
40
+ }): Promise<'claimed' | 'duplicate'> => {
41
+ const { refreshId, windowKey } = params;
42
+ const now = new Date().toISOString();
43
+
44
+ try {
45
+ await neogma.queryRunner.run(
46
+ `CREATE (delivery:CredentialRefreshEmailDelivery {
47
+ deliveryKey: $deliveryKey,
48
+ refreshId: $refreshId,
49
+ windowKey: $windowKey,
50
+ state: 'claimed',
51
+ attempts: 1,
52
+ createdAt: $now,
53
+ updatedAt: $now
54
+ })`,
55
+ {
56
+ deliveryKey: deliveryKeyFor(refreshId, windowKey),
57
+ refreshId,
58
+ windowKey,
59
+ now,
60
+ }
61
+ );
62
+
63
+ return 'claimed';
64
+ } catch (error) {
65
+ if (isUniqueConstraintViolation(error)) return 'duplicate';
66
+
67
+ throw error;
68
+ }
69
+ };
70
+
71
+ /** Records the terminal outcome of a claimed delivery attempt. */
72
+ export const finalizeCredentialRefreshEmailDelivery = async (params: {
73
+ refreshId: string;
74
+ windowKey: string;
75
+ state: Extract<CredentialRefreshEmailDeliveryState, 'delivered' | 'failed'>;
76
+ }): Promise<void> => {
77
+ const { refreshId, windowKey, state } = params;
78
+ const now = new Date().toISOString();
79
+
80
+ await neogma.queryRunner.run(
81
+ `MATCH (delivery:CredentialRefreshEmailDelivery {deliveryKey: $deliveryKey})
82
+ SET delivery.state = $state, delivery.updatedAt = $now`,
83
+ { deliveryKey: deliveryKeyFor(refreshId, windowKey), state, now }
84
+ );
85
+ };
86
+
87
+ /** Reads a delivery record for observability and tests. */
88
+ export const getCredentialRefreshEmailDelivery = async (
89
+ refreshId: string,
90
+ windowKey: string
91
+ ): Promise<{
92
+ deliveryKey: string;
93
+ refreshId: string;
94
+ windowKey: string;
95
+ state: CredentialRefreshEmailDeliveryState;
96
+ attempts: number;
97
+ } | null> => {
98
+ const result = await neogma.queryRunner.run(
99
+ `MATCH (delivery:CredentialRefreshEmailDelivery {deliveryKey: $deliveryKey})
100
+ RETURN delivery LIMIT 1`,
101
+ { deliveryKey: deliveryKeyFor(refreshId, windowKey) }
102
+ );
103
+ const node = result.records[0]?.get('delivery');
104
+
105
+ if (!node) return null;
106
+
107
+ const props = node.properties;
108
+
109
+ return {
110
+ deliveryKey: props.deliveryKey,
111
+ refreshId: props.refreshId,
112
+ windowKey: props.windowKey,
113
+ state: props.state,
114
+ attempts: Number(props.attempts),
115
+ };
116
+ };
@@ -1,3 +1,4 @@
1
1
  export * from './create';
2
+ export * from './email-delivery';
2
3
  export * from './read';
3
4
  export * from './update';
@@ -262,8 +262,14 @@ export const getCredentialRefreshVersions = async (
262
262
  }
263
263
 
264
264
  const result = await neogma.queryRunner.run(
265
- `MATCH (version:Credential {refreshId: $refreshId})
266
- WHERE $beforeVersion IS NULL OR version.version < $beforeVersion
265
+ `CALL {
266
+ MATCH (version:Credential {refreshId: $refreshId}) RETURN version
267
+ UNION ALL
268
+ MATCH (version:InboxRefreshPublication {refreshId: $refreshId})
269
+ WHERE NOT EXISTS { MATCH (:Credential {refreshVersionKey: version.refreshVersionKey}) }
270
+ RETURN version
271
+ }
272
+ WITH version WHERE $beforeVersion IS NULL OR version.version < $beforeVersion
267
273
  RETURN {
268
274
  id: version.id,
269
275
  refreshId: version.refreshId,
@@ -78,6 +78,7 @@ export const advanceCredentialRefreshHead = async (
78
78
  etag,
79
79
  materialDigest,
80
80
  updateSummary,
81
+ credentialDisplayName,
81
82
  effectiveAt,
82
83
  notificationOutcome,
83
84
  notificationId,
@@ -103,6 +104,7 @@ export const advanceCredentialRefreshHead = async (
103
104
  etag,
104
105
  signingMode,
105
106
  updateSummary,
107
+ credentialDisplayName,
106
108
  idempotencyKey,
107
109
  refreshIdempotencyKey,
108
110
  notificationOutcome,
@@ -160,6 +162,7 @@ export const advanceCredentialRefreshHead = async (
160
162
  refresh.etag = $etag,
161
163
  refresh.materialDigest = $materialDigest,
162
164
  refresh.updateSummary = $updateSummary,
165
+ refresh.credentialDisplayName = coalesce($credentialDisplayName, refresh.credentialDisplayName),
163
166
  refresh.signingMode = $signingMode,
164
167
  refresh.lastPublishedAt = $publishedAt,
165
168
  refresh.idempotencyKey = $idempotencyKey,
@@ -176,6 +179,7 @@ export const advanceCredentialRefreshHead = async (
176
179
  etag: etag ?? null,
177
180
  materialDigest: materialDigest ?? null,
178
181
  updateSummary: updateSummary ?? null,
182
+ credentialDisplayName: credentialDisplayName ?? null,
179
183
  signingMode,
180
184
  publishedAt,
181
185
  now,