@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/server/plugins/collab.ts +1 -0
- package/corpus/templates/slides/DEVELOPING.md +17 -0
- package/corpus/templates/slides/actions/get-deck-access-status.ts +67 -0
- package/corpus/templates/slides/actions/request-deck-access.ts +569 -0
- package/corpus/templates/slides/agent-native.json +3 -0
- package/corpus/templates/slides/app/components/layout/Header.tsx +2 -0
- package/corpus/templates/slides/app/hooks/use-deck-access.ts +51 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +12 -0
- package/corpus/templates/slides/app/lib/deck-editor-loading.ts +7 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +172 -32
- package/corpus/templates/slides/changelog/2026-08-18-private-deck-links-now-explain-access-and-let-viewers-notify.md +6 -0
- package/corpus/templates/slides/server/db/schema.ts +10 -0
- package/corpus/templates/slides/server/plugins/db.ts +17 -0
- package/dist/client/analytics.d.ts +1 -0
- package/dist/client/analytics.js +19 -5
- package/dist/client/session-replay.js +349 -100
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/storage.d.ts +2 -0
- package/dist/collab/storage.js +7 -0
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/config.js +11 -1
- package/dist/deploy/build.d.ts +1 -1
- package/dist/deploy/build.js +89 -12
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/observability/tracking-identity.js +2 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/collab-plugin.d.ts +6 -0
- package/dist/server/collab-plugin.js +27 -6
- package/dist/server/deploy-environment.d.ts +1 -1
- package/dist/server/deploy-environment.js +40 -2
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/sentry-config.js +8 -4
- package/dist/server/sentry.js +4 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/tracking/registry.js +8 -6
- package/dist/vite/client.js +5 -0
- package/docs/content/deployment.mdx +19 -5
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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>
|