@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,221 @@
|
|
|
1
|
+
import { CredentialRefreshRecordValidator } from 'types/credential-refresh';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
import { neogma } from '@instance';
|
|
5
|
+
import type { InboxCredentialType, PublishCredentialRefreshResult } from '@learncard/types';
|
|
6
|
+
import { inflateObject } from '@helpers/objects.helpers';
|
|
7
|
+
|
|
8
|
+
export const getInboxRefreshIssue = async (
|
|
9
|
+
issueKey: string
|
|
10
|
+
): Promise<{
|
|
11
|
+
inbox: InboxCredentialType;
|
|
12
|
+
requestDigest: string;
|
|
13
|
+
claimUrl?: string;
|
|
14
|
+
} | null> => {
|
|
15
|
+
const result = await neogma.queryRunner.run(
|
|
16
|
+
'MATCH (ic:InboxCredential {refreshIssueKey: $issueKey}) RETURN ic LIMIT 1',
|
|
17
|
+
{ issueKey }
|
|
18
|
+
);
|
|
19
|
+
const props = result.records[0]?.get('ic')?.properties;
|
|
20
|
+
return props
|
|
21
|
+
? {
|
|
22
|
+
inbox: inflateObject(props),
|
|
23
|
+
requestDigest: props.refreshRequestDigest,
|
|
24
|
+
claimUrl: props.refreshClaimUrl,
|
|
25
|
+
}
|
|
26
|
+
: null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Creates the inbox row and unbound aggregate together; a retry cannot allocate a second root. */
|
|
30
|
+
export const createPendingInboxRefresh = async (params: {
|
|
31
|
+
inbox: Record<string, unknown>;
|
|
32
|
+
aggregate: Record<string, unknown>;
|
|
33
|
+
issueKey: string;
|
|
34
|
+
requestDigest: string;
|
|
35
|
+
}): Promise<InboxCredentialType> => {
|
|
36
|
+
const result = await neogma.queryRunner.run(
|
|
37
|
+
`MATCH (issuer:Profile {profileId: $issuerProfileId})
|
|
38
|
+
MERGE (ic:InboxCredential {refreshIssueKey: $issueKey})
|
|
39
|
+
ON CREATE SET ic += $inbox, ic.refreshRequestDigest = $requestDigest
|
|
40
|
+
WITH issuer, ic WHERE ic.refreshRequestDigest = $requestDigest
|
|
41
|
+
MERGE (refresh:CredentialRefresh {refreshId: ic.refreshId})
|
|
42
|
+
ON CREATE SET refresh += $aggregate
|
|
43
|
+
MERGE (issuer)-[:ISSUED_REFRESH]->(refresh)
|
|
44
|
+
MERGE (refresh)-[:INBOX]->(ic)
|
|
45
|
+
MERGE (p:InboxRefreshPublication {refreshVersionKey: ic.refreshId + ':1'})
|
|
46
|
+
ON CREATE SET p.id = ic.refreshId, p.refreshId = ic.refreshId, p.version = 1,
|
|
47
|
+
p.publishedAt = ic.createdAt, p.signingMode = 'signing-authority'
|
|
48
|
+
RETURN ic`,
|
|
49
|
+
{ ...params, issuerProfileId: params.aggregate.issuerProfileId }
|
|
50
|
+
);
|
|
51
|
+
const props = result.records[0]?.get('ic')?.properties;
|
|
52
|
+
if (!props)
|
|
53
|
+
throw new TRPCError({
|
|
54
|
+
code: 'CONFLICT',
|
|
55
|
+
message: 'This idempotencyKey was used for a different inbox issuance.',
|
|
56
|
+
});
|
|
57
|
+
return inflateObject<InboxCredentialType>(props);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Metadata only. Pending publication bodies live exclusively in the expiring inbox escrow. */
|
|
61
|
+
export const getPendingInboxPublication = async (
|
|
62
|
+
refreshId: string,
|
|
63
|
+
idempotencyKey?: string
|
|
64
|
+
): Promise<PublishCredentialRefreshResult | null> => {
|
|
65
|
+
if (!idempotencyKey) return null;
|
|
66
|
+
const result = await neogma.queryRunner.run(
|
|
67
|
+
`MATCH (p:InboxRefreshPublication {refreshIdempotencyKey: $key}) RETURN p LIMIT 1`,
|
|
68
|
+
{ key: `${refreshId}:${idempotencyKey}` }
|
|
69
|
+
);
|
|
70
|
+
const props = result.records[0]?.get('p')?.properties;
|
|
71
|
+
return props
|
|
72
|
+
? {
|
|
73
|
+
refreshId,
|
|
74
|
+
version: Number(props.version),
|
|
75
|
+
publishedAt: props.publishedAt,
|
|
76
|
+
notification: 'not-applicable',
|
|
77
|
+
}
|
|
78
|
+
: null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Publication and claim both lock the inbox row, then compare the version they prepared. */
|
|
82
|
+
export const advancePendingInboxRefresh = async (params: {
|
|
83
|
+
refreshId: string;
|
|
84
|
+
expectedVersion: number;
|
|
85
|
+
credential: string;
|
|
86
|
+
credentialName?: string;
|
|
87
|
+
materialDigest: string;
|
|
88
|
+
idempotencyKey?: string;
|
|
89
|
+
updateSummary?: string;
|
|
90
|
+
}): Promise<PublishCredentialRefreshResult | null> => {
|
|
91
|
+
const { refreshId, expectedVersion, idempotencyKey } = params;
|
|
92
|
+
const replay = await getPendingInboxPublication(refreshId, idempotencyKey);
|
|
93
|
+
if (replay) return replay;
|
|
94
|
+
const version = expectedVersion + 1;
|
|
95
|
+
const publishedAt = new Date().toISOString();
|
|
96
|
+
const result = await neogma.queryRunner.run(
|
|
97
|
+
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:INBOX]->(ic:InboxCredential)
|
|
98
|
+
SET ic._escrowLock = true REMOVE ic._escrowLock
|
|
99
|
+
WITH refresh, ic
|
|
100
|
+
WHERE refresh.state = 'pending_holder' AND refresh.currentVersion = $expectedVersion
|
|
101
|
+
AND ic.currentStatus = 'PENDING' AND datetime(ic.expiresAt) > datetime()
|
|
102
|
+
AND ($key IS NULL OR NOT EXISTS { MATCH (:InboxRefreshPublication {refreshIdempotencyKey: $key}) })
|
|
103
|
+
CREATE (p:InboxRefreshPublication $publication)
|
|
104
|
+
SET ic.credential = $credential, ic.credentialName = $credentialName,
|
|
105
|
+
refresh.currentVersion = $version, refresh.materialDigest = $materialDigest,
|
|
106
|
+
refresh.credentialDisplayName = coalesce($credentialName, refresh.credentialDisplayName),
|
|
107
|
+
refresh.lastPublishedAt = $publishedAt, refresh.updatedAt = $publishedAt,
|
|
108
|
+
refresh.updateSummary = $updateSummary
|
|
109
|
+
RETURN p`,
|
|
110
|
+
{
|
|
111
|
+
...params,
|
|
112
|
+
version,
|
|
113
|
+
publishedAt,
|
|
114
|
+
credentialName: params.credentialName ?? null,
|
|
115
|
+
updateSummary: params.updateSummary ?? null,
|
|
116
|
+
key: idempotencyKey ? `${refreshId}:${idempotencyKey}` : null,
|
|
117
|
+
publication: Object.fromEntries(
|
|
118
|
+
Object.entries({
|
|
119
|
+
id: randomUUID(),
|
|
120
|
+
refreshId,
|
|
121
|
+
version,
|
|
122
|
+
refreshVersionKey: `${refreshId}:${version}`,
|
|
123
|
+
publishedAt,
|
|
124
|
+
signingMode: 'signing-authority',
|
|
125
|
+
updateSummary: params.updateSummary,
|
|
126
|
+
refreshIdempotencyKey: idempotencyKey
|
|
127
|
+
? `${refreshId}:${idempotencyKey}`
|
|
128
|
+
: undefined,
|
|
129
|
+
}).filter(([, value]) => value !== undefined)
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
if (!result.records.length) return getPendingInboxPublication(refreshId, idempotencyKey);
|
|
134
|
+
return { refreshId, version, publishedAt, notification: 'not-applicable' };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Single commit: holder binding, canonical delivery, current version, and escrow erasure. */
|
|
138
|
+
export const bindPendingInboxRefresh = async (params: {
|
|
139
|
+
inboxId: string;
|
|
140
|
+
refreshId: string;
|
|
141
|
+
expectedVersion: number;
|
|
142
|
+
holderDid: string;
|
|
143
|
+
holderProfileId?: string;
|
|
144
|
+
issuerProfileId: string;
|
|
145
|
+
root: Record<string, unknown>;
|
|
146
|
+
aggregate: Record<string, unknown>;
|
|
147
|
+
deliveryCredential: string;
|
|
148
|
+
deliveryExpiresAt: string;
|
|
149
|
+
accepted: boolean;
|
|
150
|
+
boostId?: string;
|
|
151
|
+
}): Promise<boolean> => {
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
const result = await neogma.queryRunner.run(
|
|
154
|
+
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:INBOX]->(ic:InboxCredential {id: $inboxId})
|
|
155
|
+
SET ic._escrowLock = true REMOVE ic._escrowLock
|
|
156
|
+
WITH refresh, ic
|
|
157
|
+
WHERE refresh.state = 'pending_holder' AND refresh.currentVersion = $expectedVersion
|
|
158
|
+
AND ic.currentStatus = 'PENDING' AND datetime(ic.expiresAt) > datetime()
|
|
159
|
+
AND (ic.guardianStatus IS NULL OR ic.guardianStatus = 'GUARDIAN_APPROVED')
|
|
160
|
+
MATCH (issuer:Profile {profileId: $issuerProfileId})
|
|
161
|
+
OPTIONAL MATCH (holder:Profile {profileId: $holderProfileId})
|
|
162
|
+
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
163
|
+
CREATE (root:Credential $root)
|
|
164
|
+
CREATE (refresh)-[:ROOT]->(root)
|
|
165
|
+
CREATE (refresh)-[:HEAD]->(root)
|
|
166
|
+
CREATE (issuer)-[:CREDENTIAL_SENT {to: $deliveryTarget, date: $now, activityId: ic.activityId, integrationId: ic.integrationId}]->(root)
|
|
167
|
+
FOREACH (_ IN CASE WHEN holder IS NULL THEN [] ELSE [1] END |
|
|
168
|
+
CREATE (holder)-[:HELD_REFRESH]->(refresh))
|
|
169
|
+
FOREACH (_ IN CASE WHEN holder IS NOT NULL AND $accepted THEN [1] ELSE [] END |
|
|
170
|
+
CREATE (root)-[:CREDENTIAL_RECEIVED {date: $now}]->(holder))
|
|
171
|
+
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
172
|
+
CREATE (root)-[:INSTANCE_OF]->(boost))
|
|
173
|
+
SET refresh += $aggregate,
|
|
174
|
+
refresh.holderDid = $holderDid, refresh.holderProfileId = $holderProfileId,
|
|
175
|
+
refresh.state = CASE WHEN $accepted THEN 'active' ELSE 'awaiting_claim' END,
|
|
176
|
+
refresh.updatedAt = $now,
|
|
177
|
+
ic.currentStatus = 'ISSUED', ic.isAccepted = $accepted, ic.finalizedAt = $now,
|
|
178
|
+
ic.credential = null, ic.credentialName = null, ic.achievementType = null,
|
|
179
|
+
ic.deliveryCredential = $deliveryCredential, ic.deliveryRecipientDid = $holderDid,
|
|
180
|
+
ic.deliveryExpiresAt = $deliveryExpiresAt
|
|
181
|
+
RETURN root.id AS id`,
|
|
182
|
+
{
|
|
183
|
+
...params,
|
|
184
|
+
now,
|
|
185
|
+
holderProfileId: params.holderProfileId ?? null,
|
|
186
|
+
boostId: params.boostId ?? null,
|
|
187
|
+
deliveryTarget: params.holderProfileId ?? params.holderDid,
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
return result.records.length > 0;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** A read snapshot under the same inbox lock used by publish/claim, preventing mixed revisions. */
|
|
194
|
+
export const getInboxRefreshSnapshot = async (inboxId: string) => {
|
|
195
|
+
const result = await neogma.queryRunner.run(
|
|
196
|
+
`MATCH (refresh:CredentialRefresh)-[:INBOX]->(ic:InboxCredential {id: $inboxId})
|
|
197
|
+
SET ic._escrowLock = true REMOVE ic._escrowLock
|
|
198
|
+
RETURN ic, refresh LIMIT 1`,
|
|
199
|
+
{ inboxId }
|
|
200
|
+
);
|
|
201
|
+
const row = result.records[0];
|
|
202
|
+
if (!row) return null;
|
|
203
|
+
const props = row.get('refresh').properties;
|
|
204
|
+
return {
|
|
205
|
+
inbox: inflateObject<InboxCredentialType>(row.get('ic').properties),
|
|
206
|
+
aggregate: CredentialRefreshRecordValidator.parse({
|
|
207
|
+
...props,
|
|
208
|
+
currentVersion: Number(props.currentVersion),
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const recordInboxRefreshClaimUrl = async (id: string, claimUrl: string): Promise<string> => {
|
|
214
|
+
const result = await neogma.queryRunner.run(
|
|
215
|
+
`MATCH (ic:InboxCredential {id: $id})
|
|
216
|
+
SET ic.refreshClaimUrl = coalesce(ic.refreshClaimUrl, $claimUrl)
|
|
217
|
+
RETURN ic.refreshClaimUrl AS claimUrl`,
|
|
218
|
+
{ id, claimUrl }
|
|
219
|
+
);
|
|
220
|
+
return result.records[0]!.get('claimUrl');
|
|
221
|
+
};
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
|
|
11
11
|
export const updateInboxCredential = async (
|
|
12
12
|
id: string,
|
|
13
|
-
updates: Partial<Omit<InboxCredentialType, 'id' | 'createdAt' | 'credential'>>
|
|
13
|
+
updates: Partial<Omit<InboxCredentialType, 'id' | 'createdAt' | 'credential' | 'refresh'>>
|
|
14
14
|
): Promise<InboxCredentialType | null> => {
|
|
15
15
|
const result = await new QueryBuilder(new BindParam({ id, updates: flattenObject(updates) }))
|
|
16
16
|
.match({ model: InboxCredential, identifier: 'inboxCredential' })
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Cache } from './index';
|
|
3
|
+
|
|
4
|
+
/** Run the same contract against memory in unit tests and Redis 7 in the CI container suite. */
|
|
5
|
+
export const testAtomicCacheCounter = (cache: Cache): void => {
|
|
6
|
+
describe('atomic cache counter', () => {
|
|
7
|
+
it('increments atomically and repairs missing expiry', async () => {
|
|
8
|
+
const key = `atomic-increment-${crypto.randomUUID()}`;
|
|
9
|
+
try {
|
|
10
|
+
const counts = await Promise.all(
|
|
11
|
+
Array.from({ length: 20 }, () => cache.incr(key, 3600, 5))
|
|
12
|
+
);
|
|
13
|
+
expect(counts.slice().sort((a, b) => a! - b!)).toEqual(
|
|
14
|
+
Array.from({ length: 20 }, (_, i) => (i + 1) * 5)
|
|
15
|
+
);
|
|
16
|
+
expect(await cache.ttl(key)).toBeGreaterThan(3590);
|
|
17
|
+
// ttl=false deliberately writes without expiry; keepTtl would preserve it.
|
|
18
|
+
await cache.set(key, 200, false);
|
|
19
|
+
expect(await cache.ttl(key)).toBe(-1);
|
|
20
|
+
expect(await cache.incr(key, 3600, 2)).toBe(202);
|
|
21
|
+
expect(await cache.ttl(key)).toBeGreaterThan(3590);
|
|
22
|
+
} finally {
|
|
23
|
+
await cache.delete([key]);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
};
|
package/src/cache/index.ts
CHANGED
|
@@ -85,11 +85,11 @@ export type Cache = {
|
|
|
85
85
|
lrange: (key: RedisKey, start: number, stop: number) => Promise<string[] | undefined>;
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* Atomically increments a key by 1 and returns the new value.
|
|
89
|
-
* If the key does not exist it is created with value
|
|
90
|
-
* When ttl is provided
|
|
88
|
+
* Atomically increments a key by amount (default 1) and returns the new value.
|
|
89
|
+
* If the key does not exist it is created with value amount.
|
|
90
|
+
* When ttl is provided, increment and missing-expiry repair happen atomically.
|
|
91
91
|
*/
|
|
92
|
-
incr: (key: RedisKey, ttl?: number) => Promise<number | undefined>;
|
|
92
|
+
incr: (key: RedisKey, ttl?: number, amount?: number) => Promise<number | undefined>;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
/** Evict all keys after one hour by default */
|
|
@@ -239,19 +239,31 @@ export const getCache = (): Cache => {
|
|
|
239
239
|
|
|
240
240
|
return undefined;
|
|
241
241
|
},
|
|
242
|
-
incr: async (key, ttl) => {
|
|
242
|
+
incr: async (key, ttl, amount = 1) => {
|
|
243
243
|
try {
|
|
244
244
|
const redis = cache?.redis ?? cache?.node;
|
|
245
245
|
if (!redis) return undefined;
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
247
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) return undefined;
|
|
248
|
+
// Increment and expiry must be atomic: a crash between commands must not
|
|
249
|
+
// leave a permanent quota. Also repair counters created without an expiry.
|
|
250
|
+
if (ttl)
|
|
251
|
+
return Number(
|
|
252
|
+
await redis.eval(
|
|
253
|
+
`
|
|
254
|
+
local count = redis.call('INCRBY', KEYS[1], ARGV[1])
|
|
255
|
+
if redis.call('TTL', KEYS[1]) < 0 then
|
|
256
|
+
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
257
|
+
end
|
|
258
|
+
return count
|
|
259
|
+
`,
|
|
260
|
+
1,
|
|
261
|
+
key,
|
|
262
|
+
amount,
|
|
263
|
+
ttl
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
return await redis.incrby(key, amount);
|
|
255
267
|
} catch (e) {
|
|
256
268
|
console.error('Cache incr error', e);
|
|
257
269
|
}
|
|
@@ -36,6 +36,9 @@ export const brainServiceEnvironmentShape = {
|
|
|
36
36
|
OIDC_EXPECTED_AUDIENCE: optionalEnvironmentString,
|
|
37
37
|
AWS_REGION: optionalEnvironmentString,
|
|
38
38
|
NOTIFICATIONS_QUEUE_URL: optionalEnvironmentUrl,
|
|
39
|
+
INBOX_QUEUE_URL: optionalEnvironmentUrl,
|
|
40
|
+
INBOX_DEAD_LETTER_QUEUE_URL: optionalEnvironmentUrl,
|
|
41
|
+
INBOX_QUEUE_ENDPOINT: optionalEnvironmentUrl,
|
|
39
42
|
NOTIFICATIONS_QUEUE_POLL_URL: optionalEnvironmentUrl,
|
|
40
43
|
NOTIFICATIONS_SERVICE_WEBHOOK_URL: optionalEnvironmentUrl.or(z.literal('false')),
|
|
41
44
|
NOTIFICATIONS_SERVICE_PORT: optionalEnvironmentPort,
|
|
@@ -80,6 +83,8 @@ export const brainServiceEnvironmentShape = {
|
|
|
80
83
|
IS_CI: optionalEnvironmentBoolean.default(false),
|
|
81
84
|
IS_E2E_TEST: optionalEnvironmentBoolean.default(false),
|
|
82
85
|
INBOX_DELETE_EXPIRED_RECORDS: optionalEnvironmentBoolean.default(false),
|
|
86
|
+
INBOX_BATCH_ITEMS_PER_HOUR: optionalEnvironmentString,
|
|
87
|
+
AWS_LAMBDA_FUNCTION_NAME: optionalEnvironmentString,
|
|
83
88
|
ENABLE_BENCH_ROUTES: optionalEnvironmentBoolean.default(false),
|
|
84
89
|
ENABLE_SEND_CREDENTIAL_TELEMETRY: optionalEnvironmentBoolean.default(false),
|
|
85
90
|
LC_PERF_LOG: optionalEnvironmentBoolean.default(false),
|
|
@@ -206,6 +211,27 @@ export const getCredentialRefreshRuntimeEnvironment = (): CredentialRefreshRunti
|
|
|
206
211
|
examplePath: 'services/learn-card-network/brain-service/.env.example',
|
|
207
212
|
});
|
|
208
213
|
|
|
214
|
+
const inboxBatchRuntimeEnvironmentSchema = z.object({
|
|
215
|
+
INBOX_QUEUE_URL: brainServiceEnvironmentShape.INBOX_QUEUE_URL,
|
|
216
|
+
INBOX_DEAD_LETTER_QUEUE_URL: brainServiceEnvironmentShape.INBOX_DEAD_LETTER_QUEUE_URL,
|
|
217
|
+
INBOX_QUEUE_ENDPOINT: brainServiceEnvironmentShape.INBOX_QUEUE_ENDPOINT,
|
|
218
|
+
AWS_REGION: brainServiceEnvironmentShape.AWS_REGION,
|
|
219
|
+
NODE_ENV: brainServiceEnvironmentShape.NODE_ENV,
|
|
220
|
+
IS_OFFLINE: brainServiceEnvironmentShape.IS_OFFLINE,
|
|
221
|
+
AWS_LAMBDA_FUNCTION_NAME: brainServiceEnvironmentShape.AWS_LAMBDA_FUNCTION_NAME,
|
|
222
|
+
INBOX_BATCH_ITEMS_PER_HOUR: brainServiceEnvironmentShape.INBOX_BATCH_ITEMS_PER_HOUR,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export type InboxBatchRuntimeEnvironment = z.output<typeof inboxBatchRuntimeEnvironmentSchema>;
|
|
226
|
+
|
|
227
|
+
/** Reads batch settings at call time so local invocations and tests can override them safely. */
|
|
228
|
+
export const getInboxBatchRuntimeEnvironment = (): InboxBatchRuntimeEnvironment =>
|
|
229
|
+
parseEnvironment(inboxBatchRuntimeEnvironmentSchema, process.env, {
|
|
230
|
+
project: 'brain-service inbox batch',
|
|
231
|
+
source: 'process environment',
|
|
232
|
+
examplePath: 'services/learn-card-network/brain-service/.env.example',
|
|
233
|
+
});
|
|
234
|
+
|
|
209
235
|
export type BrainServiceEnvironment = z.output<typeof brainServiceEnvironmentSchema>;
|
|
210
236
|
|
|
211
237
|
export const parseBrainServiceEnvironment = (
|
|
@@ -172,7 +172,11 @@ const authenticateRefreshRequest = async (
|
|
|
172
172
|
|
|
173
173
|
// Missing aggregates, wrong holders, and awaiting-claim aggregates deliberately
|
|
174
174
|
// share one response so a valid DID cannot use the endpoint as an existence oracle.
|
|
175
|
-
if (
|
|
175
|
+
if (
|
|
176
|
+
!aggregate.holderDid ||
|
|
177
|
+
aggregate.state === 'pending_holder' ||
|
|
178
|
+
aggregate.holderDid !== auth.holderDid
|
|
179
|
+
) {
|
|
176
180
|
logRefreshRequest(refreshId, 'unauthorized', startedAt);
|
|
177
181
|
await reply.status(403).send({ code: 'CREDENTIAL_REFRESH_UNAUTHORIZED' });
|
|
178
182
|
return null;
|
package/src/docker-entry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { configureInboxBatchBodyLimit } from '@helpers/inbox-batch-http.helpers';
|
|
1
2
|
import { environment } from '@environment';
|
|
2
3
|
import Fastify from 'fastify';
|
|
3
4
|
import fastifyCors from '@fastify/cors';
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
import { neogma } from '@instance';
|
|
16
17
|
import { appRouter, type AppRouter, createContext } from './app';
|
|
17
18
|
import { openApiDocument } from './openapi';
|
|
19
|
+
import { inboxBatchResponseMeta } from './helpers/inbox-batch-http.helpers';
|
|
18
20
|
import { didFastifyPlugin } from './dids';
|
|
19
21
|
import { skillsViewerFastifyPlugin } from './skills-viewer';
|
|
20
22
|
import { statusListsFastifyPlugin } from './status-lists';
|
|
@@ -25,6 +27,11 @@ import { maybeAutoSeedSkillFrameworks } from './seed/seedSkillFrameworks';
|
|
|
25
27
|
|
|
26
28
|
const server = Fastify({ routerOptions: { maxParamLength: 5000 } });
|
|
27
29
|
|
|
30
|
+
// Register before either OpenAPI or tRPC registers its wildcard route. The hook raises the
|
|
31
|
+
// parser ceiling for those shared routes, then narrows it back to 4 MiB only for batch issuance.
|
|
32
|
+
// Registering this later would leave the already-created adapter routes at Fastify's 1 MiB limit.
|
|
33
|
+
configureInboxBatchBodyLimit(server);
|
|
34
|
+
|
|
28
35
|
server.addHook('onRequest', (request, _reply, done) => {
|
|
29
36
|
type RawWithEmitter = typeof request.raw & {
|
|
30
37
|
on?: (...args: unknown[]) => unknown;
|
|
@@ -79,6 +86,7 @@ server.register(fastifyTRPCPlugin, {
|
|
|
79
86
|
});
|
|
80
87
|
|
|
81
88
|
server.register(fastifyTRPCOpenApiPlugin, {
|
|
89
|
+
responseMeta: inboxBatchResponseMeta,
|
|
82
90
|
basePath: '/api',
|
|
83
91
|
router: appRouter,
|
|
84
92
|
createContext,
|
|
@@ -20,6 +20,7 @@ export const logCredentialActivity = async (
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export const logCredentialSent = async (params: {
|
|
23
|
+
onBehalfOf?: string;
|
|
23
24
|
actorProfileId?: string;
|
|
24
25
|
recipientType: CredentialActivityRecipientType;
|
|
25
26
|
recipientIdentifier: string;
|
|
@@ -45,6 +46,7 @@ export const logCredentialSent = async (params: {
|
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
export const logCredentialDelivered = async (params: {
|
|
49
|
+
onBehalfOf?: string;
|
|
48
50
|
activityId: string;
|
|
49
51
|
actorProfileId: string;
|
|
50
52
|
recipientType: CredentialActivityRecipientType;
|
|
@@ -64,6 +66,7 @@ export const logCredentialDelivered = async (params: {
|
|
|
64
66
|
};
|
|
65
67
|
|
|
66
68
|
export const logCredentialClaimed = async (params: {
|
|
69
|
+
onBehalfOf?: string;
|
|
67
70
|
activityId?: string;
|
|
68
71
|
actorProfileId: string;
|
|
69
72
|
recipientType: CredentialActivityRecipientType;
|
|
@@ -98,6 +101,7 @@ export const logCredentialExpired = async (params: {
|
|
|
98
101
|
};
|
|
99
102
|
|
|
100
103
|
export const logCredentialFailed = async (params: {
|
|
104
|
+
onBehalfOf?: string;
|
|
101
105
|
activityId?: string;
|
|
102
106
|
actorProfileId: string;
|
|
103
107
|
recipientType: CredentialActivityRecipientType;
|
|
@@ -44,7 +44,7 @@ export const handleIncrementCounterEvent = async (
|
|
|
44
44
|
// tRPC does not support HTTP 429 natively, so we cast to BAD_REQUEST
|
|
45
45
|
// while keeping the semantic code in the message for clients.
|
|
46
46
|
throw new TRPCError({
|
|
47
|
-
code: 'TOO_MANY_REQUESTS'
|
|
47
|
+
code: 'TOO_MANY_REQUESTS',
|
|
48
48
|
message: 'Rate limit exceeded: max 100 counter writes per user per app per minute',
|
|
49
49
|
});
|
|
50
50
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const environment = vi.hoisted(() => ({
|
|
4
|
+
CLIENT_APP_DOMAIN_NAME: undefined as string | undefined,
|
|
5
|
+
CLIENT_APP_PORT: undefined as number | undefined,
|
|
6
|
+
PORT: 4000,
|
|
7
|
+
IS_OFFLINE: true,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('@environment', () => ({ environment }));
|
|
10
|
+
vi.mock('@cache', () => ({ default: {} }));
|
|
11
|
+
|
|
12
|
+
import { generateClaimUrl } from './contact-method.helpers';
|
|
13
|
+
|
|
14
|
+
describe('inbox claim links', () => {
|
|
15
|
+
it('opens the default frontend instead of the backend port', () => {
|
|
16
|
+
expect(generateClaimUrl('test-token')).toBe(
|
|
17
|
+
'http://localhost:3000/interactions/inbox-claim/test-token?iuv=1'
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('uses the configured frontend port locally', () => {
|
|
22
|
+
environment.CLIENT_APP_PORT = 3100;
|
|
23
|
+
try {
|
|
24
|
+
expect(generateClaimUrl('test-token')).toContain('http://localhost:3100/');
|
|
25
|
+
} finally {
|
|
26
|
+
environment.CLIENT_APP_PORT = undefined;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('honors a configured app domain locally and keeps production HTTPS', () => {
|
|
31
|
+
environment.CLIENT_APP_DOMAIN_NAME = 'app.example.org';
|
|
32
|
+
try {
|
|
33
|
+
expect(generateClaimUrl('test-token')).toContain('http://app.example.org/');
|
|
34
|
+
environment.IS_OFFLINE = false;
|
|
35
|
+
expect(generateClaimUrl('test-token')).toBe(
|
|
36
|
+
'https://app.example.org/interactions/inbox-claim/test-token?iuv=1'
|
|
37
|
+
);
|
|
38
|
+
} finally {
|
|
39
|
+
environment.IS_OFFLINE = true;
|
|
40
|
+
environment.CLIENT_APP_DOMAIN_NAME = undefined;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -110,10 +110,9 @@ export const markInboxClaimTokenAsUsed = async (token: string): Promise<boolean>
|
|
|
110
110
|
|
|
111
111
|
export const generateClaimUrl = (token: string): string => {
|
|
112
112
|
const domainName = environment.CLIENT_APP_DOMAIN_NAME;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
: domainName;
|
|
113
|
+
// Claim links open the frontend, not the brain-service HTTP port. Honor an
|
|
114
|
+
// explicitly configured app domain in local development as well.
|
|
115
|
+
const domain = domainName || `localhost:${environment.CLIENT_APP_PORT || 3000}`;
|
|
117
116
|
|
|
118
117
|
const protocol = environment.IS_OFFLINE ? 'http' : 'https';
|
|
119
118
|
|