@agent-native/core 0.201.1 → 0.202.0-nightly-20261006033736

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 (98) hide show
  1. package/dist/automation/index.d.ts +1 -1
  2. package/dist/client/analytics/index.d.ts +1 -1
  3. package/dist/client/analytics/index.js +1 -1
  4. package/dist/client/analytics.d.ts +23 -3
  5. package/dist/client/analytics.js +189 -46
  6. package/dist/client/client-status-requests.d.ts +4 -1
  7. package/dist/client/client-status-requests.js +21 -5
  8. package/dist/client/embed-auth.d.ts +6 -0
  9. package/dist/client/embed-auth.js +13 -0
  10. package/dist/client/session-replay.js +48 -22
  11. package/dist/client/sharing/index.d.ts +2 -0
  12. package/dist/client/sharing/index.js +2 -0
  13. package/dist/client/sharing/useAccessRequestReview.d.ts +80 -0
  14. package/dist/client/sharing/useAccessRequestReview.js +100 -0
  15. package/dist/client/sharing/useResourceAccessGate.d.ts +57 -0
  16. package/dist/client/sharing/useResourceAccessGate.js +76 -0
  17. package/dist/client/use-agent-engine-configured.d.ts +2 -0
  18. package/dist/client/use-agent-engine-configured.js +4 -1
  19. package/dist/email-catalog/registry.d.ts +1 -1
  20. package/dist/email-catalog/registry.js +1 -1
  21. package/dist/email-catalog/system-emails.d.ts +2 -2
  22. package/dist/email-catalog/system-emails.js +47 -2
  23. package/dist/email-catalog/templates.d.ts +5 -1
  24. package/dist/email-catalog/templates.js +11 -1
  25. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  26. package/dist/framework-tools.js +6 -0
  27. package/dist/identity/rekey.js +9 -0
  28. package/dist/jobs/run-history.d.ts +27 -8
  29. package/dist/jobs/run-history.js +17 -10
  30. package/dist/jobs/scheduler.js +39 -10
  31. package/dist/mcp/build-server.d.ts +1 -0
  32. package/dist/mcp/build-server.js +30 -7
  33. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  34. package/dist/notifications/routes.d.ts +2 -2
  35. package/dist/observability/routes.d.ts +7 -7
  36. package/dist/org/index.d.ts +1 -1
  37. package/dist/progress/routes.d.ts +1 -1
  38. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  39. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  40. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  41. package/dist/review/suggestions/actions.js +14 -35
  42. package/dist/review/suggestions/store.d.ts +0 -1
  43. package/dist/review/suggestions/store.js +0 -7
  44. package/dist/secrets/routes.d.ts +11 -11
  45. package/dist/server/action-discovery.js +24 -0
  46. package/dist/server/agent-access.js +7 -3
  47. package/dist/server/agent-chat/mcp-options.d.ts +1 -0
  48. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  49. package/dist/server/agent-engine-default-model-route.d.ts +1 -1
  50. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  51. package/dist/server/attribution.d.ts +33 -1
  52. package/dist/server/attribution.js +93 -21
  53. package/dist/server/auth.js +35 -11
  54. package/dist/server/email-templates.d.ts +34 -0
  55. package/dist/server/email-templates.js +40 -0
  56. package/dist/server/magic-link-attribution.js +2 -1
  57. package/dist/server/onboarding-html.js +35 -67
  58. package/dist/server/release-schema.js +4 -0
  59. package/dist/server/short-lived-token.d.ts +7 -0
  60. package/dist/server/short-lived-token.js +59 -0
  61. package/dist/shared/analytics-anonymous-id.d.ts +1 -0
  62. package/dist/shared/analytics-anonymous-id.js +1 -1
  63. package/dist/shared/attribution-source.d.ts +30 -0
  64. package/dist/shared/attribution-source.js +65 -0
  65. package/dist/sharing/access-request-store.d.ts +96 -0
  66. package/dist/sharing/access-request-store.js +213 -0
  67. package/dist/sharing/access-requests.d.ts +113 -0
  68. package/dist/sharing/access-requests.js +490 -0
  69. package/dist/sharing/access.d.ts +36 -2
  70. package/dist/sharing/access.js +74 -12
  71. package/dist/sharing/actions/approve-resource-access-request.d.ts +10 -0
  72. package/dist/sharing/actions/approve-resource-access-request.js +15 -0
  73. package/dist/sharing/actions/decline-resource-access-request.d.ts +7 -0
  74. package/dist/sharing/actions/decline-resource-access-request.js +14 -0
  75. package/dist/sharing/actions/get-resource-access-request.d.ts +4 -0
  76. package/dist/sharing/actions/get-resource-access-request.js +13 -0
  77. package/dist/sharing/actions/get-resource-access-status.d.ts +5 -0
  78. package/dist/sharing/actions/get-resource-access-status.js +19 -0
  79. package/dist/sharing/actions/list-resource-access-requests.d.ts +5 -0
  80. package/dist/sharing/actions/list-resource-access-requests.js +16 -0
  81. package/dist/sharing/actions/list-resource-shares.d.ts +1 -1
  82. package/dist/sharing/actions/request-resource-access.d.ts +6 -0
  83. package/dist/sharing/actions/request-resource-access.js +17 -0
  84. package/dist/sharing/actions/share-resource.d.ts +1 -1
  85. package/dist/sharing/actions/share-resource.js +18 -144
  86. package/dist/sharing/grant.d.ts +49 -0
  87. package/dist/sharing/grant.js +195 -0
  88. package/dist/sharing/index.d.ts +2 -1
  89. package/dist/sharing/index.js +2 -1
  90. package/dist/sharing/registry.d.ts +36 -0
  91. package/dist/templates/chat/server/plugins/auth.ts +2 -2
  92. package/dist/templates/default/app/routes/access-requests.$requestId.tsx +7 -0
  93. package/dist/templates/default/server/plugins/auth.ts +2 -2
  94. package/dist/vite/action-types-plugin.js +24 -0
  95. package/package.json +11 -3
  96. package/src/templates/chat/server/plugins/auth.ts +2 -2
  97. package/src/templates/default/app/routes/access-requests.$requestId.tsx +7 -0
  98. package/src/templates/default/server/plugins/auth.ts +2 -2
@@ -0,0 +1,490 @@
1
+ import { and, asc, eq } from "drizzle-orm";
2
+ import { fail } from "../action.js";
3
+ import { getDbExec, withDbExec } from "../db/client.js";
4
+ import { CORE_ACCESS_GRANTED_EMAIL_ID, CORE_ACCESS_REQUESTED_EMAIL_ID, renderTransactionalEmail, } from "../email-catalog/templates.js";
5
+ import { notifyWithDelivery } from "../notifications/registry.js";
6
+ import { getAppProductionUrl } from "../server/app-url.js";
7
+ import { resolveEmailBrandApp } from "../server/email-templates.js";
8
+ import { getEmailReadiness, isEmailConfigured, sendEmail, } from "../server/email.js";
9
+ import { getUserProfile } from "../user-profile/store.js";
10
+ import { decideAccessRequest, deleteAccessRequest, findAccessRequest, getAccessRequest, listPendingAccessRequests, countRecentAccessRequests, openAccessRequest, recordAccessRequestDelivery, } from "./access-request-store.js";
11
+ import { currentAccess, resolveAccess, resolveAccessStatus, } from "./access.js";
12
+ import { resolveShareNotificationUrl } from "./actions/share-resource.js";
13
+ import { announceResourceAccessChange, grantResourceAccess } from "./grant.js";
14
+ import { filterRecipientsByResourceAccess } from "./recipients.js";
15
+ import { requireShareableResource, } from "./registry.js";
16
+ export const ACCESS_REQUEST_NOTE_MAX_LENGTH = 500;
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ /** How long a declined requester waits before they can ask again. */
19
+ export const ACCESS_REQUEST_DECLINE_COOLDOWN_MS = 7 * DAY_MS;
20
+ /** Requests one person can make in a day, across every resource. */
21
+ export const ACCESS_REQUESTS_PER_REQUESTER_PER_DAY = 20;
22
+ /** Requests one owner can receive in a day, across their resources. */
23
+ export const ACCESS_REQUESTS_PER_OWNER_PER_DAY = 50;
24
+ /**
25
+ * How long a request nobody has been told about yet reads as still sending.
26
+ * After that its send was cut off, and asking again sends it again; the
27
+ * request's idempotency keys stop anyone it already reached hearing twice.
28
+ */
29
+ export const ACCESS_REQUEST_SEND_WINDOW_MS = 2 * 60 * 1000;
30
+ // People shared directly as admin who hear about a request, besides the owner.
31
+ const ADMIN_RECIPIENT_LIMIT = 20;
32
+ const PENDING_LIST_LIMIT = 50;
33
+ /** Where someone who manages access reviews a request, relative to the app. */
34
+ export function accessRequestReviewPath(requestId) {
35
+ return `/access-requests/${encodeURIComponent(requestId)}`;
36
+ }
37
+ function iso(ms) {
38
+ return new Date(ms).toISOString();
39
+ }
40
+ function viewerEmail() {
41
+ return currentAccess().userEmail?.trim().toLowerCase() || null;
42
+ }
43
+ // A declined request reads to the requester as still pending until the
44
+ // cooldown ends, so declining tells them nothing and they can't ask again
45
+ // straight away. A pending one whose send was cut off before anyone was told
46
+ // isn't open, so the requester isn't left waiting on nobody.
47
+ function openRequestFor(row, now) {
48
+ if (!row)
49
+ return undefined;
50
+ const open = (row.state === "pending" &&
51
+ (row.delivered ||
52
+ row.requestedAt > now - ACCESS_REQUEST_SEND_WINDOW_MS)) ||
53
+ (row.state === "declined" &&
54
+ row.decidedAt !== null &&
55
+ row.decidedAt > now - ACCESS_REQUEST_DECLINE_COOLDOWN_MS);
56
+ return open
57
+ ? { state: "pending", requestedAt: iso(row.requestedAt) }
58
+ : undefined;
59
+ }
60
+ /**
61
+ * {@link resolveAccessStatus}, plus whether a viewer who can't open the
62
+ * resource can ask for access, and their own open request if they already
63
+ * asked. Registrations without `accessRequests` never offer it.
64
+ */
65
+ export async function resolveLinkStatus(resourceType, resourceId) {
66
+ const status = await resolveAccessStatus(resourceType, resourceId);
67
+ const reg = requireShareableResource(resourceType);
68
+ const email = viewerEmail();
69
+ if (status.state !== "denied" || !reg.accessRequests || !email) {
70
+ return status;
71
+ }
72
+ const request = openRequestFor(await findAccessRequest(resourceType, resourceId, email), Date.now());
73
+ return request
74
+ ? { ...status, canRequest: false, request }
75
+ : { ...status, canRequest: true };
76
+ }
77
+ function requireSignedIn(message) {
78
+ const email = viewerEmail();
79
+ if (!email)
80
+ fail(message, { errorCode: "sign_in_required", statusCode: 401 });
81
+ return email;
82
+ }
83
+ async function loadResource(reg, resourceId) {
84
+ const [resource] = await reg.getDb()
85
+ .select()
86
+ .from(reg.resourceTable)
87
+ .where(eq(reg.resourceTable.id, resourceId));
88
+ return resource;
89
+ }
90
+ function resourceTitle(reg, resource) {
91
+ const title = resource?.[reg.titleColumn ?? "title"];
92
+ return typeof title === "string" && title.trim()
93
+ ? title.trim()
94
+ : reg.displayName;
95
+ }
96
+ function resourcePath(reg, resource) {
97
+ return reg.getResourcePath ? reg.getResourcePath(resource) : undefined;
98
+ }
99
+ async function emailBrand(reg, resource) {
100
+ const app = resolveEmailBrandApp();
101
+ let name = app.name;
102
+ let logoUrl = app.logoUrl;
103
+ try {
104
+ name = (await reg.getBrandName?.(resource))?.trim() || name;
105
+ }
106
+ catch (err) {
107
+ console.error("[access-requests] brand name resolver failed:", err);
108
+ }
109
+ try {
110
+ logoUrl = (await reg.getLogoUrl?.(resource)) ?? logoUrl;
111
+ }
112
+ catch (err) {
113
+ console.error("[access-requests] brand logo resolver failed:", err);
114
+ }
115
+ return { name, logoUrl };
116
+ }
117
+ // The owner and people shared directly as admin, kept only while their access
118
+ // still resolves to admin: on a restricted resource an admin share grants
119
+ // nothing outside its organization, and a request carries the title and the
120
+ // note. Anyone who manages access another way, through an organization, a
121
+ // group, or the app's own rule, sees requests in the Share panel instead.
122
+ async function requestRecipients(reg, resource, request) {
123
+ const rows = await reg.getDb()
124
+ .select({ principalId: reg.sharesTable.principalId })
125
+ .from(reg.sharesTable)
126
+ .where(and(eq(reg.sharesTable.resourceId, request.resourceId), eq(reg.sharesTable.principalType, "user"), eq(reg.sharesTable.role, "admin")))
127
+ .orderBy(asc(reg.sharesTable.createdAt))
128
+ .limit(ADMIN_RECIPIENT_LIMIT);
129
+ const candidates = Array.from(new Set([
130
+ request.ownerEmail,
131
+ ...rows.map((row) => row.principalId.trim().toLowerCase()),
132
+ ])).filter((email) => email && email !== request.requesterEmail);
133
+ return filterRecipientsByResourceAccess({
134
+ resourceType: request.resourceType,
135
+ resourceId: request.resourceId,
136
+ emails: candidates,
137
+ orgId: resource.orgId,
138
+ minimumRole: "admin",
139
+ });
140
+ }
141
+ async function enforceRequestLimits(requesterEmail, ownerEmail, now) {
142
+ const since = now - DAY_MS;
143
+ for (const [by, limit] of [
144
+ [{ requesterEmail }, ACCESS_REQUESTS_PER_REQUESTER_PER_DAY],
145
+ [{ ownerEmail }, ACCESS_REQUESTS_PER_OWNER_PER_DAY],
146
+ ]) {
147
+ const recent = await countRecentAccessRequests(by, since);
148
+ if (recent.count <= limit)
149
+ continue;
150
+ const retryAt = iso((recent.oldestAt ?? now) + DAY_MS);
151
+ fail("Too many access requests right now. Try again later.", {
152
+ errorCode: "access_request_rate_limited",
153
+ statusCode: 429,
154
+ details: { retryAt },
155
+ });
156
+ }
157
+ }
158
+ async function deliverAccessRequest(reg, resource, request) {
159
+ const recipients = await requestRecipients(reg, resource, request);
160
+ const title = resourceTitle(reg, resource);
161
+ const requesterName = request.requesterName || request.requesterEmail;
162
+ const reviewPath = accessRequestReviewPath(request.id);
163
+ const sendsEmail = await isEmailConfigured();
164
+ const reviewUrl = resolveShareNotificationUrl(undefined, reviewPath);
165
+ const resourceUrl = resolveShareNotificationUrl(undefined, resourcePath(reg, resource));
166
+ const app = sendsEmail ? await emailBrand(reg, resource) : null;
167
+ const key = `access-request:${request.id}:${request.generation}`;
168
+ const delivery = {
169
+ recipients: recipients.length,
170
+ inbox: 0,
171
+ email: 0,
172
+ failed: 0,
173
+ };
174
+ for (const recipient of recipients) {
175
+ try {
176
+ const result = await notifyWithDelivery({
177
+ severity: "info",
178
+ title: `${requesterName} is asking for access to "${title}"`,
179
+ body: request.note ?? undefined,
180
+ // Only the inbox: the email channel would go to workspace-wide
181
+ // recipients, and the email below already reaches this person.
182
+ channels: ["inbox"],
183
+ metadata: {
184
+ link: reviewPath,
185
+ kind: "access-request",
186
+ resourceType: request.resourceType,
187
+ resourceId: request.resourceId,
188
+ requestId: request.id,
189
+ },
190
+ idempotencyKey: key,
191
+ }, { owner: recipient });
192
+ if (result.notification)
193
+ delivery.inbox++;
194
+ else
195
+ delivery.failed++;
196
+ }
197
+ catch (err) {
198
+ delivery.failed++;
199
+ console.error("[access-requests] request notification failed:", err);
200
+ }
201
+ if (!app)
202
+ continue;
203
+ try {
204
+ const { subject, html, text } = await renderTransactionalEmail(CORE_ACCESS_REQUESTED_EMAIL_ID, {
205
+ recipientEmail: recipient,
206
+ requester: { name: requesterName, email: request.requesterEmail },
207
+ resource: {
208
+ type: request.resourceType,
209
+ label: reg.displayName,
210
+ title,
211
+ url: resourceUrl,
212
+ },
213
+ reviewUrl,
214
+ note: request.note ?? undefined,
215
+ app,
216
+ });
217
+ const sent = await sendEmail({
218
+ to: recipient,
219
+ subject,
220
+ html,
221
+ text,
222
+ replyTo: request.requesterEmail,
223
+ templateId: CORE_ACCESS_REQUESTED_EMAIL_ID,
224
+ idempotencyKey: `${key}:${recipient}`,
225
+ });
226
+ if (sent.status === "sent")
227
+ delivery.email++;
228
+ }
229
+ catch (err) {
230
+ delivery.failed++;
231
+ console.error("[access-requests] request email failed:", err);
232
+ }
233
+ }
234
+ return delivery;
235
+ }
236
+ /**
237
+ * Asks the owner, and anyone with admin, to give the signed-in viewer access
238
+ * to a resource they can't open. Asking again while a request is open sends
239
+ * nothing; asking again after its send was cut off sends it again. If the
240
+ * request is over a daily limit, or nobody could be told, it is withdrawn
241
+ * and this fails, so the viewer never sees "sent" for a request nobody will
242
+ * find.
243
+ */
244
+ export async function requestResourceAccess(input) {
245
+ const reg = requireShareableResource(input.resourceType);
246
+ if (!reg.accessRequests) {
247
+ fail(`${reg.displayName} doesn't take access requests.`, {
248
+ errorCode: "access_requests_unsupported",
249
+ });
250
+ }
251
+ const requesterEmail = requireSignedIn("Sign in to request access.");
252
+ const status = await resolveAccessStatus(input.resourceType, input.resourceId);
253
+ if (status.state === "allowed" || status.state === "trashed") {
254
+ return { state: "allowed" };
255
+ }
256
+ if (status.state !== "denied") {
257
+ fail(`This ${reg.displayName.toLowerCase()} doesn't exist.`, {
258
+ errorCode: "resource_missing",
259
+ statusCode: 404,
260
+ });
261
+ }
262
+ const now = Date.now();
263
+ const existing = openRequestFor(await findAccessRequest(input.resourceType, input.resourceId, requesterEmail), now);
264
+ if (existing)
265
+ return { state: "requested", request: existing, sent: false };
266
+ const resource = await loadResource(reg, input.resourceId);
267
+ const ownerEmail = String(resource?.ownerEmail ?? "")
268
+ .trim()
269
+ .toLowerCase();
270
+ if (!resource || !ownerEmail) {
271
+ fail(`This ${reg.displayName.toLowerCase()} doesn't exist.`, {
272
+ errorCode: "resource_missing",
273
+ statusCode: 404,
274
+ });
275
+ }
276
+ const profile = await getUserProfile(requesterEmail);
277
+ const note = input.note?.trim().slice(0, ACCESS_REQUEST_NOTE_MAX_LENGTH);
278
+ const { request, opened } = await openAccessRequest({
279
+ resourceType: input.resourceType,
280
+ resourceId: input.resourceId,
281
+ requesterEmail,
282
+ requesterName: profile.name?.trim() || null,
283
+ ownerEmail,
284
+ note: note || null,
285
+ now,
286
+ declinedCooldownStart: now - ACCESS_REQUEST_DECLINE_COOLDOWN_MS,
287
+ sendWindowStart: now - ACCESS_REQUEST_SEND_WINDOW_MS,
288
+ });
289
+ const view = openRequestFor(request, now);
290
+ if (!opened || !view) {
291
+ if (view)
292
+ return { state: "requested", request: view, sent: false };
293
+ fail("This request changed while it was being sent. Try again.", {
294
+ errorCode: "access_request_conflict",
295
+ statusCode: 409,
296
+ });
297
+ }
298
+ // The limits count the request just opened, so requests made at the same
299
+ // moment can't all pass a count taken before any of them existed.
300
+ let delivery;
301
+ try {
302
+ await enforceRequestLimits(requesterEmail, ownerEmail, now);
303
+ delivery = await deliverAccessRequest(reg, resource, request);
304
+ if (delivery.inbox + delivery.email === 0) {
305
+ fail("The owner couldn't be notified. Try again in a moment.", {
306
+ errorCode: "access_request_undelivered",
307
+ statusCode: 503,
308
+ });
309
+ }
310
+ }
311
+ catch (err) {
312
+ await deleteAccessRequest(request.id, request.generation, request.requestedAt);
313
+ throw err;
314
+ }
315
+ await recordAccessRequestDelivery(request.id, request.generation, {
316
+ ...delivery,
317
+ });
318
+ return { state: "requested", request: view, sent: true };
319
+ }
320
+ // Someone who can't manage the resource learns nothing from a request id,
321
+ // not even that it exists.
322
+ function requestNotFound() {
323
+ fail("This request doesn't exist, or you can't manage access to it.", {
324
+ errorCode: "access_request_not_found",
325
+ statusCode: 404,
326
+ });
327
+ }
328
+ async function loadReviewableRequest(requestId) {
329
+ requireSignedIn("Sign in to review access requests.");
330
+ const request = await getAccessRequest(requestId);
331
+ if (!request)
332
+ requestNotFound();
333
+ const access = await resolveAccess(request.resourceType, request.resourceId);
334
+ if (access?.role !== "owner" && access?.role !== "admin")
335
+ requestNotFound();
336
+ return {
337
+ request,
338
+ reg: requireShareableResource(request.resourceType),
339
+ resource: access.resource,
340
+ };
341
+ }
342
+ function toReview(reg, resource, request) {
343
+ return {
344
+ id: request.id,
345
+ generation: request.generation,
346
+ state: request.state,
347
+ requester: { email: request.requesterEmail, name: request.requesterName },
348
+ note: request.note,
349
+ requestedAt: iso(request.requestedAt),
350
+ decidedAt: request.decidedAt === null ? null : iso(request.decidedAt),
351
+ grantedRole: request.grantedRole,
352
+ resource: {
353
+ type: request.resourceType,
354
+ id: request.resourceId,
355
+ label: reg.displayName,
356
+ title: resourceTitle(reg, resource),
357
+ path: resourcePath(reg, resource) ?? null,
358
+ },
359
+ };
360
+ }
361
+ /** A request, for someone who manages access to its resource. */
362
+ export async function getAccessRequestReview(requestId) {
363
+ const { request, reg, resource } = await loadReviewableRequest(requestId);
364
+ return toReview(reg, resource, request);
365
+ }
366
+ /** Pending requests for one resource, for someone who manages its access. */
367
+ export async function listResourceAccessRequests(resourceType, resourceId) {
368
+ const reg = requireShareableResource(resourceType);
369
+ const access = await resolveAccess(resourceType, resourceId, undefined, {
370
+ skipResourceBody: true,
371
+ });
372
+ if (access?.role !== "owner" && access?.role !== "admin") {
373
+ fail(`You can't manage access to this ${reg.displayName.toLowerCase()}.`, {
374
+ errorCode: "forbidden",
375
+ statusCode: 403,
376
+ });
377
+ }
378
+ if (!reg.accessRequests)
379
+ return { requests: [], hasMore: false };
380
+ const rows = await listPendingAccessRequests(resourceType, resourceId, PENDING_LIST_LIMIT + 1);
381
+ return {
382
+ requests: rows
383
+ .slice(0, PENDING_LIST_LIMIT)
384
+ .map((row) => toReview(reg, access.resource, row)),
385
+ hasMore: rows.length > PENDING_LIST_LIMIT,
386
+ };
387
+ }
388
+ function staleRequest() {
389
+ fail("Someone already handled this request, or it changed. Reload it.", {
390
+ errorCode: "access_request_stale",
391
+ statusCode: 409,
392
+ });
393
+ }
394
+ async function sendAccessGrantedEmail(reg, resource, request, approverEmail, role) {
395
+ try {
396
+ // `isEmailConfigured` is false for a broken setup too, which the approver
397
+ // needs to hear about rather than read as "no email here".
398
+ const readiness = await getEmailReadiness();
399
+ if (readiness.status === "not-configured")
400
+ return "skipped";
401
+ if (readiness.status !== "ready") {
402
+ throw new Error(`Email is ${readiness.status} (provider: ${readiness.provider}).`);
403
+ }
404
+ const approver = await getUserProfile(approverEmail);
405
+ const { subject, html, text } = await renderTransactionalEmail(CORE_ACCESS_GRANTED_EMAIL_ID, {
406
+ recipientEmail: request.requesterEmail,
407
+ approver: {
408
+ name: approver.name?.trim() || approverEmail,
409
+ email: approverEmail,
410
+ },
411
+ resource: {
412
+ type: request.resourceType,
413
+ label: reg.displayName,
414
+ title: resourceTitle(reg, resource),
415
+ url: resolveShareNotificationUrl(undefined, resourcePath(reg, resource), getAppProductionUrl()),
416
+ },
417
+ role,
418
+ app: await emailBrand(reg, resource),
419
+ });
420
+ const sent = await sendEmail({
421
+ to: request.requesterEmail,
422
+ subject,
423
+ html,
424
+ text,
425
+ templateId: CORE_ACCESS_GRANTED_EMAIL_ID,
426
+ idempotencyKey: `access-granted:${request.id}:${request.generation}`,
427
+ });
428
+ return sent.status === "sent" ? "sent" : "skipped";
429
+ }
430
+ catch (err) {
431
+ console.error("[access-requests] access-granted email failed:", err);
432
+ return "failed";
433
+ }
434
+ }
435
+ /**
436
+ * Gives the requester `role`, through the same grant and sharing rules as
437
+ * Share, and closes the request in the same transaction. A stronger role the
438
+ * requester already holds is kept. The access stands even when the email
439
+ * telling them fails; `email` says so.
440
+ */
441
+ export async function approveAccessRequest(input) {
442
+ const { request, reg, resource } = await loadReviewableRequest(input.requestId);
443
+ if (request.state !== "pending" || request.generation !== input.generation) {
444
+ staleRequest();
445
+ }
446
+ const approver = viewerEmail();
447
+ const exec = getDbExec();
448
+ const grant = await exec.transaction((tx) => withDbExec(tx, async () => {
449
+ const granted = await grantResourceAccess({
450
+ resourceType: request.resourceType,
451
+ resourceId: request.resourceId,
452
+ principalType: "user",
453
+ principalId: request.requesterEmail,
454
+ role: input.role,
455
+ keepStrongerRole: true,
456
+ });
457
+ const decided = await decideAccessRequest({
458
+ id: request.id,
459
+ generation: request.generation,
460
+ state: "approved",
461
+ decidedBy: approver,
462
+ grantedRole: granted.role,
463
+ now: Date.now(),
464
+ }, tx);
465
+ if (!decided)
466
+ staleRequest();
467
+ return granted;
468
+ }));
469
+ await announceResourceAccessChange(request.resourceType, request.resourceId, grant.extensionTargetsBefore);
470
+ const email = await sendAccessGrantedEmail(reg, resource, request, approver, grant.role);
471
+ return { state: "approved", role: grant.role, email };
472
+ }
473
+ /** Closes a request without granting anything or telling the requester. */
474
+ export async function declineAccessRequest(input) {
475
+ const { request } = await loadReviewableRequest(input.requestId);
476
+ if (request.state !== "pending" || request.generation !== input.generation) {
477
+ staleRequest();
478
+ }
479
+ const decided = await decideAccessRequest({
480
+ id: request.id,
481
+ generation: request.generation,
482
+ state: "declined",
483
+ decidedBy: viewerEmail(),
484
+ grantedRole: null,
485
+ now: Date.now(),
486
+ });
487
+ if (!decided)
488
+ staleRequest();
489
+ return { state: "declined" };
490
+ }
@@ -26,8 +26,9 @@ export interface ResolvedAccess {
26
26
  * Minimal resource shape returned when a caller opts into a projected access
27
27
  * load via `{ skipResourceBody: true }`. Contains exactly the columns the
28
28
  * access-decision logic itself reads — identity, ownership, org scope, and
29
- * visibility — never a resource type's heavy body columns (`data`,
30
- * `content`, and similar blobs).
29
+ * visibility, plus any columns the registration's availability rule reads —
30
+ * never a resource type's heavy body columns (`data`, `content`, and similar
31
+ * blobs).
31
32
  */
32
33
  export interface AccessProjectedResource {
33
34
  id: string;
@@ -42,12 +43,45 @@ export interface ResolvedAccessProjected {
42
43
  export interface ResolveAccessOptions {
43
44
  skipResourceBody?: boolean;
44
45
  }
46
+ /**
47
+ * Whether a loaded row passes its registration's availability rule. A row
48
+ * loaded without an availability column (an older schema) counts as
49
+ * available, the same as a registration without a rule.
50
+ */
51
+ export declare function isResourceAvailable(reg: ShareableResourceRegistration, resource: any): boolean;
45
52
  export declare function resolveAccess(resourceType: string, resourceId: string, rawCtx?: AccessContext, options?: {
46
53
  skipResourceBody?: false;
47
54
  }): Promise<ResolvedAccess | null>;
48
55
  export declare function resolveAccess(resourceType: string, resourceId: string, rawCtx: AccessContext | undefined, options: {
49
56
  skipResourceBody: true;
50
57
  }): Promise<ResolvedAccessProjected | null>;
58
+ /**
59
+ * What a link to a shareable resource can honestly say to the person who
60
+ * opened it.
61
+ *
62
+ * - `allowed`: they can open it.
63
+ * - `trashed`: they could open it, but it fails the registration's
64
+ * availability rule (for example it is in the trash).
65
+ * - `denied`: they are signed in, can't open it, and it exists.
66
+ * - `missing`: they are signed in and it doesn't exist, or it is unavailable
67
+ * and they can't open it, so trash looks the same as deleted.
68
+ * - `signed-out`: nobody is signed in, so nothing about it is revealed, not
69
+ * even whether it exists or is public.
70
+ */
71
+ export type ResourceAccessState = "allowed" | "trashed" | "denied" | "missing" | "signed-out";
72
+ export interface ResourceAccessStatus {
73
+ state: ResourceAccessState;
74
+ /** The viewer's role, only when they can open the resource. */
75
+ role?: ResolvedAccess["role"];
76
+ }
77
+ /**
78
+ * Resolves a link's {@link ResourceAccessState} for the current viewer. It
79
+ * never returns the resource's title, owner, visibility, or workspace, and
80
+ * database failures stay errors rather than reading as `missing`. A
81
+ * signed-out visitor gets `signed-out` before any row is read, so nothing
82
+ * about the link, including whether it exists, depends on the resource.
83
+ */
84
+ export declare function resolveAccessStatus(resourceType: string, resourceId: string, ctx?: AccessContext): Promise<ResourceAccessStatus>;
51
85
  export declare function assertAccess(resourceType: string, resourceId: string, minRole?: ShareRole | "owner", ctx?: AccessContext, options?: {
52
86
  skipResourceBody?: false;
53
87
  }): Promise<ResolvedAccess>;
@@ -276,24 +276,46 @@ function selectExistingColumns(columns, omittedColumnNames) {
276
276
  }
277
277
  return selection;
278
278
  }
279
- function projectedAccessColumns(resourceTable) {
280
- return {
279
+ function projectedAccessColumns(reg) {
280
+ const resourceTable = reg.resourceTable;
281
+ const columns = {
281
282
  id: resourceTable.id,
282
283
  ownerEmail: resourceTable.ownerEmail,
283
284
  orgId: resourceTable.orgId,
284
285
  visibility: resourceTable.visibility,
285
286
  };
287
+ for (const key of [
288
+ ...(reg.availability?.columns ?? []),
289
+ ...(reg.fallbackAccessContext?.columns ?? []),
290
+ ]) {
291
+ if (resourceTable[key])
292
+ columns[key] = resourceTable[key];
293
+ }
294
+ return columns;
295
+ }
296
+ /**
297
+ * Whether a loaded row passes its registration's availability rule. A row
298
+ * loaded without an availability column (an older schema) counts as
299
+ * available, the same as a registration without a rule.
300
+ */
301
+ export function isResourceAvailable(reg, resource) {
302
+ if (!reg.availability)
303
+ return true;
304
+ if (reg.availability.columns.some((column) => !(column in resource))) {
305
+ return true;
306
+ }
307
+ return reg.availability.isAvailable(resource);
286
308
  }
287
309
  function hasDynamicPublicAccessRoleResolver(reg) {
288
310
  return typeof reg.publicAccessRole === "function";
289
311
  }
290
312
  async function loadResourceForAccess(reg, resourceId, options = {}) {
291
313
  const db = reg.getDb();
314
+ // Hooks that receive the row may read any column, so they get all of it.
292
315
  const useProjection = options.skipResourceBody === true &&
293
- !hasDynamicPublicAccessRoleResolver(reg);
294
- const projectedColumns = useProjection
295
- ? projectedAccessColumns(reg.resourceTable)
296
- : null;
316
+ !hasDynamicPublicAccessRoleResolver(reg) &&
317
+ !reg.canManageAccess;
318
+ const projectedColumns = useProjection ? projectedAccessColumns(reg) : null;
297
319
  const omittedColumnNames = new Set();
298
320
  for (let attempt = 0; attempt < 12; attempt++) {
299
321
  try {
@@ -316,11 +338,13 @@ async function loadResourceForAccess(reg, resourceId, options = {}) {
316
338
  throw new Error(`Could not load ${reg.type} ${resourceId}: too many missing resource columns`);
317
339
  }
318
340
  export async function resolveAccess(resourceType, resourceId, rawCtx = currentAccess(), options = {}) {
319
- return rawCtx.transaction
320
- ? withDbExec(rawCtx.transaction, () => resolveAccessImpl(resourceType, resourceId, rawCtx, options))
321
- : resolveAccessImpl(resourceType, resourceId, rawCtx, options);
341
+ const { access } = await inAccessTransaction(rawCtx, () => loadAndResolveAccess(resourceType, resourceId, rawCtx, options));
342
+ return access;
343
+ }
344
+ function inAccessTransaction(ctx, run) {
345
+ return ctx.transaction ? withDbExec(ctx.transaction, run) : run();
322
346
  }
323
- async function resolveAccessImpl(resourceType, resourceId, rawCtx = currentAccess(), options = {}) {
347
+ async function loadAndResolveAccess(resourceType, resourceId, rawCtx, options) {
324
348
  const registered = requireShareableResource(resourceType);
325
349
  const transaction = rawCtx.transaction ?? getScopedDbExec();
326
350
  const transactionDb = transaction
@@ -336,7 +360,14 @@ async function resolveAccessImpl(resourceType, resourceId, rawCtx = currentAcces
336
360
  const ctx = resolveRegisteredAccessContext(reg, transactionCtx);
337
361
  const resource = await loadResourceForAccess(reg, resourceId, options);
338
362
  if (!resource)
339
- return null;
363
+ return { access: null, resource: null, reg };
364
+ return {
365
+ access: await accessToResource(reg, resourceId, resource, ctx),
366
+ resource,
367
+ reg,
368
+ };
369
+ }
370
+ async function accessToResource(reg, resourceId, resource, ctx) {
340
371
  const { userEmail } = ctx;
341
372
  const normalizedUserEmail = normalizeEmailForAccess(userEmail);
342
373
  if (normalizedUserEmail &&
@@ -364,6 +395,37 @@ async function resolveAccessImpl(resourceType, resourceId, rawCtx = currentAcces
364
395
  return { role, resource };
365
396
  return null;
366
397
  }
398
+ /**
399
+ * Resolves a link's {@link ResourceAccessState} for the current viewer. It
400
+ * never returns the resource's title, owner, visibility, or workspace, and
401
+ * database failures stay errors rather than reading as `missing`. A
402
+ * signed-out visitor gets `signed-out` before any row is read, so nothing
403
+ * about the link, including whether it exists, depends on the resource.
404
+ */
405
+ export async function resolveAccessStatus(resourceType, resourceId, ctx = currentAccess()) {
406
+ requireShareableResource(resourceType);
407
+ if (!normalizeEmailForAccess(ctx.userEmail))
408
+ return { state: "signed-out" };
409
+ return inAccessTransaction(ctx, async () => {
410
+ const loaded = await loadAndResolveAccess(resourceType, resourceId, ctx, {
411
+ skipResourceBody: true,
412
+ });
413
+ const { resource, reg } = loaded;
414
+ if (!resource)
415
+ return { state: "missing" };
416
+ let access = loaded.access;
417
+ if (!access && reg.fallbackAccessContext) {
418
+ const fallback = await reg.fallbackAccessContext.resolve(resource, ctx);
419
+ if (fallback) {
420
+ access = await accessToResource(reg, resourceId, resource, resolveRegisteredAccessContext(reg, fallback));
421
+ }
422
+ }
423
+ const available = isResourceAvailable(reg, resource);
424
+ if (!access)
425
+ return { state: available ? "denied" : "missing" };
426
+ return { state: available ? "allowed" : "trashed", role: access.role };
427
+ });
428
+ }
367
429
  async function highestShareRole(reg, resourceId, ctx, resource) {
368
430
  const { userEmail, orgId } = ctx;
369
431
  const normalizedUserEmail = normalizeEmailForAccess(userEmail);
@@ -410,7 +472,7 @@ async function highestShareRole(reg, resourceId, ctx, resource) {
410
472
  return best;
411
473
  }
412
474
  export async function assertAccess(resourceType, resourceId, minRole = "viewer", ctx = currentAccess(), options = {}) {
413
- const access = await resolveAccessImpl(resourceType, resourceId, ctx, options);
475
+ const { access } = await loadAndResolveAccess(resourceType, resourceId, ctx, options);
414
476
  if (!access) {
415
477
  throw new ForbiddenError(`No access to ${resourceType} ${resourceId}`);
416
478
  }