@agent-native/core 0.162.0 → 0.163.1

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 (132) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/sharing/SKILL.md +27 -2
  3. package/corpus/templates/analytics/server/plugins/collab.ts +1 -0
  4. package/corpus/templates/assets/.agents/skills/sharing/SKILL.md +27 -2
  5. package/corpus/templates/brain/.agents/skills/sharing/SKILL.md +27 -2
  6. package/corpus/templates/calendar/.agents/skills/sharing/SKILL.md +27 -2
  7. package/corpus/templates/chat/.agents/skills/sharing/SKILL.md +27 -2
  8. package/corpus/templates/clips/app/lib/camera-composite.ts +15 -1
  9. package/corpus/templates/clips/changelog/2026-08-18-clips-no-longer-crashes-when-a-camera-bubble-starts-at-a-tin.md +6 -0
  10. package/corpus/templates/content/.agents/skills/sharing/SKILL.md +27 -2
  11. package/corpus/templates/content/actions/create-content-database.ts +1 -1
  12. package/corpus/templates/content/actions/create-document.ts +1 -1
  13. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +27 -2
  14. package/corpus/templates/design/.agents/skills/sharing/SKILL.md +27 -2
  15. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +6 -1
  16. package/corpus/templates/dispatch/.agents/skills/sharing/SKILL.md +27 -2
  17. package/corpus/templates/factory/.agents/skills/sharing/SKILL.md +27 -2
  18. package/corpus/templates/forms/.agents/skills/sharing/SKILL.md +27 -2
  19. package/corpus/templates/macros/.agents/skills/sharing/SKILL.md +27 -2
  20. package/corpus/templates/mail/.agents/skills/sharing/SKILL.md +27 -2
  21. package/corpus/templates/plan/.agents/skills/sharing/SKILL.md +27 -2
  22. package/corpus/templates/slides/.agents/skills/sharing/SKILL.md +27 -2
  23. package/corpus/templates/slides/DEVELOPING.md +17 -0
  24. package/corpus/templates/slides/actions/get-deck-access-status.ts +67 -0
  25. package/corpus/templates/slides/actions/request-deck-access.ts +569 -0
  26. package/corpus/templates/slides/agent-native.json +3 -0
  27. package/corpus/templates/slides/app/components/editor/GoogleDriveConnectionCta.tsx +3 -3
  28. package/corpus/templates/slides/app/components/editor/GoogleSlidesReferenceImport.tsx +3 -3
  29. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +3 -1
  30. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -1
  31. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +1 -1
  32. package/corpus/templates/slides/app/components/layout/Header.tsx +2 -0
  33. package/corpus/templates/slides/app/hooks/use-deck-access.ts +51 -0
  34. package/corpus/templates/slides/app/i18n/en-US.ts +12 -0
  35. package/corpus/templates/slides/app/lib/deck-editor-loading.ts +7 -0
  36. package/corpus/templates/slides/app/pages/DeckEditor.tsx +173 -33
  37. package/corpus/templates/slides/changelog/2026-08-18-private-deck-links-now-explain-access-and-let-viewers-notify.md +6 -0
  38. package/corpus/templates/slides/server/db/schema.ts +10 -0
  39. package/corpus/templates/slides/server/handlers/request-auth-context.ts +1 -0
  40. package/corpus/templates/slides/server/plugins/db.ts +17 -0
  41. package/corpus/templates/tasks/.agents/skills/sharing/SKILL.md +27 -2
  42. package/dist/app-config/schema.d.ts +1 -0
  43. package/dist/app-config/workspace.d.ts +1 -0
  44. package/dist/app-config/workspace.js +8 -0
  45. package/dist/chat-threads/schema.d.ts +2 -2
  46. package/dist/client/analytics.d.ts +1 -0
  47. package/dist/client/analytics.js +19 -5
  48. package/dist/client/org/TeamPage.js +15 -2
  49. package/dist/client/org/hooks.d.ts +2 -0
  50. package/dist/client/org/hooks.js +12 -0
  51. package/dist/client/org/index.d.ts +2 -2
  52. package/dist/client/org/index.js +1 -1
  53. package/dist/client/sharing/ShareButton.js +43 -18
  54. package/dist/client/sharing/useShareButtonController.d.ts +12 -2
  55. package/dist/client/sharing/useShareButtonController.js +38 -8
  56. package/dist/collab/awareness.d.ts +2 -2
  57. package/dist/collab/routes.d.ts +1 -1
  58. package/dist/collab/storage.d.ts +2 -0
  59. package/dist/collab/storage.js +7 -0
  60. package/dist/config.js +11 -1
  61. package/dist/dashboard-storage/schema.d.ts +2 -2
  62. package/dist/data-programs/schema.d.ts +2 -2
  63. package/dist/deploy/build.d.ts +1 -1
  64. package/dist/deploy/build.js +89 -12
  65. package/dist/extensions/schema.d.ts +2 -2
  66. package/dist/extensions/store.js +3 -0
  67. package/dist/localization/core-messages/ar-SA.js +1 -0
  68. package/dist/localization/core-messages/de-DE.js +1 -0
  69. package/dist/localization/core-messages/en-US.d.ts +1 -0
  70. package/dist/localization/core-messages/en-US.js +1 -0
  71. package/dist/localization/core-messages/es-ES.js +1 -0
  72. package/dist/localization/core-messages/fr-FR.js +1 -0
  73. package/dist/localization/core-messages/hi-IN.js +1 -0
  74. package/dist/localization/core-messages/ja-JP.js +1 -0
  75. package/dist/localization/core-messages/ko-KR.js +1 -0
  76. package/dist/localization/core-messages/pt-BR.js +1 -0
  77. package/dist/localization/core-messages/zh-CN.js +1 -0
  78. package/dist/localization/core-messages/zh-TW.js +1 -0
  79. package/dist/localization/core-messages.d.ts +1 -0
  80. package/dist/localization/default-messages.d.ts +4 -0
  81. package/dist/localization/default-messages.js +4 -0
  82. package/dist/observability/routes.d.ts +1 -1
  83. package/dist/observability/tracking-identity.js +2 -0
  84. package/dist/org/handlers.d.ts +6 -1
  85. package/dist/org/handlers.js +43 -0
  86. package/dist/org/index.d.ts +3 -3
  87. package/dist/org/index.js +2 -2
  88. package/dist/org/migrations.js +33 -0
  89. package/dist/org/plugin.js +10 -1
  90. package/dist/org/schema.d.ts +315 -0
  91. package/dist/org/schema.js +20 -1
  92. package/dist/org/types.d.ts +3 -0
  93. package/dist/org/workspace-app-access.d.ts +10 -0
  94. package/dist/org/workspace-app-access.js +179 -0
  95. package/dist/provider-api/actions/custom-provider-registration.d.ts +8 -8
  96. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  97. package/dist/secrets/routes.d.ts +9 -9
  98. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  99. package/dist/server/auth.js +14 -1
  100. package/dist/server/collab-plugin.d.ts +6 -0
  101. package/dist/server/collab-plugin.js +27 -6
  102. package/dist/server/deploy-environment.d.ts +1 -1
  103. package/dist/server/deploy-environment.js +40 -2
  104. package/dist/server/poll.js +6 -0
  105. package/dist/server/realtime-token.d.ts +1 -1
  106. package/dist/server/sentry-config.js +8 -4
  107. package/dist/server/sentry.js +4 -0
  108. package/dist/sharing/access.d.ts +2 -2
  109. package/dist/sharing/access.js +56 -3
  110. package/dist/sharing/actions/list-resource-shares.d.ts +2 -0
  111. package/dist/sharing/actions/list-resource-shares.js +26 -1
  112. package/dist/sharing/actions/share-resource.d.ts +1 -1
  113. package/dist/sharing/actions/share-resource.js +23 -4
  114. package/dist/sharing/actions/unshare-resource.d.ts +1 -1
  115. package/dist/sharing/actions/unshare-resource.js +1 -1
  116. package/dist/sharing/registry.d.ts +12 -0
  117. package/dist/sharing/schema.d.ts +4 -4
  118. package/dist/sharing/schema.js +2 -2
  119. package/dist/templates/chat/.agents/skills/sharing/SKILL.md +27 -2
  120. package/dist/templates/default/.agents/skills/sharing/SKILL.md +27 -2
  121. package/dist/templates/workspace-core/.agents/skills/sharing/SKILL.md +27 -2
  122. package/dist/tracking/registry.js +8 -6
  123. package/dist/vite/client.js +5 -0
  124. package/dist/workspace-connections/actions/list-workspace-user-groups.d.ts +9 -1
  125. package/dist/workspace-connections/actions/list-workspace-user-groups.js +9 -4
  126. package/dist/workspace-connections/groups.d.ts +2 -0
  127. package/dist/workspace-connections/groups.js +16 -5
  128. package/docs/content/deployment.mdx +19 -5
  129. package/package.json +1 -1
  130. package/src/templates/chat/.agents/skills/sharing/SKILL.md +27 -2
  131. package/src/templates/default/.agents/skills/sharing/SKILL.md +27 -2
  132. package/src/templates/workspace-core/.agents/skills/sharing/SKILL.md +27 -2
@@ -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
  }
@@ -35,11 +35,11 @@ async function readJson<T>(response: Response): Promise<JsonReadResult<T>> {
35
35
  try {
36
36
  return { ok: true, data: (await response.json()) as T };
37
37
  } catch (caught) {
38
+ const error = new Error("The server returned an unreadable response.");
39
+ (error as Error & { cause?: unknown }).cause = caught;
38
40
  return {
39
41
  ok: false,
40
- error: new Error("The server returned an unreadable response.", {
41
- cause: caught,
42
- }),
42
+ error,
43
43
  };
44
44
  }
45
45
  }
@@ -56,11 +56,11 @@ async function readJson<T>(response: Response): Promise<JsonReadResult<T>> {
56
56
  try {
57
57
  return { ok: true, data: (await response.json()) as T };
58
58
  } catch (caught) {
59
+ const error = new Error("The server returned an unreadable response.");
60
+ (error as Error & { cause?: unknown }).cause = caught;
59
61
  return {
60
62
  ok: false,
61
- error: new Error("The server returned an unreadable response.", {
62
- cause: caught,
63
- }),
63
+ error,
64
64
  };
65
65
  }
66
66
  }
@@ -55,7 +55,9 @@ export async function uploadPromptFiles(
55
55
  message = data.error;
56
56
  }
57
57
  } catch (error) {
58
- throw new Error(`Upload failed (${response.status})`, { cause: error });
58
+ const uploadError = new Error(`Upload failed (${response.status})`);
59
+ (uploadError as Error & { cause?: unknown }).cause = error;
60
+ throw uploadError;
59
61
  }
60
62
  throw new Error(message);
61
63
  }
@@ -325,7 +325,7 @@ function stripPlaceholderZws(root: Element): void {
325
325
  }
326
326
  for (const textNode of textNodes) {
327
327
  if (!textNode.data.includes(ZERO_WIDTH_SPACE)) continue;
328
- const withoutZws = textNode.data.replaceAll(ZERO_WIDTH_SPACE, "");
328
+ const withoutZws = textNode.data.split(ZERO_WIDTH_SPACE).join("");
329
329
  if (withoutZws.length > 0) {
330
330
  textNode.data = withoutZws;
331
331
  } else if (textNode.parentNode?.childNodes.length !== 1) {
@@ -83,7 +83,7 @@ export interface SlideSelectionIdentity {
83
83
  }
84
84
 
85
85
  function escapeAttributeValue(value: string): string {
86
- return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
86
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
87
87
  }
88
88
 
89
89
  export function getSlideSelectionIdentity(