@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.
- package/dist/automation/index.d.ts +1 -1
- package/dist/client/analytics/index.d.ts +1 -1
- package/dist/client/analytics/index.js +1 -1
- package/dist/client/analytics.d.ts +23 -3
- package/dist/client/analytics.js +189 -46
- package/dist/client/client-status-requests.d.ts +4 -1
- package/dist/client/client-status-requests.js +21 -5
- package/dist/client/embed-auth.d.ts +6 -0
- package/dist/client/embed-auth.js +13 -0
- package/dist/client/session-replay.js +48 -22
- package/dist/client/sharing/index.d.ts +2 -0
- package/dist/client/sharing/index.js +2 -0
- package/dist/client/sharing/useAccessRequestReview.d.ts +80 -0
- package/dist/client/sharing/useAccessRequestReview.js +100 -0
- package/dist/client/sharing/useResourceAccessGate.d.ts +57 -0
- package/dist/client/sharing/useResourceAccessGate.js +76 -0
- package/dist/client/use-agent-engine-configured.d.ts +2 -0
- package/dist/client/use-agent-engine-configured.js +4 -1
- package/dist/email-catalog/registry.d.ts +1 -1
- package/dist/email-catalog/registry.js +1 -1
- package/dist/email-catalog/system-emails.d.ts +2 -2
- package/dist/email-catalog/system-emails.js +47 -2
- package/dist/email-catalog/templates.d.ts +5 -1
- package/dist/email-catalog/templates.js +11 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/framework-tools.js +6 -0
- package/dist/identity/rekey.js +9 -0
- package/dist/jobs/run-history.d.ts +27 -8
- package/dist/jobs/run-history.js +17 -10
- package/dist/jobs/scheduler.js +39 -10
- package/dist/mcp/build-server.d.ts +1 -0
- package/dist/mcp/build-server.js +30 -7
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +7 -7
- package/dist/org/index.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
- package/dist/provider-api/actions/provider-api.d.ts +11 -11
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/review/suggestions/actions.js +14 -35
- package/dist/review/suggestions/store.d.ts +0 -1
- package/dist/review/suggestions/store.js +0 -7
- package/dist/secrets/routes.d.ts +11 -11
- package/dist/server/action-discovery.js +24 -0
- package/dist/server/agent-access.js +7 -3
- package/dist/server/agent-chat/mcp-options.d.ts +1 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-default-model-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
- package/dist/server/attribution.d.ts +33 -1
- package/dist/server/attribution.js +93 -21
- package/dist/server/auth.js +35 -11
- package/dist/server/email-templates.d.ts +34 -0
- package/dist/server/email-templates.js +40 -0
- package/dist/server/magic-link-attribution.js +2 -1
- package/dist/server/onboarding-html.js +35 -67
- package/dist/server/release-schema.js +4 -0
- package/dist/server/short-lived-token.d.ts +7 -0
- package/dist/server/short-lived-token.js +59 -0
- package/dist/shared/analytics-anonymous-id.d.ts +1 -0
- package/dist/shared/analytics-anonymous-id.js +1 -1
- package/dist/shared/attribution-source.d.ts +30 -0
- package/dist/shared/attribution-source.js +65 -0
- package/dist/sharing/access-request-store.d.ts +96 -0
- package/dist/sharing/access-request-store.js +213 -0
- package/dist/sharing/access-requests.d.ts +113 -0
- package/dist/sharing/access-requests.js +490 -0
- package/dist/sharing/access.d.ts +36 -2
- package/dist/sharing/access.js +74 -12
- package/dist/sharing/actions/approve-resource-access-request.d.ts +10 -0
- package/dist/sharing/actions/approve-resource-access-request.js +15 -0
- package/dist/sharing/actions/decline-resource-access-request.d.ts +7 -0
- package/dist/sharing/actions/decline-resource-access-request.js +14 -0
- package/dist/sharing/actions/get-resource-access-request.d.ts +4 -0
- package/dist/sharing/actions/get-resource-access-request.js +13 -0
- package/dist/sharing/actions/get-resource-access-status.d.ts +5 -0
- package/dist/sharing/actions/get-resource-access-status.js +19 -0
- package/dist/sharing/actions/list-resource-access-requests.d.ts +5 -0
- package/dist/sharing/actions/list-resource-access-requests.js +16 -0
- package/dist/sharing/actions/list-resource-shares.d.ts +1 -1
- package/dist/sharing/actions/request-resource-access.d.ts +6 -0
- package/dist/sharing/actions/request-resource-access.js +17 -0
- package/dist/sharing/actions/share-resource.d.ts +1 -1
- package/dist/sharing/actions/share-resource.js +18 -144
- package/dist/sharing/grant.d.ts +49 -0
- package/dist/sharing/grant.js +195 -0
- package/dist/sharing/index.d.ts +2 -1
- package/dist/sharing/index.js +2 -1
- package/dist/sharing/registry.d.ts +36 -0
- package/dist/templates/chat/server/plugins/auth.ts +2 -2
- package/dist/templates/default/app/routes/access-requests.$requestId.tsx +7 -0
- package/dist/templates/default/server/plugins/auth.ts +2 -2
- package/dist/vite/action-types-plugin.js +24 -0
- package/package.json +11 -3
- package/src/templates/chat/server/plugins/auth.ts +2 -2
- package/src/templates/default/app/routes/access-requests.$requestId.tsx +7 -0
- 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
|
+
}
|
package/dist/sharing/access.d.ts
CHANGED
|
@@ -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
|
|
30
|
-
* `content`, and similar
|
|
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>;
|
package/dist/sharing/access.js
CHANGED
|
@@ -276,24 +276,46 @@ function selectExistingColumns(columns, omittedColumnNames) {
|
|
|
276
276
|
}
|
|
277
277
|
return selection;
|
|
278
278
|
}
|
|
279
|
-
function projectedAccessColumns(
|
|
280
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
|
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
|
}
|