@agent-native/core 0.163.0 → 0.163.2

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 (43) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/server/plugins/collab.ts +1 -0
  3. package/corpus/templates/slides/DEVELOPING.md +17 -0
  4. package/corpus/templates/slides/actions/get-deck-access-status.ts +67 -0
  5. package/corpus/templates/slides/actions/request-deck-access.ts +569 -0
  6. package/corpus/templates/slides/agent-native.json +3 -0
  7. package/corpus/templates/slides/app/components/layout/Header.tsx +2 -0
  8. package/corpus/templates/slides/app/hooks/use-deck-access.ts +51 -0
  9. package/corpus/templates/slides/app/i18n/en-US.ts +12 -0
  10. package/corpus/templates/slides/app/lib/deck-editor-loading.ts +7 -0
  11. package/corpus/templates/slides/app/pages/DeckEditor.tsx +172 -32
  12. package/corpus/templates/slides/changelog/2026-08-18-private-deck-links-now-explain-access-and-let-viewers-notify.md +6 -0
  13. package/corpus/templates/slides/server/db/schema.ts +10 -0
  14. package/corpus/templates/slides/server/plugins/db.ts +17 -0
  15. package/dist/client/analytics.d.ts +1 -0
  16. package/dist/client/analytics.js +19 -5
  17. package/dist/client/session-replay.js +349 -100
  18. package/dist/collab/routes.d.ts +1 -1
  19. package/dist/collab/storage.d.ts +2 -0
  20. package/dist/collab/storage.js +7 -0
  21. package/dist/collab/struct-routes.d.ts +1 -1
  22. package/dist/config.js +11 -1
  23. package/dist/deploy/build.d.ts +1 -1
  24. package/dist/deploy/build.js +89 -12
  25. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  26. package/dist/observability/routes.d.ts +1 -1
  27. package/dist/observability/tracking-identity.js +2 -0
  28. package/dist/progress/routes.d.ts +1 -1
  29. package/dist/resources/handlers.d.ts +1 -1
  30. package/dist/secrets/routes.d.ts +9 -9
  31. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  32. package/dist/server/collab-plugin.d.ts +6 -0
  33. package/dist/server/collab-plugin.js +27 -6
  34. package/dist/server/deploy-environment.d.ts +1 -1
  35. package/dist/server/deploy-environment.js +40 -2
  36. package/dist/server/realtime-token.d.ts +1 -1
  37. package/dist/server/sentry-config.js +8 -4
  38. package/dist/server/sentry.js +4 -0
  39. package/dist/server/transcribe-voice.d.ts +1 -1
  40. package/dist/tracking/registry.js +8 -6
  41. package/dist/vite/client.js +5 -0
  42. package/docs/content/deployment.mdx +19 -5
  43. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8324
34
+ - template files: 8328
@@ -10,6 +10,7 @@ export default createCollabPlugin({
10
10
  contentColumn: "config",
11
11
  idColumn: "id",
12
12
  autoSeed: true,
13
+ resolveCollabDocumentId: (dashboardId) => `dash-${dashboardId}`,
13
14
  access: {
14
15
  mode: "resource",
15
16
  resourceType: "dashboard",
@@ -114,6 +114,23 @@ Real credential values belong only in local `.env` files, deployment configurati
114
114
  | `DATABASE_URL` | Production yes, local dev no | Persistent SQL connection string (local dev default: `file:./data/app.db`) |
115
115
  | `DATABASE_AUTH_TOKEN` | Only when the provider needs it | Auth token for providers such as Turso/libSQL |
116
116
 
117
+ ## Private Deck Access Recovery
118
+
119
+ The editor route intentionally keeps private decks out of the content response
120
+ when the current viewer has no grant. The `get-deck-access-status` action may
121
+ return only existence, visibility, and the current viewer identity so the UI
122
+ can explain the denial without exposing deck data. The private-deck pane uses
123
+ that metadata to show the Google-Docs-style "This deck is private" state.
124
+
125
+ Signed-in viewers can call `request-deck-access`. That action records a
126
+ `deck.access_requested` event in `deck_events`, adds an owner-scoped in-app
127
+ notification, and emails the deck owner when outbound email is configured.
128
+ Requests are idempotent per deck and requester. In-app and email delivery are
129
+ tracked independently, retried safely after transient failures, and claimed
130
+ atomically so concurrent retries do not fan out duplicate notifications. It
131
+ never grants access; the owner must use the existing Share controls. Anonymous
132
+ viewers are sent through the normal sign-in flow first.
133
+
117
134
  ## Build & Dev Commands
118
135
 
119
136
  ```bash
@@ -0,0 +1,67 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import {
3
+ getRequestUserEmail,
4
+ getRequestUserName,
5
+ } from "@agent-native/core/server/request-context";
6
+ import { currentAccess, resolveAccess } from "@agent-native/core/sharing";
7
+ import { eq } from "drizzle-orm";
8
+ import { z } from "zod";
9
+
10
+ import { getDb, schema } from "../server/db/index.js";
11
+
12
+ export type DeckAccessStatus = {
13
+ exists: boolean;
14
+ hasAccess: boolean;
15
+ signedIn: boolean;
16
+ viewerEmail: string | null;
17
+ viewerName: string | null;
18
+ role: "owner" | "viewer" | "commenter" | "editor" | "admin" | null;
19
+ visibility: "private" | "org" | "public" | null;
20
+ };
21
+
22
+ export default defineAction({
23
+ description:
24
+ "Return whether a deck URL exists and whether the current viewer can access it. This reveals only safe access metadata, never deck content.",
25
+ schema: z.object({
26
+ deckId: z.string().min(1).describe("Deck ID to check."),
27
+ }),
28
+ http: { method: "GET" },
29
+ readOnly: true,
30
+ requiresAuth: false,
31
+ agentTool: false,
32
+ run: async ({ deckId }): Promise<DeckAccessStatus> => {
33
+ const viewerEmail = getRequestUserEmail() ?? null;
34
+ const viewerName = viewerEmail ? (getRequestUserName() ?? null) : null;
35
+ const [deck] = await getDb()
36
+ .select({
37
+ id: schema.decks.id,
38
+ visibility: schema.decks.visibility,
39
+ })
40
+ .from(schema.decks)
41
+ .where(eq(schema.decks.id, deckId))
42
+ .limit(1);
43
+
44
+ if (!deck) {
45
+ return {
46
+ exists: false,
47
+ hasAccess: false,
48
+ signedIn: Boolean(viewerEmail),
49
+ viewerEmail,
50
+ viewerName,
51
+ role: null,
52
+ visibility: null,
53
+ };
54
+ }
55
+
56
+ const access = await resolveAccess("deck", deckId, currentAccess());
57
+ return {
58
+ exists: true,
59
+ hasAccess: Boolean(access),
60
+ signedIn: Boolean(viewerEmail),
61
+ viewerEmail,
62
+ viewerName,
63
+ role: access?.role ?? null,
64
+ visibility: deck.visibility ?? "private",
65
+ };
66
+ },
67
+ });
@@ -0,0 +1,569 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ import { defineAction } from "@agent-native/core";
4
+ import { notifyWithDelivery } from "@agent-native/core/notifications";
5
+ import { isEmailConfigured } from "@agent-native/core/server";
6
+ import {
7
+ getRequestUserEmail,
8
+ getRequestUserName,
9
+ } from "@agent-native/core/server/request-context";
10
+ import { currentAccess, resolveAccess } from "@agent-native/core/sharing";
11
+ import { and, eq } from "drizzle-orm";
12
+ import { z } from "zod";
13
+
14
+ import { getDb, schema } from "../server/db/index.js";
15
+ import { getDeckUrl } from "./_app-url.js";
16
+
17
+ function httpError(message: string, statusCode: number): Error {
18
+ return Object.assign(new Error(message), { statusCode });
19
+ }
20
+
21
+ function displayNameForEmail(email: string): string {
22
+ const local = email.replace(/@.*/, "");
23
+ const parts = local
24
+ .split(/[._+-]+/)
25
+ .map((part) => part.trim())
26
+ .filter(Boolean);
27
+ if (parts.length === 0) return email;
28
+ return parts
29
+ .slice(0, 2)
30
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
31
+ .join(" ");
32
+ }
33
+
34
+ function normalizeEmail(email: string): string {
35
+ return email.trim().toLowerCase();
36
+ }
37
+
38
+ function cleanSubjectPart(value: string): string {
39
+ return value.replace(/[\r\n]+/g, " ").trim();
40
+ }
41
+
42
+ function accessRequestEventId(deckId: string, requesterEmail: string): string {
43
+ return (
44
+ "access-request-" +
45
+ createHash("sha256")
46
+ .update(deckId)
47
+ .update("\0")
48
+ .update(requesterEmail)
49
+ .digest("hex")
50
+ );
51
+ }
52
+
53
+ type AccessRequestPayload = {
54
+ requestId?: string;
55
+ requesterEmail?: string;
56
+ requesterName?: string;
57
+ requestedAt?: string;
58
+ notifiedOwner?: boolean;
59
+ notifiedAt?: string;
60
+ inAppNotified?: boolean;
61
+ emailNotified?: boolean;
62
+ notificationClaimedAt?: string;
63
+ notificationClaimToken?: string;
64
+ };
65
+
66
+ type AccessRequestNotificationState = {
67
+ inAppNotified: boolean;
68
+ emailNotified: boolean;
69
+ emailRequired: boolean;
70
+ ownerCanNotify: boolean;
71
+ };
72
+
73
+ const NOTIFICATION_CLAIM_TTL_MS = 5 * 60 * 1000;
74
+ const NOTIFICATION_CLAIM_HEARTBEAT_MS = Math.floor(
75
+ NOTIFICATION_CLAIM_TTL_MS / 3,
76
+ );
77
+
78
+ function parseAccessRequestPayload(
79
+ payload: string | null | undefined,
80
+ ): AccessRequestPayload | null {
81
+ try {
82
+ const parsed = JSON.parse(payload ?? "") as unknown;
83
+ return parsed && typeof parsed === "object"
84
+ ? (parsed as AccessRequestPayload)
85
+ : null;
86
+ } catch {
87
+ // coercion-ok: malformed historical event payload cannot represent a matching requester.
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function notificationStateFor(
93
+ payload: AccessRequestPayload,
94
+ ownerEmail: string | null,
95
+ requesterEmail: string,
96
+ emailConfigured: boolean,
97
+ ): AccessRequestNotificationState {
98
+ const ownerCanNotify = Boolean(
99
+ ownerEmail && normalizeEmail(ownerEmail) !== requesterEmail,
100
+ );
101
+ const legacyNotified = payload.notifiedOwner === true;
102
+ return {
103
+ inAppNotified: payload.inAppNotified ?? legacyNotified,
104
+ emailNotified: payload.emailNotified ?? legacyNotified,
105
+ emailRequired: ownerCanNotify && emailConfigured,
106
+ ownerCanNotify,
107
+ };
108
+ }
109
+
110
+ function notificationWasDelivered(
111
+ state: AccessRequestNotificationState,
112
+ ): boolean {
113
+ return state.inAppNotified || state.emailNotified;
114
+ }
115
+
116
+ function notificationStillNeeded(
117
+ state: AccessRequestNotificationState,
118
+ ): boolean {
119
+ return (
120
+ state.ownerCanNotify &&
121
+ (!state.inAppNotified || (state.emailRequired && !state.emailNotified))
122
+ );
123
+ }
124
+
125
+ function hasRecentNotificationClaim(
126
+ claimedAt: string | undefined,
127
+ now = Date.now(),
128
+ ): boolean {
129
+ if (!claimedAt) return false;
130
+ const claimedAtMs = Date.parse(claimedAt);
131
+ return (
132
+ Number.isFinite(claimedAtMs) &&
133
+ now - claimedAtMs < NOTIFICATION_CLAIM_TTL_MS
134
+ );
135
+ }
136
+
137
+ async function notifyAccessRequestOwner(input: {
138
+ deckId: string;
139
+ deckTitle: string;
140
+ ownerEmail: string | null;
141
+ requesterEmail: string;
142
+ requesterName: string;
143
+ state: AccessRequestNotificationState;
144
+ }): Promise<AccessRequestNotificationState> {
145
+ const ownerEmail = input.ownerEmail;
146
+ if (!ownerEmail || !input.state.ownerCanNotify) return input.state;
147
+
148
+ const emailConfigured = await isEmailConfigured();
149
+ const state = {
150
+ ...input.state,
151
+ emailRequired: input.state.ownerCanNotify && emailConfigured,
152
+ };
153
+ const channels: string[] = [];
154
+ if (!state.inAppNotified) channels.push("inbox");
155
+ if (state.emailRequired && !state.emailNotified) channels.push("email");
156
+ if (channels.length === 0) return state;
157
+
158
+ try {
159
+ const delivery = await notifyWithDelivery(
160
+ {
161
+ severity: "info",
162
+ title: "Deck access requested",
163
+ body: `${input.requesterName} requested access to “${input.deckTitle}”.`,
164
+ channels,
165
+ metadata: {
166
+ deckId: input.deckId,
167
+ requesterEmail: input.requesterEmail,
168
+ link: getDeckUrl(input.deckId),
169
+ ...(channels.includes("email")
170
+ ? {
171
+ emailRecipients: [ownerEmail],
172
+ emailSubject: `${cleanSubjectPart(input.requesterName)} requested access to "${cleanSubjectPart(input.deckTitle)}"`,
173
+ }
174
+ : {}),
175
+ },
176
+ },
177
+ { owner: ownerEmail },
178
+ );
179
+ return {
180
+ ...state,
181
+ inAppNotified:
182
+ state.inAppNotified || delivery.deliveredChannels.includes("inbox"),
183
+ emailNotified:
184
+ state.emailNotified || delivery.deliveredChannels.includes("email"),
185
+ };
186
+ } catch (error) {
187
+ console.warn("[deck-access] access request notification failed:", error);
188
+ return state;
189
+ }
190
+ }
191
+
192
+ export default defineAction({
193
+ description:
194
+ "Request access to a private Agent-Native Slides deck. Records an access-request event and notifies the owner when email is configured.",
195
+ schema: z.object({
196
+ deckId: z.string().min(1).describe("Deck ID to request access to."),
197
+ }),
198
+ agentTool: false,
199
+ run: async ({ deckId }) => {
200
+ const rawRequesterEmail = getRequestUserEmail();
201
+ if (!rawRequesterEmail) {
202
+ throw httpError("Sign in to request access to this deck.", 401);
203
+ }
204
+ const requesterEmail = normalizeEmail(rawRequesterEmail);
205
+
206
+ const db = getDb();
207
+ const claimNotification = async (
208
+ requestId: string,
209
+ currentPayloadJson: string,
210
+ payload: AccessRequestPayload,
211
+ ): Promise<AccessRequestPayload | null> => {
212
+ const claimedPayload: AccessRequestPayload = {
213
+ ...payload,
214
+ notificationClaimedAt: new Date().toISOString(),
215
+ notificationClaimToken: randomUUID(),
216
+ };
217
+ try {
218
+ const [claimed] = await db
219
+ .update(schema.deckEvents)
220
+ .set({
221
+ payload: JSON.stringify(claimedPayload),
222
+ })
223
+ .where(
224
+ and(
225
+ eq(schema.deckEvents.id, requestId),
226
+ eq(schema.deckEvents.payload, currentPayloadJson),
227
+ ),
228
+ )
229
+ .returning({ id: schema.deckEvents.id });
230
+ return claimed ? claimedPayload : null;
231
+ } catch (error) {
232
+ console.warn("[deck-access] notification claim failed:", error);
233
+ return null;
234
+ }
235
+ };
236
+ const renewNotificationClaim = async (
237
+ requestId: string,
238
+ claimedPayload: AccessRequestPayload,
239
+ ): Promise<AccessRequestPayload | null> => {
240
+ const renewedPayload: AccessRequestPayload = {
241
+ ...claimedPayload,
242
+ notificationClaimedAt: new Date().toISOString(),
243
+ };
244
+ try {
245
+ const [renewed] = await db
246
+ .update(schema.deckEvents)
247
+ .set({
248
+ payload: JSON.stringify(renewedPayload),
249
+ })
250
+ .where(
251
+ and(
252
+ eq(schema.deckEvents.id, requestId),
253
+ eq(schema.deckEvents.payload, JSON.stringify(claimedPayload)),
254
+ ),
255
+ )
256
+ .returning({ id: schema.deckEvents.id });
257
+ return renewed ? renewedPayload : null;
258
+ } catch (error) {
259
+ // Keep the current payload so the next heartbeat can retry a
260
+ // transient database failure without dropping the claim.
261
+ console.warn("[deck-access] notification claim renewal failed:", error);
262
+ return claimedPayload;
263
+ }
264
+ };
265
+ const notifyWithClaimHeartbeat = async (
266
+ requestId: string,
267
+ claimedPayload: AccessRequestPayload,
268
+ notify: () => Promise<AccessRequestNotificationState>,
269
+ ): Promise<{
270
+ state: AccessRequestNotificationState;
271
+ claimedPayload: AccessRequestPayload;
272
+ }> => {
273
+ let currentPayload = claimedPayload;
274
+ let stopped = false;
275
+ let renewalInFlight = Promise.resolve();
276
+ const renew = () => {
277
+ if (stopped) return renewalInFlight;
278
+ renewalInFlight = renewalInFlight.then(async () => {
279
+ if (stopped) return;
280
+ const renewedPayload = await renewNotificationClaim(
281
+ requestId,
282
+ currentPayload,
283
+ );
284
+ if (renewedPayload) currentPayload = renewedPayload;
285
+ });
286
+ return renewalInFlight;
287
+ };
288
+ const heartbeat = setInterval(renew, NOTIFICATION_CLAIM_HEARTBEAT_MS);
289
+ let deliveredState: AccessRequestNotificationState | null = null;
290
+ try {
291
+ deliveredState = await notify();
292
+ } finally {
293
+ stopped = true;
294
+ clearInterval(heartbeat);
295
+ await renewalInFlight;
296
+ }
297
+ if (!deliveredState) {
298
+ throw new Error("Notification delivery did not return a state.");
299
+ }
300
+ return {
301
+ state: deliveredState,
302
+ claimedPayload: currentPayload,
303
+ };
304
+ };
305
+ const recordNotification = async (
306
+ requestId: string,
307
+ claimedPayload: AccessRequestPayload,
308
+ state: AccessRequestNotificationState,
309
+ ): Promise<boolean> => {
310
+ const delivered = notificationWasDelivered(state);
311
+ const persistedPayload: AccessRequestPayload = {
312
+ ...claimedPayload,
313
+ inAppNotified: state.inAppNotified,
314
+ emailNotified: state.emailNotified,
315
+ notifiedOwner: delivered,
316
+ notificationClaimedAt: undefined,
317
+ notificationClaimToken: undefined,
318
+ ...(delivered ? { notifiedAt: new Date().toISOString() } : {}),
319
+ };
320
+ try {
321
+ const [persisted] = await db
322
+ .update(schema.deckEvents)
323
+ .set({ payload: JSON.stringify(persistedPayload) })
324
+ .where(
325
+ and(
326
+ eq(schema.deckEvents.id, requestId),
327
+ eq(schema.deckEvents.payload, JSON.stringify(claimedPayload)),
328
+ ),
329
+ )
330
+ .returning({ id: schema.deckEvents.id });
331
+ if (!persisted) {
332
+ console.warn(
333
+ "[deck-access] notification status update was superseded:",
334
+ requestId,
335
+ );
336
+ }
337
+ return Boolean(persisted);
338
+ } catch (error) {
339
+ console.warn("[deck-access] notification status update failed:", error);
340
+ return false;
341
+ }
342
+ };
343
+ const [deck] = await db
344
+ .select({
345
+ id: schema.decks.id,
346
+ title: schema.decks.title,
347
+ ownerEmail: schema.decks.ownerEmail,
348
+ visibility: schema.decks.visibility,
349
+ })
350
+ .from(schema.decks)
351
+ .where(eq(schema.decks.id, deckId))
352
+ .limit(1);
353
+
354
+ if (!deck) {
355
+ throw httpError(`Deck ${deckId} not found`, 404);
356
+ }
357
+
358
+ const access = await resolveAccess("deck", deckId, currentAccess());
359
+ if (access) {
360
+ return {
361
+ ok: true as const,
362
+ alreadyHasAccess: true,
363
+ notifiedOwner: false,
364
+ message: "You already have access. Refreshing the deck...",
365
+ };
366
+ }
367
+ if ((deck.visibility ?? "private") !== "private") {
368
+ return {
369
+ ok: true as const,
370
+ alreadyHasAccess: false,
371
+ alreadyRequested: false,
372
+ notifiedOwner: false,
373
+ message: "Access requests are only available for private decks.",
374
+ };
375
+ }
376
+
377
+ const requesterName =
378
+ getRequestUserName()?.trim() || displayNameForEmail(requesterEmail);
379
+ const ownerEmail = deck.ownerEmail?.trim() || null;
380
+ const emailConfigured = await isEmailConfigured();
381
+ const previousRequests = await db
382
+ .select({
383
+ id: schema.deckEvents.id,
384
+ payload: schema.deckEvents.payload,
385
+ })
386
+ .from(schema.deckEvents)
387
+ .where(
388
+ and(
389
+ eq(schema.deckEvents.deckId, deckId),
390
+ eq(schema.deckEvents.type, "deck.access_requested"),
391
+ ),
392
+ );
393
+ const previousRequest = previousRequests.find((event) => {
394
+ const payload = parseAccessRequestPayload(event.payload);
395
+ return (
396
+ typeof payload?.requesterEmail === "string" &&
397
+ normalizeEmail(payload.requesterEmail) === requesterEmail
398
+ );
399
+ });
400
+
401
+ if (previousRequest) {
402
+ const previousPayload = parseAccessRequestPayload(
403
+ previousRequest.payload,
404
+ );
405
+ const previousRequestId = previousRequest.id;
406
+ if (!previousPayload) {
407
+ return {
408
+ ok: true as const,
409
+ alreadyHasAccess: false,
410
+ alreadyRequested: true,
411
+ notifiedOwner: false,
412
+ requestId: previousRequestId,
413
+ message: "Your access request is already with the deck owner.",
414
+ };
415
+ }
416
+
417
+ const previousState = notificationStateFor(
418
+ previousPayload,
419
+ ownerEmail,
420
+ requesterEmail,
421
+ emailConfigured,
422
+ );
423
+ if (!notificationStillNeeded(previousState)) {
424
+ return {
425
+ ok: true as const,
426
+ alreadyHasAccess: false,
427
+ alreadyRequested: true,
428
+ notifiedOwner: notificationWasDelivered(previousState),
429
+ requestId: previousRequestId,
430
+ message: "Your access request is already with the deck owner.",
431
+ };
432
+ }
433
+ if (hasRecentNotificationClaim(previousPayload.notificationClaimedAt)) {
434
+ return {
435
+ ok: true as const,
436
+ alreadyHasAccess: false,
437
+ alreadyRequested: true,
438
+ notifiedOwner: notificationWasDelivered(previousState),
439
+ requestId: previousRequestId,
440
+ message: "Your access request is already with the deck owner.",
441
+ };
442
+ }
443
+
444
+ const claimedPayload = await claimNotification(
445
+ previousRequestId,
446
+ previousRequest.payload ?? "",
447
+ previousPayload,
448
+ );
449
+ if (!claimedPayload) {
450
+ return {
451
+ ok: true as const,
452
+ alreadyHasAccess: false,
453
+ alreadyRequested: true,
454
+ notifiedOwner: notificationWasDelivered(previousState),
455
+ requestId: previousRequestId,
456
+ message: "Your access request is already with the deck owner.",
457
+ };
458
+ }
459
+
460
+ const delivery = await notifyWithClaimHeartbeat(
461
+ previousRequestId,
462
+ claimedPayload,
463
+ () =>
464
+ notifyAccessRequestOwner({
465
+ deckId,
466
+ deckTitle: deck.title,
467
+ ownerEmail,
468
+ requesterEmail,
469
+ requesterName:
470
+ previousPayload?.requesterName?.trim() || requesterName,
471
+ state: previousState,
472
+ }),
473
+ );
474
+ const persisted = await recordNotification(
475
+ previousRequestId,
476
+ delivery.claimedPayload,
477
+ delivery.state,
478
+ );
479
+ const notifiedOwner =
480
+ persisted && notificationWasDelivered(delivery.state);
481
+
482
+ return {
483
+ ok: true as const,
484
+ alreadyHasAccess: false,
485
+ alreadyRequested: true,
486
+ notifiedOwner,
487
+ requestId: previousRequestId,
488
+ message: notifiedOwner
489
+ ? "Access request sent to the deck owner."
490
+ : "Your access request is already with the deck owner.",
491
+ };
492
+ }
493
+
494
+ const requestId = accessRequestEventId(deckId, requesterEmail);
495
+ const requestedAt = new Date().toISOString();
496
+ const initialPayload: AccessRequestPayload = {
497
+ requestId,
498
+ requesterEmail,
499
+ requesterName,
500
+ requestedAt,
501
+ notifiedOwner: false,
502
+ inAppNotified: false,
503
+ emailNotified: false,
504
+ notificationClaimedAt: requestedAt,
505
+ notificationClaimToken: randomUUID(),
506
+ };
507
+ const initialPayloadJson = JSON.stringify(initialPayload);
508
+
509
+ const [insertedRequest] = await db
510
+ .insert(schema.deckEvents)
511
+ .values({
512
+ id: requestId,
513
+ deckId,
514
+ type: "deck.access_requested",
515
+ message: `${requesterEmail} requested access to this deck.`,
516
+ payload: initialPayloadJson,
517
+ createdBy: "human",
518
+ createdAt: requestedAt,
519
+ })
520
+ .onConflictDoNothing()
521
+ .returning({ id: schema.deckEvents.id });
522
+
523
+ if (!insertedRequest) {
524
+ return {
525
+ ok: true as const,
526
+ alreadyHasAccess: false,
527
+ alreadyRequested: true,
528
+ notifiedOwner: false,
529
+ message: "Your access request is already with the deck owner.",
530
+ };
531
+ }
532
+
533
+ const initialState = notificationStateFor(
534
+ initialPayload,
535
+ ownerEmail,
536
+ requesterEmail,
537
+ emailConfigured,
538
+ );
539
+ const delivery = await notifyWithClaimHeartbeat(
540
+ requestId,
541
+ initialPayload,
542
+ () =>
543
+ notifyAccessRequestOwner({
544
+ deckId,
545
+ deckTitle: deck.title,
546
+ ownerEmail,
547
+ requesterEmail,
548
+ requesterName,
549
+ state: initialState,
550
+ }),
551
+ );
552
+ const persisted = await recordNotification(
553
+ requestId,
554
+ delivery.claimedPayload,
555
+ delivery.state,
556
+ );
557
+ const notifiedOwner = persisted && notificationWasDelivered(delivery.state);
558
+
559
+ return {
560
+ ok: true as const,
561
+ alreadyHasAccess: false,
562
+ notifiedOwner,
563
+ requestId,
564
+ message: notifiedOwner
565
+ ? "Access request sent to the deck owner."
566
+ : "Access request recorded for the deck owner.",
567
+ };
568
+ },
569
+ });
@@ -7,6 +7,9 @@
7
7
  }
8
8
  },
9
9
  "doctor": {
10
+ "dbToolScopingDenylist": {
11
+ "slides:deck_events": "Durable access-request audit rows are written by the request action and are not exposed through generic agent DB reads."
12
+ },
10
13
  "failOnBuild": false
11
14
  }
12
15
  }
@@ -1,5 +1,6 @@
1
1
  import { AgentToggleButton } from "@agent-native/core/client/agent-chat";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
+ import { NotificationsBell } from "@agent-native/core/client/notifications";
3
4
  import { RunsTray } from "@agent-native/core/client/progress";
4
5
  import {
5
6
  useHeaderTitle,
@@ -68,6 +69,7 @@ export function Header() {
68
69
  </div>
69
70
  <div className="flex items-center gap-2 shrink-0">
70
71
  {actions}
72
+ <NotificationsBell pollMs={30_000} />
71
73
  <RunsTray pollMs={0} />
72
74
  <AgentToggleButton />
73
75
  </div>