@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
@@ -0,0 +1,239 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { resolveTenantFromRequest } from '@learncard/email-templates';
3
+ import { ACT_AS_HEADER, AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
4
+ import type { ProfileType } from 'types/profile';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ getProfileByDid: vi.fn(),
8
+ getProfileByProfileId: vi.fn(),
9
+ getProfilesThatManageAProfile: vi.fn(),
10
+ grant: vi.fn(),
11
+ verifyPresentation: vi.fn(),
12
+ isAuthGrantAssociatedWithProfile: vi.fn(),
13
+ getAuthGrantById: vi.fn(),
14
+ updateAuthGrant: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('@accesslayer/profile/read', () => ({
18
+ getProfileByDid: mocks.getProfileByDid,
19
+ getProfileByProfileId: mocks.getProfileByProfileId,
20
+ }));
21
+ vi.mock('@accesslayer/profile/relationships/read', () => ({
22
+ getProfilesThatManageAProfile: mocks.getProfilesThatManageAProfile,
23
+ }));
24
+ vi.mock('@accesslayer/auth-grant/read', () => ({
25
+ isAuthGrantChallengeValidForDID: mocks.grant,
26
+ isAuthGrantAssociatedWithProfile: mocks.isAuthGrantAssociatedWithProfile,
27
+ getAuthGrantById: mocks.getAuthGrantById,
28
+ }));
29
+ vi.mock('@accesslayer/auth-grant/update', () => ({ updateAuthGrant: mocks.updateAuthGrant }));
30
+ vi.mock('@helpers/learnCard.helpers', () => ({
31
+ getEmptyLearnCard: async () => ({ invoke: { verifyPresentation: mocks.verifyPresentation } }),
32
+ }));
33
+
34
+ import { createContext, didRoute, profileRoute, t, type Context } from './index';
35
+ import { profilesRouter } from './profiles';
36
+ import { authGrantsRouter } from './auth-grants';
37
+
38
+ const domain = 'network.example.com';
39
+ const manager: ProfileType = {
40
+ profileId: 'manager',
41
+ did: 'did:key:manager',
42
+ displayName: 'Manager',
43
+ shortBio: '',
44
+ bio: '',
45
+ };
46
+ const target: ProfileType = {
47
+ profileId: 'the-id',
48
+ did: `did:web:${domain}:users:the-id`,
49
+ displayName: 'District',
50
+ shortBio: '',
51
+ bio: '',
52
+ };
53
+ const inspectionRouter = t.router({
54
+ context: didRoute.query(({ ctx }) => ctx.user),
55
+ writeInbox: profileRoute.meta({ requiredScope: 'inbox:write' }).query(() => true),
56
+ writeBoost: profileRoute.meta({ requiredScope: 'boosts:write' }).query(() => true),
57
+ });
58
+ const context = (overrides: Partial<Context> = {}): Context => ({
59
+ domain,
60
+ tenant: resolveTenantFromRequest({}),
61
+ actAs: target.profileId,
62
+ user: { did: manager.did, isChallengeValid: true, scope: '*:*' },
63
+ ...overrides,
64
+ });
65
+
66
+ beforeEach(() => {
67
+ vi.resetAllMocks();
68
+ mocks.getProfileByDid.mockResolvedValue(manager);
69
+ mocks.getProfileByProfileId.mockImplementation(async (id: string) =>
70
+ id === target.profileId ? target : null
71
+ );
72
+ mocks.getProfilesThatManageAProfile.mockResolvedValue([manager]);
73
+ mocks.verifyPresentation.mockResolvedValue({ warnings: [], errors: [], checks: ['JWS'] });
74
+ });
75
+
76
+ describe('act as a managed profile', () => {
77
+ it.each([target.profileId, target.did])('accepts seed-auth delegation to %s', async actAs => {
78
+ const ctx = context({ actAs });
79
+ await expect(profilesRouter.createCaller(ctx).getProfile()).resolves.toMatchObject(target);
80
+ await expect(inspectionRouter.createCaller(ctx).context()).resolves.toMatchObject({
81
+ did: target.did,
82
+ profile: target,
83
+ onBehalfOf: manager.profileId,
84
+ scope: '*:*',
85
+ });
86
+ expect(ctx.user?.did).toBe(manager.did);
87
+ expect(mocks.getProfilesThatManageAProfile).toHaveBeenCalledWith(target.profileId);
88
+ });
89
+
90
+ it('rejects a profile the caller does not manage', async () => {
91
+ mocks.getProfilesThatManageAProfile.mockResolvedValue([]);
92
+ await expect(inspectionRouter.createCaller(context()).context()).rejects.toMatchObject({
93
+ code: 'FORBIDDEN',
94
+ message: 'You do not manage profile "the-id".',
95
+ });
96
+ });
97
+
98
+ it('rejects an unknown profile', async () => {
99
+ await expect(
100
+ inspectionRouter.createCaller(context({ actAs: 'missing' })).context()
101
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
102
+ });
103
+
104
+ it.each([
105
+ 'did:key:other',
106
+ 'did:web:elsewhere:users:the-id',
107
+ `did:web:${domain}:manager:the-id`,
108
+ `${target.did}:extra`,
109
+ ])('rejects an unsupported DID: %s', async actAs => {
110
+ await expect(
111
+ inspectionRouter.createCaller(context({ actAs })).context()
112
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
113
+ });
114
+
115
+ it.each([
116
+ [undefined, false],
117
+ ['', false],
118
+ ['*', true],
119
+ ['other-id', false],
120
+ ['the-id,other', true],
121
+ [' other, the-id ', true],
122
+ ] as const)('enforces token policy %s (allowed: %s)', async (actAs, allowed) => {
123
+ mocks.grant.mockResolvedValue({ isChallengeValid: true, scope: 'inbox:write', actAs });
124
+ const payload = Buffer.from(
125
+ JSON.stringify({
126
+ vp: { holder: manager.did },
127
+ nonce: `${AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX}test`,
128
+ })
129
+ ).toString('base64url');
130
+ const ctx = await createContext({
131
+ req: {
132
+ headers: new Map([
133
+ ['authorization', `Bearer e30.${payload}.signature`],
134
+ [ACT_AS_HEADER, target.profileId],
135
+ ]),
136
+ },
137
+ });
138
+ const caller = inspectionRouter.createCaller(ctx);
139
+ if (!allowed) {
140
+ await expect(caller.context()).rejects.toMatchObject({
141
+ code: 'FORBIDDEN',
142
+ message: 'This API token may not act as "the-id". Grant actAs on the token.',
143
+ });
144
+ return;
145
+ }
146
+ await expect(caller.context()).resolves.toMatchObject({
147
+ profile: target,
148
+ did: target.did,
149
+ onBehalfOf: manager.profileId,
150
+ scope: 'inbox:write',
151
+ isAuthGrant: true,
152
+ actAsPolicy: actAs,
153
+ });
154
+ await expect(caller.writeInbox()).resolves.toBe(true);
155
+ await expect(caller.writeBoost()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
156
+ });
157
+
158
+ it('leaves the authenticated identity unchanged without a header', async () => {
159
+ const result = await inspectionRouter.createCaller(context({ actAs: undefined })).context();
160
+ expect(result).toMatchObject({ profile: manager, did: manager.did, scope: '*:*' });
161
+ expect(result.onBehalfOf).toBeUndefined();
162
+ });
163
+
164
+ it.each([ACT_AS_HEADER, ACT_AS_HEADER.toLowerCase(), ACT_AS_HEADER.toUpperCase()])(
165
+ 'reads header %s without resolving a profile in createContext',
166
+ async name => {
167
+ const ctx = await createContext({ req: { headers: new Map([[name, target.did]]) } });
168
+ expect(ctx.actAs).toBe(target.did);
169
+ expect(mocks.getProfileByProfileId).not.toHaveBeenCalled();
170
+ }
171
+ );
172
+ });
173
+
174
+ describe('act-as ordering', () => {
175
+ it('does not resolve the target when the challenge is not verified', async () => {
176
+ const ctx = context({ user: { did: manager.did, isChallengeValid: false, scope: '*:*' } });
177
+ await expect(inspectionRouter.createCaller(ctx).context()).resolves.toMatchObject({
178
+ did: manager.did,
179
+ });
180
+ expect(mocks.getProfileByProfileId).not.toHaveBeenCalled();
181
+ expect(mocks.getProfilesThatManageAProfile).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it('treats a blank header as no delegation', async () => {
185
+ const ctx = context({ actAs: ' ' });
186
+ await expect(inspectionRouter.createCaller(ctx).context()).resolves.toMatchObject({
187
+ did: manager.did,
188
+ });
189
+ expect(mocks.getProfileByProfileId).not.toHaveBeenCalled();
190
+ });
191
+ });
192
+
193
+ describe('auth grant actAs is immutable', () => {
194
+ const grant = {
195
+ id: 'grant-1',
196
+ name: 'svc',
197
+ challenge: `${AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX}test`,
198
+ status: 'active',
199
+ scope: 'inbox:write',
200
+ createdAt: '2026-01-01T00:00:00.000Z',
201
+ actAs: '*',
202
+ };
203
+
204
+ beforeEach(() => {
205
+ mocks.isAuthGrantAssociatedWithProfile.mockResolvedValue(true);
206
+ mocks.getAuthGrantById.mockResolvedValue(grant);
207
+ mocks.updateAuthGrant.mockResolvedValue(true);
208
+ });
209
+
210
+ it.each([{ actAs: 'the-id' }, { actAs: '*' }, { scope: '*:*' }, { status: 'revoked' }])(
211
+ 'rejects %j with BAD_REQUEST instead of silently dropping it',
212
+ async updates => {
213
+ const caller = authGrantsRouter.createCaller(context({ actAs: undefined }));
214
+ await expect(
215
+ caller.updateAuthGrant({ id: grant.id, updates: updates as never })
216
+ ).rejects.toMatchObject({
217
+ code: 'BAD_REQUEST',
218
+ message: expect.stringContaining('Cannot update id, scope, actAs'),
219
+ });
220
+ expect(mocks.updateAuthGrant).not.toHaveBeenCalled();
221
+ }
222
+ );
223
+
224
+ it('rejects unknown keys', async () => {
225
+ const caller = authGrantsRouter.createCaller(context({ actAs: undefined }));
226
+ await expect(
227
+ caller.updateAuthGrant({ id: grant.id, updates: { bogus: true } as never })
228
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
229
+ expect(mocks.updateAuthGrant).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it('still allows mutable fields', async () => {
233
+ const caller = authGrantsRouter.createCaller(context({ actAs: undefined }));
234
+ await expect(
235
+ caller.updateAuthGrant({ id: grant.id, updates: { description: 'renamed' } })
236
+ ).resolves.toBe(true);
237
+ expect(mocks.updateAuthGrant).toHaveBeenCalledWith(grant, { description: 'renamed' });
238
+ });
239
+ });
@@ -1671,7 +1671,7 @@ const handleSendNotificationEvent = async (
1671
1671
  // tRPC does not support HTTP 429 natively, so we cast to BAD_REQUEST
1672
1672
  // while keeping the semantic code in the message for clients.
1673
1673
  throw new TRPCError({
1674
- code: 'TOO_MANY_REQUESTS' as 'BAD_REQUEST',
1674
+ code: 'TOO_MANY_REQUESTS',
1675
1675
  message: 'Rate limit exceeded: max 10 notifications per user per app per hour',
1676
1676
  });
1677
1677
  }
@@ -2718,7 +2718,7 @@ export const appStoreRouter = t.router({
2718
2718
  // tRPC does not support HTTP 429 natively, so we cast to BAD_REQUEST
2719
2719
  // while keeping the semantic code in the message for clients.
2720
2720
  throw new TRPCError({
2721
- code: 'TOO_MANY_REQUESTS' as 'BAD_REQUEST',
2721
+ code: 'TOO_MANY_REQUESTS',
2722
2722
  message: 'Rate limit exceeded: max 60 notifications per app per hour',
2723
2723
  });
2724
2724
  }
@@ -139,14 +139,9 @@ export const authGrantsRouter = t.router({
139
139
  .input(
140
140
  z.object({
141
141
  id: z.string(),
142
- updates: AuthGrantValidator.partial().omit({
143
- id: true,
144
- scope: true,
145
- status: true,
146
- createdAt: true,
147
- expiresAt: true,
148
- challenge: true,
149
- }),
142
+ // Immutable fields stay in the schema so the guard below can reject them
143
+ // explicitly; omitting them would strip the key and silently no-op instead.
144
+ updates: AuthGrantValidator.partial().strict(),
150
145
  })
151
146
  )
152
147
  .output(z.boolean())
@@ -158,13 +153,20 @@ export const authGrantsRouter = t.router({
158
153
  });
159
154
  }
160
155
 
161
- // Extra check to reject sensitive, invalid updates
162
- const invalidUpdates = ['id', 'scope', 'status', 'createdAt', 'expiresAt', 'challenge'];
156
+ const invalidUpdates = [
157
+ 'id',
158
+ 'scope',
159
+ 'actAs',
160
+ 'status',
161
+ 'createdAt',
162
+ 'expiresAt',
163
+ 'challenge',
164
+ ];
163
165
  if (invalidUpdates.some(key => input.updates.hasOwnProperty(key))) {
164
166
  throw new TRPCError({
165
167
  code: 'BAD_REQUEST',
166
168
  message:
167
- 'Cannot update id, scope, status, createdAt, expiresAt, or challenge of an AuthGrant',
169
+ 'Cannot update id, scope, actAs, status, createdAt, expiresAt, or challenge of an AuthGrant',
168
170
  });
169
171
  }
170
172