@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,10 @@
|
|
|
1
|
+
declare const _default: import("../../action.js").ActionDefinition<{
|
|
2
|
+
requestId: string;
|
|
3
|
+
generation: number;
|
|
4
|
+
role?: "admin" | "commenter" | "editor" | "viewer";
|
|
5
|
+
}, {
|
|
6
|
+
state: "approved";
|
|
7
|
+
role: import("../schema.js").ShareRole;
|
|
8
|
+
email: import("../access-requests.js").AccessGrantedEmail;
|
|
9
|
+
}>;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineAction } from "../../action.js";
|
|
3
|
+
import { approveAccessRequest } from "../access-requests.js";
|
|
4
|
+
export default defineAction({
|
|
5
|
+
description: "Allow a pending access request, giving the requester a role on the resource under the same sharing rules as Share. A stronger role they already hold is kept. `email` says whether the requester was emailed: sent, skipped, or failed.",
|
|
6
|
+
schema: z.object({
|
|
7
|
+
requestId: z.string().min(1),
|
|
8
|
+
generation: z.number().int().positive(),
|
|
9
|
+
role: z.enum(["viewer", "commenter", "editor", "admin"]).default("viewer"),
|
|
10
|
+
}),
|
|
11
|
+
agentTool: false,
|
|
12
|
+
mcpTool: false,
|
|
13
|
+
toolCallable: false,
|
|
14
|
+
run: (args) => approveAccessRequest(args),
|
|
15
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineAction } from "../../action.js";
|
|
3
|
+
import { declineAccessRequest } from "../access-requests.js";
|
|
4
|
+
export default defineAction({
|
|
5
|
+
description: "Decline a pending access request. The requester isn't told, and can ask again after a cooldown.",
|
|
6
|
+
schema: z.object({
|
|
7
|
+
requestId: z.string().min(1),
|
|
8
|
+
generation: z.number().int().positive(),
|
|
9
|
+
}),
|
|
10
|
+
agentTool: false,
|
|
11
|
+
mcpTool: false,
|
|
12
|
+
toolCallable: false,
|
|
13
|
+
run: (args) => declineAccessRequest(args),
|
|
14
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineAction } from "../../action.js";
|
|
3
|
+
import { getAccessRequestReview } from "../access-requests.js";
|
|
4
|
+
export default defineAction({
|
|
5
|
+
description: "Read one access request for review: who asked, for which resource, and whether it is still pending. Only someone who manages access to the resource can read it.",
|
|
6
|
+
schema: z.object({ requestId: z.string().min(1) }),
|
|
7
|
+
http: { method: "GET" },
|
|
8
|
+
readOnly: true,
|
|
9
|
+
agentTool: false,
|
|
10
|
+
mcpTool: false,
|
|
11
|
+
toolCallable: false,
|
|
12
|
+
run: ({ requestId }) => getAccessRequestReview(requestId),
|
|
13
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineAction } from "../../action.js";
|
|
3
|
+
import { resolveLinkStatus } from "../access-requests.js";
|
|
4
|
+
export default defineAction({
|
|
5
|
+
description: "Say whether the current viewer can open a shareable resource, and if not, whether the link points at something that exists and whether they can ask for access. Returns only the state, the viewer's own role, and their own request; never the title, owner, visibility, or workspace.",
|
|
6
|
+
schema: z.object({
|
|
7
|
+
resourceType: z.string().min(1),
|
|
8
|
+
resourceId: z.string().min(1),
|
|
9
|
+
}),
|
|
10
|
+
http: { method: "GET" },
|
|
11
|
+
readOnly: true,
|
|
12
|
+
// A signed-out visitor holding a link gets `signed-out` and nothing else.
|
|
13
|
+
requiresAuth: false,
|
|
14
|
+
// Only the app's own access screens ask; agents read resources directly.
|
|
15
|
+
agentTool: false,
|
|
16
|
+
mcpTool: false,
|
|
17
|
+
toolCallable: false,
|
|
18
|
+
run: async ({ resourceType, resourceId }) => resolveLinkStatus(resourceType, resourceId),
|
|
19
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineAction } from "../../action.js";
|
|
3
|
+
import { listResourceAccessRequests } from "../access-requests.js";
|
|
4
|
+
export default defineAction({
|
|
5
|
+
description: "List the newest pending access requests for a shareable resource, up to 50; `hasMore` says older ones exist. Only someone who manages access to it can list them.",
|
|
6
|
+
schema: z.object({
|
|
7
|
+
resourceType: z.string().min(1),
|
|
8
|
+
resourceId: z.string().min(1),
|
|
9
|
+
}),
|
|
10
|
+
http: { method: "GET" },
|
|
11
|
+
readOnly: true,
|
|
12
|
+
agentTool: false,
|
|
13
|
+
mcpTool: false,
|
|
14
|
+
toolCallable: false,
|
|
15
|
+
run: ({ resourceType, resourceId }) => listResourceAccessRequests(resourceType, resourceId),
|
|
16
|
+
});
|
|
@@ -2,6 +2,7 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
2
2
|
resourceType: string;
|
|
3
3
|
resourceId: string;
|
|
4
4
|
}, {
|
|
5
|
+
role?: undefined;
|
|
5
6
|
ownerEmail: any;
|
|
6
7
|
visibility: any;
|
|
7
8
|
shares: any[];
|
|
@@ -11,7 +12,6 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
11
12
|
supportsGroupShares?: boolean;
|
|
12
13
|
};
|
|
13
14
|
orgId?: undefined;
|
|
14
|
-
role?: undefined;
|
|
15
15
|
agentReadable?: undefined;
|
|
16
16
|
} | {
|
|
17
17
|
ownerEmail: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineAction } from "../../action.js";
|
|
3
|
+
import { ACCESS_REQUEST_NOTE_MAX_LENGTH, requestResourceAccess, } from "../access-requests.js";
|
|
4
|
+
export default defineAction({
|
|
5
|
+
description: "Ask the owner and admins of a shareable resource the signed-in viewer can't open to give them access. Asking again while a request is open sends nothing.",
|
|
6
|
+
schema: z.object({
|
|
7
|
+
resourceType: z.string().min(1),
|
|
8
|
+
resourceId: z.string().min(1),
|
|
9
|
+
note: z.string().max(ACCESS_REQUEST_NOTE_MAX_LENGTH).optional(),
|
|
10
|
+
}),
|
|
11
|
+
// It emails other people, so only the person holding the link asks, from
|
|
12
|
+
// the access screen.
|
|
13
|
+
agentTool: false,
|
|
14
|
+
mcpTool: false,
|
|
15
|
+
toolCallable: false,
|
|
16
|
+
run: (args) => requestResourceAccess(args),
|
|
17
|
+
});
|
|
@@ -1,21 +1,17 @@
|
|
|
1
|
-
import { and, eq
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { defineAction
|
|
3
|
+
import { defineAction } from "../../action.js";
|
|
4
4
|
import { getAppConfig } from "../../app-config/index.js";
|
|
5
|
-
import { getDbExec } from "../../db/client.js";
|
|
6
5
|
import { CORE_RESOURCE_SHARED_EMAIL_ID, renderTransactionalEmail, } from "../../email-catalog/templates.js";
|
|
7
|
-
import { isOrgMember } from "../../org/membership.js";
|
|
8
6
|
import { getAppProductionUrl } from "../../server/app-url.js";
|
|
9
7
|
import { sendEmail, isEmailConfigured } from "../../server/email.js";
|
|
10
|
-
import { invalidateCollabAccessCache } from "../../server/poll.js";
|
|
11
8
|
import { getRequestUserEmail } from "../../server/request-context.js";
|
|
12
9
|
import { track } from "../../tracking/registry.js";
|
|
13
10
|
import { getUserProfile } from "../../user-profile/store.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import { assertAccess } from "../access.js";
|
|
12
|
+
import { announceResourceAccessChange, grantResourceAccess, isEmailPrincipalId, isOrgMemberOrInvited, normalizePrincipalId, principalIdMatches, } from "../grant.js";
|
|
16
13
|
import { requireShareableResource } from "../registry.js";
|
|
17
14
|
import { resourceSharingChange } from "./change-result.js";
|
|
18
|
-
import { getExtensionShareChangeTargets, notifyExtensionShareChanged, } from "./extension-change.js";
|
|
19
15
|
function appPath(path) {
|
|
20
16
|
if (!path.startsWith("/"))
|
|
21
17
|
return path;
|
|
@@ -65,40 +61,6 @@ export function resolveShareNotificationUrl(explicitUrl, fallbackPath, appUrl =
|
|
|
65
61
|
}
|
|
66
62
|
return appUrl;
|
|
67
63
|
}
|
|
68
|
-
function nanoid(size = 12) {
|
|
69
|
-
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
70
|
-
let id = "";
|
|
71
|
-
const bytes = crypto.getRandomValues(new Uint8Array(size));
|
|
72
|
-
for (const byte of bytes)
|
|
73
|
-
id += chars[byte % chars.length];
|
|
74
|
-
return id;
|
|
75
|
-
}
|
|
76
|
-
function normalizePrincipalId(principalType, principalId) {
|
|
77
|
-
return principalType === "user"
|
|
78
|
-
? principalId.trim().toLowerCase()
|
|
79
|
-
: principalId;
|
|
80
|
-
}
|
|
81
|
-
function isEmailPrincipalId(value) {
|
|
82
|
-
return /^[^\s@]+@[^\s@]+$/.test(value.trim());
|
|
83
|
-
}
|
|
84
|
-
function principalIdMatches(sharesTable, principalType, principalId) {
|
|
85
|
-
return principalType === "user"
|
|
86
|
-
? sql `lower(${sharesTable.principalId}) = ${principalId}`
|
|
87
|
-
: eq(sharesTable.principalId, principalId);
|
|
88
|
-
}
|
|
89
|
-
async function isOrgMemberOrInvited(orgId, email) {
|
|
90
|
-
const lower = email.trim().toLowerCase();
|
|
91
|
-
if (!lower || !orgId)
|
|
92
|
-
return false;
|
|
93
|
-
const client = getDbExec();
|
|
94
|
-
if (await isOrgMember(orgId, lower))
|
|
95
|
-
return true;
|
|
96
|
-
const invited = await client.execute({
|
|
97
|
-
sql: `SELECT 1 FROM org_invitations WHERE org_id = ? AND LOWER(email) = ? AND status = 'pending' LIMIT 1`,
|
|
98
|
-
args: [orgId, lower],
|
|
99
|
-
});
|
|
100
|
-
return invited.rows.length > 0;
|
|
101
|
-
}
|
|
102
64
|
async function needsExternalShareApproval(args) {
|
|
103
65
|
if (args.principalType === "group")
|
|
104
66
|
return false;
|
|
@@ -163,116 +125,28 @@ export default defineAction({
|
|
|
163
125
|
needsApproval: needsExternalShareApproval,
|
|
164
126
|
run: async (args) => {
|
|
165
127
|
const reg = requireShareableResource(args.resourceType);
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
if (!actor)
|
|
169
|
-
throw new ForbiddenError("Not signed in");
|
|
170
|
-
const principalId = normalizePrincipalId(args.principalType, args.principalId);
|
|
171
|
-
if (args.principalType === "group" && reg.supportsGroupShares !== true) {
|
|
172
|
-
throw new ForbiddenError(`${reg.displayName} does not support organization groups yet.`);
|
|
173
|
-
}
|
|
174
|
-
if (args.principalType === "user" && !isEmailPrincipalId(principalId)) {
|
|
175
|
-
fail("User shares must use an email address, not an internal user id.", {
|
|
176
|
-
errorCode: "invalid_user_share_principal",
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
if (args.principalType === "group") {
|
|
180
|
-
const resourceOrgId = access.resource?.orgId;
|
|
181
|
-
if (!resourceOrgId) {
|
|
182
|
-
throw new ForbiddenError(`${reg.displayName} can only be shared with a group from within an organization.`);
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
await assertWorkspaceUserGroupIds([principalId], resourceOrgId);
|
|
186
|
-
}
|
|
187
|
-
catch {
|
|
188
|
-
throw new ForbiddenError(`${reg.displayName} can only be shared with a group from its own organization.`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const beforeExtensionTargets = await getExtensionShareChangeTargets(args.resourceType, args.resourceId);
|
|
192
|
-
if (reg.requireOrgMemberForUserShares) {
|
|
193
|
-
const resourceOrgId = access.resource?.orgId;
|
|
194
|
-
if (!resourceOrgId) {
|
|
195
|
-
throw new ForbiddenError(`${reg.displayName} can only be shared from within an organization. Create or join an organization first.`);
|
|
196
|
-
}
|
|
197
|
-
if (args.principalType === "user") {
|
|
198
|
-
const ok = await isOrgMemberOrInvited(resourceOrgId, principalId);
|
|
199
|
-
if (!ok) {
|
|
200
|
-
throw new ForbiddenError(`${principalId} is not in your organization. Invite them to the organization first, then share.`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
else if (args.principalType === "org") {
|
|
204
|
-
if (principalId !== resourceOrgId) {
|
|
205
|
-
throw new ForbiddenError(`${reg.displayName} can only be shared with its own organization, not a different one.`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
else if (args.principalType === "group") {
|
|
209
|
-
// Group ownership was validated above against the resource org.
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
const db = reg.getDb();
|
|
213
|
-
const [existing] = await db
|
|
214
|
-
.select()
|
|
215
|
-
.from(reg.sharesTable)
|
|
216
|
-
.where(and(eq(reg.sharesTable.resourceId, args.resourceId), eq(reg.sharesTable.principalType, args.principalType), principalIdMatches(reg.sharesTable, args.principalType, principalId)));
|
|
217
|
-
if (existing) {
|
|
218
|
-
const [updated] = await db
|
|
219
|
-
.update(reg.sharesTable)
|
|
220
|
-
.set({ role: args.role })
|
|
221
|
-
.where(and(eq(reg.sharesTable.id, existing.id), ne(reg.sharesTable.role, args.role)))
|
|
222
|
-
.returning({ id: reg.sharesTable.id });
|
|
223
|
-
invalidateCollabAccessCache(args.resourceType, args.resourceId);
|
|
224
|
-
await notifyExtensionShareChanged(args.resourceType, args.resourceId, beforeExtensionTargets);
|
|
225
|
-
return {
|
|
226
|
-
id: existing.id,
|
|
227
|
-
updated: Boolean(updated),
|
|
228
|
-
...(updated
|
|
229
|
-
? {
|
|
230
|
-
change: resourceSharingChange(reg, access.resource, "updated", `${args.principalType}:${principalId} · ${args.role}`).change,
|
|
231
|
-
}
|
|
232
|
-
: {}),
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
const id = nanoid();
|
|
236
|
-
const [inserted] = await db
|
|
237
|
-
.insert(reg.sharesTable)
|
|
238
|
-
.values({
|
|
239
|
-
id,
|
|
128
|
+
const grant = await grantResourceAccess({
|
|
129
|
+
resourceType: args.resourceType,
|
|
240
130
|
resourceId: args.resourceId,
|
|
241
131
|
principalType: args.principalType,
|
|
242
|
-
principalId,
|
|
132
|
+
principalId: args.principalId,
|
|
243
133
|
role: args.role,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!inserted) {
|
|
250
|
-
const [existingAfterConflict] = await db
|
|
251
|
-
.select()
|
|
252
|
-
.from(reg.sharesTable)
|
|
253
|
-
.where(and(eq(reg.sharesTable.resourceId, args.resourceId), eq(reg.sharesTable.principalType, args.principalType), principalIdMatches(reg.sharesTable, args.principalType, principalId)));
|
|
254
|
-
if (!existingAfterConflict) {
|
|
255
|
-
throw new Error("Share conflict could not be resolved.");
|
|
256
|
-
}
|
|
257
|
-
const [updated] = await db
|
|
258
|
-
.update(reg.sharesTable)
|
|
259
|
-
.set({ role: args.role })
|
|
260
|
-
.where(and(eq(reg.sharesTable.id, existingAfterConflict.id), ne(reg.sharesTable.role, args.role)))
|
|
261
|
-
.returning({ id: reg.sharesTable.id });
|
|
262
|
-
invalidateCollabAccessCache(args.resourceType, args.resourceId);
|
|
263
|
-
await notifyExtensionShareChanged(args.resourceType, args.resourceId, beforeExtensionTargets);
|
|
134
|
+
});
|
|
135
|
+
const actor = getRequestUserEmail();
|
|
136
|
+
const { id, principalId, resource: accessResource } = grant;
|
|
137
|
+
await announceResourceAccessChange(args.resourceType, args.resourceId, grant.extensionTargetsBefore);
|
|
138
|
+
if (!grant.created) {
|
|
264
139
|
return {
|
|
265
|
-
id
|
|
266
|
-
updated:
|
|
267
|
-
...(updated
|
|
140
|
+
id,
|
|
141
|
+
updated: grant.updated,
|
|
142
|
+
...(grant.updated
|
|
268
143
|
? {
|
|
269
|
-
change: resourceSharingChange(reg,
|
|
144
|
+
change: resourceSharingChange(reg, accessResource, "updated", `${args.principalType}:${principalId} · ${args.role}`).change,
|
|
270
145
|
}
|
|
271
146
|
: {}),
|
|
272
147
|
};
|
|
273
148
|
}
|
|
274
|
-
|
|
275
|
-
await notifyExtensionShareChanged(args.resourceType, args.resourceId, beforeExtensionTargets);
|
|
149
|
+
const db = reg.getDb();
|
|
276
150
|
const shouldNotify = args.notify !== false &&
|
|
277
151
|
args.principalType === "user" &&
|
|
278
152
|
(await isEmailConfigured());
|
|
@@ -408,7 +282,7 @@ export default defineAction({
|
|
|
408
282
|
return {
|
|
409
283
|
id,
|
|
410
284
|
updated: false,
|
|
411
|
-
change: resourceSharingChange(reg,
|
|
285
|
+
change: resourceSharingChange(reg, accessResource, "created", `${args.principalType}:${principalId} · ${args.role}`).change,
|
|
412
286
|
};
|
|
413
287
|
},
|
|
414
288
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type SQL } from "drizzle-orm";
|
|
2
|
+
import type { ExtensionChangeTarget } from "../extensions/change-marker.js";
|
|
3
|
+
import { type PrincipalType, type ShareRole } from "./schema.js";
|
|
4
|
+
export declare function normalizePrincipalId(principalType: PrincipalType, principalId: string): string;
|
|
5
|
+
export declare function isEmailPrincipalId(value: string): boolean;
|
|
6
|
+
export declare function principalIdMatches(sharesTable: any, principalType: PrincipalType, principalId: string): SQL;
|
|
7
|
+
export declare function isOrgMemberOrInvited(orgId: string, email: string): Promise<boolean>;
|
|
8
|
+
export interface GrantResourceAccessInput {
|
|
9
|
+
resourceType: string;
|
|
10
|
+
resourceId: string;
|
|
11
|
+
principalType: PrincipalType;
|
|
12
|
+
principalId: string;
|
|
13
|
+
role: ShareRole;
|
|
14
|
+
/**
|
|
15
|
+
* Keep a stronger role the principal already holds instead of replacing it.
|
|
16
|
+
* Share replaces roles in both directions; approving a request only raises.
|
|
17
|
+
*/
|
|
18
|
+
keepStrongerRole?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface GrantResourceAccessResult {
|
|
21
|
+
/** The principal's share row. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** A new share row was inserted. */
|
|
24
|
+
created: boolean;
|
|
25
|
+
/** An existing share row's role changed. */
|
|
26
|
+
updated: boolean;
|
|
27
|
+
/** The role the share row holds now. */
|
|
28
|
+
role: ShareRole;
|
|
29
|
+
principalId: string;
|
|
30
|
+
/** The resource as the actor's admin access check loaded it. */
|
|
31
|
+
resource: any;
|
|
32
|
+
/** Pass to {@link announceResourceAccessChange} once the grant commits. */
|
|
33
|
+
extensionTargetsBefore: ExtensionChangeTarget[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Gives a principal a role on a resource, as the signed-in actor. The actor
|
|
37
|
+
* needs admin on the resource, and every sharing rule applies: group support,
|
|
38
|
+
* organization-only sharing, and the principal's form. Share and approving an
|
|
39
|
+
* access request both grant through here, so neither can skip a rule.
|
|
40
|
+
*
|
|
41
|
+
* It only writes the share row. Call {@link announceResourceAccessChange}
|
|
42
|
+
* afterwards, outside any transaction, so open sessions see the change.
|
|
43
|
+
*/
|
|
44
|
+
export declare function grantResourceAccess(input: GrantResourceAccessInput): Promise<GrantResourceAccessResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Tells open sessions that a resource's sharing changed, after the grant that
|
|
47
|
+
* changed it has committed.
|
|
48
|
+
*/
|
|
49
|
+
export declare function announceResourceAccessChange(resourceType: string, resourceId: string, extensionTargetsBefore: ExtensionChangeTarget[]): Promise<void>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
|
2
|
+
import { drizzle as drizzleProxy } from "drizzle-orm/pg-proxy";
|
|
3
|
+
import { fail } from "../action.js";
|
|
4
|
+
import { getDbExec, getScopedDbExec } from "../db/client.js";
|
|
5
|
+
import { isOrgMember } from "../org/membership.js";
|
|
6
|
+
import { invalidateCollabAccessCache } from "../server/poll.js";
|
|
7
|
+
import { getRequestUserEmail } from "../server/request-context.js";
|
|
8
|
+
import { assertWorkspaceUserGroupIds } from "../workspace-connections/groups.js";
|
|
9
|
+
import { assertAccess, ForbiddenError } from "./access.js";
|
|
10
|
+
import { getExtensionShareChangeTargets, notifyExtensionShareChanged, } from "./actions/extension-change.js";
|
|
11
|
+
import { requireShareableResource, } from "./registry.js";
|
|
12
|
+
import { ROLE_RANK } from "./schema.js";
|
|
13
|
+
export function normalizePrincipalId(principalType, principalId) {
|
|
14
|
+
return principalType === "user"
|
|
15
|
+
? principalId.trim().toLowerCase()
|
|
16
|
+
: principalId;
|
|
17
|
+
}
|
|
18
|
+
export function isEmailPrincipalId(value) {
|
|
19
|
+
return /^[^\s@]+@[^\s@]+$/.test(value.trim());
|
|
20
|
+
}
|
|
21
|
+
export function principalIdMatches(sharesTable, principalType, principalId) {
|
|
22
|
+
return principalType === "user"
|
|
23
|
+
? sql `lower(${sharesTable.principalId}) = ${principalId}`
|
|
24
|
+
: eq(sharesTable.principalId, principalId);
|
|
25
|
+
}
|
|
26
|
+
export async function isOrgMemberOrInvited(orgId, email) {
|
|
27
|
+
const lower = email.trim().toLowerCase();
|
|
28
|
+
if (!lower || !orgId)
|
|
29
|
+
return false;
|
|
30
|
+
const client = getDbExec();
|
|
31
|
+
if (await isOrgMember(orgId, lower))
|
|
32
|
+
return true;
|
|
33
|
+
const invited = await client.execute({
|
|
34
|
+
sql: `SELECT 1 FROM org_invitations WHERE org_id = ? AND LOWER(email) = ? AND status = 'pending' LIMIT 1`,
|
|
35
|
+
args: [orgId, lower],
|
|
36
|
+
});
|
|
37
|
+
return invited.rows.length > 0;
|
|
38
|
+
}
|
|
39
|
+
function nanoid(size = 12) {
|
|
40
|
+
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
41
|
+
let id = "";
|
|
42
|
+
const bytes = crypto.getRandomValues(new Uint8Array(size));
|
|
43
|
+
for (const byte of bytes)
|
|
44
|
+
id += chars[byte % chars.length];
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
// Inside a transaction (see `withDbExec`), share rows are written through the
|
|
48
|
+
// transaction's handle so they commit or roll back with everything else.
|
|
49
|
+
function sharesDb(reg) {
|
|
50
|
+
const transaction = getScopedDbExec();
|
|
51
|
+
if (!transaction)
|
|
52
|
+
return reg.getDb();
|
|
53
|
+
return drizzleProxy(async (query, params) => {
|
|
54
|
+
const result = await transaction.execute({ sql: query, args: params });
|
|
55
|
+
return { rows: result.rows.map((row) => Object.values(row)) };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const SHARE_ROLES = ["viewer", "commenter", "editor", "admin"];
|
|
59
|
+
function weakerRoles(role) {
|
|
60
|
+
return SHARE_ROLES.filter((other) => ROLE_RANK[other] < ROLE_RANK[role]);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Gives a principal a role on a resource, as the signed-in actor. The actor
|
|
64
|
+
* needs admin on the resource, and every sharing rule applies: group support,
|
|
65
|
+
* organization-only sharing, and the principal's form. Share and approving an
|
|
66
|
+
* access request both grant through here, so neither can skip a rule.
|
|
67
|
+
*
|
|
68
|
+
* It only writes the share row. Call {@link announceResourceAccessChange}
|
|
69
|
+
* afterwards, outside any transaction, so open sessions see the change.
|
|
70
|
+
*/
|
|
71
|
+
export async function grantResourceAccess(input) {
|
|
72
|
+
const reg = requireShareableResource(input.resourceType);
|
|
73
|
+
const access = await assertAccess(input.resourceType, input.resourceId, "admin");
|
|
74
|
+
const actor = getRequestUserEmail();
|
|
75
|
+
if (!actor)
|
|
76
|
+
throw new ForbiddenError("Not signed in");
|
|
77
|
+
const principalId = normalizePrincipalId(input.principalType, input.principalId);
|
|
78
|
+
if (input.principalType === "group" && reg.supportsGroupShares !== true) {
|
|
79
|
+
throw new ForbiddenError(`${reg.displayName} does not support organization groups yet.`);
|
|
80
|
+
}
|
|
81
|
+
if (input.principalType === "user" && !isEmailPrincipalId(principalId)) {
|
|
82
|
+
fail("User shares must use an email address, not an internal user id.", {
|
|
83
|
+
errorCode: "invalid_user_share_principal",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (input.principalType === "group") {
|
|
87
|
+
const resourceOrgId = access.resource?.orgId;
|
|
88
|
+
if (!resourceOrgId) {
|
|
89
|
+
throw new ForbiddenError(`${reg.displayName} can only be shared with a group from within an organization.`);
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await assertWorkspaceUserGroupIds([principalId], resourceOrgId);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new ForbiddenError(`${reg.displayName} can only be shared with a group from its own organization.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const extensionTargetsBefore = await getExtensionShareChangeTargets(input.resourceType, input.resourceId);
|
|
99
|
+
if (reg.requireOrgMemberForUserShares) {
|
|
100
|
+
const resourceOrgId = access.resource?.orgId;
|
|
101
|
+
if (!resourceOrgId) {
|
|
102
|
+
throw new ForbiddenError(`${reg.displayName} can only be shared from within an organization. Create or join an organization first.`);
|
|
103
|
+
}
|
|
104
|
+
if (input.principalType === "user") {
|
|
105
|
+
const ok = await isOrgMemberOrInvited(resourceOrgId, principalId);
|
|
106
|
+
if (!ok) {
|
|
107
|
+
throw new ForbiddenError(`${principalId} is not in your organization. Invite them to the organization first, then share.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (input.principalType === "org") {
|
|
111
|
+
if (principalId !== resourceOrgId) {
|
|
112
|
+
throw new ForbiddenError(`${reg.displayName} can only be shared with its own organization, not a different one.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const db = sharesDb(reg);
|
|
117
|
+
const findExisting = async () => {
|
|
118
|
+
const [row] = await db
|
|
119
|
+
.select({ id: reg.sharesTable.id, role: reg.sharesTable.role })
|
|
120
|
+
.from(reg.sharesTable)
|
|
121
|
+
.where(and(eq(reg.sharesTable.resourceId, input.resourceId), eq(reg.sharesTable.principalType, input.principalType), principalIdMatches(reg.sharesTable, input.principalType, principalId)));
|
|
122
|
+
return row;
|
|
123
|
+
};
|
|
124
|
+
const setRole = async (existing) => {
|
|
125
|
+
const keep = input.keepStrongerRole === true &&
|
|
126
|
+
ROLE_RANK[existing.role] >= ROLE_RANK[input.role];
|
|
127
|
+
if (keep) {
|
|
128
|
+
return { id: existing.id, updated: false, role: existing.role };
|
|
129
|
+
}
|
|
130
|
+
// The role can change between the read above and this write, so keeping a
|
|
131
|
+
// stronger role is checked by the write itself, not only by the read.
|
|
132
|
+
const replaceable = input.keepStrongerRole
|
|
133
|
+
? inArray(reg.sharesTable.role, weakerRoles(input.role))
|
|
134
|
+
: ne(reg.sharesTable.role, input.role);
|
|
135
|
+
const [updated] = await db
|
|
136
|
+
.update(reg.sharesTable)
|
|
137
|
+
.set({ role: input.role })
|
|
138
|
+
.where(and(eq(reg.sharesTable.id, existing.id), replaceable))
|
|
139
|
+
.returning({ id: reg.sharesTable.id });
|
|
140
|
+
if (updated || !input.keepStrongerRole) {
|
|
141
|
+
return { id: existing.id, updated: Boolean(updated), role: input.role };
|
|
142
|
+
}
|
|
143
|
+
const current = await findExisting();
|
|
144
|
+
if (!current) {
|
|
145
|
+
fail("Access changed while it was being granted. Try again.", {
|
|
146
|
+
errorCode: "share_conflict",
|
|
147
|
+
statusCode: 409,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return { id: current.id, updated: false, role: current.role };
|
|
151
|
+
};
|
|
152
|
+
const result = {
|
|
153
|
+
principalId,
|
|
154
|
+
resource: access.resource,
|
|
155
|
+
extensionTargetsBefore,
|
|
156
|
+
};
|
|
157
|
+
const existing = await findExisting();
|
|
158
|
+
if (existing) {
|
|
159
|
+
return { ...result, ...(await setRole(existing)), created: false };
|
|
160
|
+
}
|
|
161
|
+
const id = nanoid();
|
|
162
|
+
const [inserted] = await db
|
|
163
|
+
.insert(reg.sharesTable)
|
|
164
|
+
.values({
|
|
165
|
+
id,
|
|
166
|
+
resourceId: input.resourceId,
|
|
167
|
+
principalType: input.principalType,
|
|
168
|
+
principalId,
|
|
169
|
+
role: input.role,
|
|
170
|
+
createdBy: actor,
|
|
171
|
+
createdAt: new Date().toISOString(),
|
|
172
|
+
})
|
|
173
|
+
.onConflictDoNothing()
|
|
174
|
+
.returning({ id: reg.sharesTable.id });
|
|
175
|
+
if (inserted) {
|
|
176
|
+
return { ...result, id, created: true, updated: false, role: input.role };
|
|
177
|
+
}
|
|
178
|
+
const existingAfterConflict = await findExisting();
|
|
179
|
+
if (!existingAfterConflict) {
|
|
180
|
+
throw new Error("Share conflict could not be resolved.");
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
...result,
|
|
184
|
+
...(await setRole(existingAfterConflict)),
|
|
185
|
+
created: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Tells open sessions that a resource's sharing changed, after the grant that
|
|
190
|
+
* changed it has committed.
|
|
191
|
+
*/
|
|
192
|
+
export async function announceResourceAccessChange(resourceType, resourceId, extensionTargetsBefore) {
|
|
193
|
+
invalidateCollabAccessCache(resourceType, resourceId);
|
|
194
|
+
await notifyExtensionShareChanged(resourceType, resourceId, extensionTargetsBefore);
|
|
195
|
+
}
|
package/dist/sharing/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ownableColumns, createSharesTable, roleSatisfies, ROLE_RANK, type Visibility, type ShareRole, type PrincipalType, } from "./schema.js";
|
|
2
2
|
export { registerShareableResource, getShareableResource, requireShareableResource, listShareableResources, type ShareableResourceRegistration, type ShareEmailExtras, } from "./registry.js";
|
|
3
|
-
export { accessFilter, resolveAccess, assertAccess, currentAccess, ForbiddenError, type AccessContext, type ResolvedAccess, } from "./access.js";
|
|
3
|
+
export { accessFilter, resolveAccess, assertAccess, currentAccess, ForbiddenError, isResourceAvailable, resolveAccessStatus, type AccessContext, type ResolvedAccess, type ResourceAccessState, type ResourceAccessStatus, } from "./access.js";
|
|
4
|
+
export { ACCESS_REQUEST_NOTE_MAX_LENGTH, accessRequestReviewPath, approveAccessRequest, declineAccessRequest, getAccessRequestReview, listResourceAccessRequests, requestResourceAccess, resolveLinkStatus, type AccessGrantedEmail, type AccessRequestReview, type RequestResourceAccessResult, type ResourceAccessRequestList, type ResourceLinkStatus, type ViewerAccessRequest, } from "./access-requests.js";
|
|
4
5
|
export { filterRecipientsByResourceAccess, type FilterRecipientsInput, } from "./recipients.js";
|
package/dist/sharing/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ownableColumns, createSharesTable, roleSatisfies, ROLE_RANK, } from "./schema.js";
|
|
2
2
|
export { registerShareableResource, getShareableResource, requireShareableResource, listShareableResources, } from "./registry.js";
|
|
3
|
-
export { accessFilter, resolveAccess, assertAccess, currentAccess, ForbiddenError, } from "./access.js";
|
|
3
|
+
export { accessFilter, resolveAccess, assertAccess, currentAccess, ForbiddenError, isResourceAvailable, resolveAccessStatus, } from "./access.js";
|
|
4
|
+
export { ACCESS_REQUEST_NOTE_MAX_LENGTH, accessRequestReviewPath, approveAccessRequest, declineAccessRequest, getAccessRequestReview, listResourceAccessRequests, requestResourceAccess, resolveLinkStatus, } from "./access-requests.js";
|
|
4
5
|
export { filterRecipientsByResourceAccess, } from "./recipients.js";
|