@plandesk/api 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -90,6 +90,7 @@ export declare const member: import("better-auth/plugins/access").Role<import("b
90
90
  }>;
91
91
  export declare const admin: import("better-auth/plugins/access").Role<import("better-auth/plugins/access").ExactRoleStatements<{
92
92
  readonly project: ["create", "delete"];
93
+ readonly invitation: ["create", "cancel"];
93
94
  readonly task: readonly ["read", "create", "update", "delete"];
94
95
  readonly document: readonly ["read", "create", "update", "delete"];
95
96
  readonly edge: readonly ["read", "create", "update", "delete"];
@@ -98,7 +99,6 @@ export declare const admin: import("better-auth/plugins/access").Role<import("be
98
99
  readonly agent_run: readonly ["read", "create", "update", "delete"];
99
100
  readonly organization: readonly [];
100
101
  readonly member: readonly [];
101
- readonly invitation: readonly [];
102
102
  readonly team: readonly [];
103
103
  readonly ac: readonly [];
104
104
  readonly apiKey: readonly [];
@@ -35,6 +35,10 @@ export const member = ac.newRole(memberPermissions);
35
35
  export const admin = ac.newRole({
36
36
  ...memberPermissions,
37
37
  project: ['create', 'delete'],
38
+ // Admins can invite teammates (as member or admin) and cancel invites, but
39
+ // cannot manage members directly (member:update/delete) or mint owners —
40
+ // better-auth blocks a non-owner inviting the creatorRole. Owner-only stays owner-only.
41
+ invitation: ['create', 'cancel'],
38
42
  });
39
43
  export const owner = ac.newRole({
40
44
  ...memberPermissions,
package/dist/auth.js CHANGED
@@ -192,6 +192,8 @@ const PUBLIC_AUTH_PATHS = new Set([
192
192
  ]);
193
193
  /** Invitation accept: invitee may have a session but zero org memberships yet. */
194
194
  const INVITATION_ACCEPT_PATH = /^\/api\/v1\/invitations\/[^/]+\/accept$/;
195
+ /** Invitation preview (GET only): the claim page renders "invited to X" pre-auth. */
196
+ const INVITATION_PREVIEW_PATH = /^\/api\/v1\/invitations\/[^/]+$/;
195
197
  export function isInvitationAcceptPath(path) {
196
198
  return INVITATION_ACCEPT_PATH.test(path);
197
199
  }
@@ -204,7 +206,8 @@ export function isPublicAuthPath(path) {
204
206
  return true;
205
207
  }
206
208
  // BA3c: accept is session-checked in-handler, not org-gated.
207
- return isInvitationAcceptPath(path);
209
+ // Preview (GET /invitations/:id) is capability-gated by the unguessable id.
210
+ return isInvitationAcceptPath(path) || INVITATION_PREVIEW_PATH.test(path);
208
211
  }
209
212
  /**
210
213
  * Pre-join share surfaces: meta (render "X invited you"), join (claim a guest
@@ -30,6 +30,21 @@ export type OrganizationMemberSummary = {
30
30
  };
31
31
  /** List better-auth members of an organization (email/name joined from user). */
32
32
  export declare function listOrganizationMembers(auth: BetterAuthInstance, organizationId: string): Promise<OrganizationMemberSummary[]>;
33
+ export type InvitationPreview = {
34
+ organizationId: string;
35
+ organizationName: string;
36
+ role: string;
37
+ email: string;
38
+ status: string;
39
+ expiresAt: string;
40
+ };
41
+ /**
42
+ * Preview an invitation by id (capability: holding the link is authorization).
43
+ * Returns the org name + role + invited email so the claim page can orient the
44
+ * invitee before they sign in. No session required — mirrors the link-only,
45
+ * deliver-by-hand model; ids are unguessable so enumeration is infeasible.
46
+ */
47
+ export declare function getInvitationPreview(auth: BetterAuthInstance, invitationId: string): Promise<InvitationPreview | undefined>;
33
48
  /**
34
49
  * Display-friendly org for /auth/session and similar.
35
50
  * Falls back to DEFAULT_ORG_ID → "Personal" when better-auth is not configured
@@ -68,6 +68,36 @@ export async function listOrganizationMembers(auth, organizationId) {
68
68
  };
69
69
  }));
70
70
  }
71
+ /**
72
+ * Preview an invitation by id (capability: holding the link is authorization).
73
+ * Returns the org name + role + invited email so the claim page can orient the
74
+ * invitee before they sign in. No session required — mirrors the link-only,
75
+ * deliver-by-hand model; ids are unguessable so enumeration is infeasible.
76
+ */
77
+ export async function getInvitationPreview(auth, invitationId) {
78
+ const adapter = (await auth.$context).adapter;
79
+ const invitation = await adapter.findOne({
80
+ model: 'invitation',
81
+ where: [{ field: 'id', value: invitationId }],
82
+ });
83
+ if (invitation === null) {
84
+ return undefined;
85
+ }
86
+ const org = await adapter.findOne({
87
+ model: 'organization',
88
+ where: [{ field: 'id', value: invitation.organizationId }],
89
+ });
90
+ return {
91
+ organizationId: invitation.organizationId,
92
+ organizationName: org?.name ?? '',
93
+ role: invitation.role,
94
+ email: invitation.email,
95
+ status: invitation.status,
96
+ expiresAt: invitation.expiresAt instanceof Date
97
+ ? invitation.expiresAt.toISOString()
98
+ : String(invitation.expiresAt),
99
+ };
100
+ }
71
101
  /**
72
102
  * Display-friendly org for /auth/session and similar.
73
103
  * Falls back to DEFAULT_ORG_ID → "Personal" when better-auth is not configured
@@ -3,7 +3,7 @@ import { getProjectInOrg, importProject, InvalidExportVersionError, PLANDESK_EXP
3
3
  import { createScopedAgentKey } from '../agent-keys.js';
4
4
  import { getAuthContext, getOrgAuthContext } from '../auth-context.js';
5
5
  import { acceptOrganizationInvitation, createOrganizationInvitation, isAuthApiError, isInvitationRole, } from '../invitations.js';
6
- import { getOrganizationById, listOrganizationMembers } from '../organizations.js';
6
+ import { getInvitationPreview, getOrganizationById, listOrganizationMembers, } from '../organizations.js';
7
7
  import { requirePermission } from '../permissions.js';
8
8
  function isPermissionSet(value) {
9
9
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
@@ -122,8 +122,9 @@ export function createOrgsRouter(db, options = {}) {
122
122
  return c.json({ members }, 200);
123
123
  });
124
124
  /**
125
- * BA3c: invite by email (link-only, no mailer). Session owner only
126
- * (member:create). Returns claimUrl for the inviter to deliver by hand.
125
+ * BA3c: invite by email (link-only, no mailer). Owners and admins may invite
126
+ * (invitation:create); better-auth still blocks a non-owner inviting an owner.
127
+ * Returns claimUrl for the inviter to deliver by hand.
127
128
  */
128
129
  router.post('/orgs/:id/invitations', async (c) => {
129
130
  const orgId = c.req.param('id');
@@ -131,11 +132,11 @@ export function createOrgsRouter(db, options = {}) {
131
132
  return c.json({ error: 'not_found' }, 404);
132
133
  }
133
134
  const authCtx = getOrgAuthContext();
134
- // Session owner only — token/loopback cannot drive better-auth createInvitation.
135
+ // Session only — token/loopback cannot drive better-auth createInvitation.
135
136
  if (authCtx.kind !== 'session') {
136
137
  return c.json({ error: 'forbidden' }, 403);
137
138
  }
138
- requirePermission(authCtx, 'member', 'create');
139
+ requirePermission(authCtx, 'invitation', 'create');
139
140
  if (betterAuth === undefined) {
140
141
  return c.json({ error: 'unavailable' }, 503);
141
142
  }
@@ -169,6 +170,25 @@ export function createOrgsRouter(db, options = {}) {
169
170
  throw err;
170
171
  }
171
172
  });
173
+ /**
174
+ * Preview an invitation (capability: the unguessable id is the authorization).
175
+ * Public so the claim page can render "you've been invited to X as Y" before
176
+ * the invitee signs in. Returns org name + role + invited email + status.
177
+ */
178
+ router.get('/invitations/:invitationId', async (c) => {
179
+ if (betterAuth === undefined) {
180
+ return c.json({ error: 'unavailable' }, 503);
181
+ }
182
+ const invitationId = c.req.param('invitationId');
183
+ if (invitationId.trim() === '') {
184
+ return c.json({ error: 'invalid_argument' }, 400);
185
+ }
186
+ const preview = await getInvitationPreview(betterAuth, invitationId);
187
+ if (preview === undefined) {
188
+ return c.json({ error: 'not_found' }, 404);
189
+ }
190
+ return c.json(preview, 200);
191
+ });
172
192
  /**
173
193
  * BA3c: accept invitation. Session-gated at the handler (public path so
174
194
  * org-less invitees can reach it); not org-gated. Single-use → 410 on retry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plandesk/api",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -26,7 +26,7 @@
26
26
  "@libsql/kysely-libsql": "^0.4.1",
27
27
  "better-auth": "1.6.23",
28
28
  "hono": "^4.12.23",
29
- "@plandesk/db": "1.0.0-beta.3"
29
+ "@plandesk/db": "1.0.0-beta.5"
30
30
  },
31
31
  "description": "Plan Desk REST + SSE API and service layer (local-first planning workspace).",
32
32
  "license": "MIT",