@plandesk/api 1.0.0-beta.8 → 1.0.0

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 (57) hide show
  1. package/dist/agent-keys.d.ts +26 -2
  2. package/dist/agent-keys.js +22 -2
  3. package/dist/auth-context.d.ts +17 -1
  4. package/dist/auth.d.ts +10 -0
  5. package/dist/auth.js +25 -6
  6. package/dist/better-auth.js +4 -3
  7. package/dist/identity.d.ts +34 -0
  8. package/dist/identity.js +182 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/invitations.d.ts +13 -2
  12. package/dist/invitations.js +14 -2
  13. package/dist/organizations.d.ts +3 -0
  14. package/dist/organizations.js +18 -0
  15. package/dist/projection.d.ts +28 -0
  16. package/dist/projection.js +29 -1
  17. package/dist/routes/auth.js +21 -5
  18. package/dist/routes/comments.js +3 -1
  19. package/dist/routes/orgs.js +139 -24
  20. package/dist/routes/projects.js +36 -5
  21. package/dist/routes/shares.js +32 -0
  22. package/dist/routes/tasks.js +3 -0
  23. package/dist/serialize.d.ts +2 -0
  24. package/dist/serialize.js +1 -0
  25. package/dist/server.js +9 -6
  26. package/dist/services/agent-runs.js +18 -0
  27. package/dist/services/artifacts.js +18 -0
  28. package/dist/services/canvas.js +9 -0
  29. package/dist/services/comments.js +63 -13
  30. package/dist/services/documents.js +36 -0
  31. package/dist/services/files.js +19 -1
  32. package/dist/services/folders.js +27 -0
  33. package/dist/services/goals.js +45 -0
  34. package/dist/services/index.d.ts +3 -0
  35. package/dist/services/index.js +1 -1
  36. package/dist/services/notes.js +27 -0
  37. package/dist/services/projects.d.ts +27 -0
  38. package/dist/services/projects.js +113 -4
  39. package/dist/services/scope.d.ts +4 -0
  40. package/dist/services/scope.js +22 -0
  41. package/dist/services/share.d.ts +25 -3
  42. package/dist/services/share.js +160 -9
  43. package/dist/services/sync.d.ts +1 -1
  44. package/dist/services/sync.js +52 -1
  45. package/dist/services/tags.js +18 -0
  46. package/dist/services/tasks.d.ts +1 -1
  47. package/dist/services/tasks.js +22 -0
  48. package/dist/test-helpers.d.ts +1 -0
  49. package/dist/test-helpers.js +14 -0
  50. package/dist/vercel.js +9 -1
  51. package/dist/worker.js +1 -1
  52. package/package.json +3 -3
  53. package/web/assets/index-BpOwO6_E.css +2 -0
  54. package/web/assets/index-CM5NiDOW.js +358 -0
  55. package/web/index.html +2 -2
  56. package/web/assets/index-aQ0kLFAX.js +0 -358
  57. package/web/assets/index-nXT4L9C-.css +0 -2
@@ -10,8 +10,9 @@ export type ApiKeyKind = 'agent' | 'owner';
10
10
  export declare const DEFAULT_AGENT_KEY_PERMISSIONS: PermissionSet;
11
11
  /** Full owner permission set — default grant for org-wide owner keys (BA4b-1). */
12
12
  export declare const DEFAULT_OWNER_KEY_PERMISSIONS: PermissionSet;
13
- /** Always stripped from agent-key effective perms — a key must never mint keys. */
14
- export declare const AGENT_FORBIDDEN_RESOURCES: readonly ["apiKey"];
13
+ /** Always stripped from agent-key effective perms — a key must never mint keys
14
+ * or create/rename workspaces (team), regardless of custom permissions. */
15
+ export declare const AGENT_FORBIDDEN_RESOURCES: readonly ["apiKey", "team"];
15
16
  /**
16
17
  * Live-role ceiling at verify time (BA5 + BA4b-1):
17
18
  * effective = intersect(keyPermissions, liveMemberRole);
@@ -55,6 +56,29 @@ export type CreatedScopedAgentKey = {
55
56
  * Metadata has no `kind` (or may omit it) — resolver treats absent as agent.
56
57
  */
57
58
  export declare function createScopedAgentKey(input: CreateScopedAgentKeyInput): Promise<CreatedScopedAgentKey>;
59
+ export type CreateWorkspaceScopedAgentKeyInput = {
60
+ auth: BetterAuthInstance;
61
+ userId: string;
62
+ orgId: string;
63
+ teamId: string;
64
+ permissions?: PermissionSet;
65
+ name?: string;
66
+ };
67
+ export type CreatedWorkspaceScopedAgentKey = {
68
+ id: string;
69
+ key: string;
70
+ name: string | null;
71
+ permissions: Record<string, string[]> | null;
72
+ metadata: {
73
+ orgId: string;
74
+ teamId: string;
75
+ };
76
+ };
77
+ /**
78
+ * Server-side mint for a workspace-scoped agent key.
79
+ * Metadata is `{ orgId, teamId }` — no `kind` so resolver treats absent as agent.
80
+ */
81
+ export declare function createWorkspaceScopedAgentKey(input: CreateWorkspaceScopedAgentKeyInput): Promise<CreatedWorkspaceScopedAgentKey>;
58
82
  export type CreateOrgOwnerKeyInput = {
59
83
  auth: BetterAuthInstance;
60
84
  userId: string;
@@ -19,8 +19,9 @@ export const DEFAULT_AGENT_KEY_PERMISSIONS = {
19
19
  };
20
20
  /** Full owner permission set — default grant for org-wide owner keys (BA4b-1). */
21
21
  export const DEFAULT_OWNER_KEY_PERMISSIONS = orgRoleToPermissionSet('owner');
22
- /** Always stripped from agent-key effective perms — a key must never mint keys. */
23
- export const AGENT_FORBIDDEN_RESOURCES = ['apiKey'];
22
+ /** Always stripped from agent-key effective perms — a key must never mint keys
23
+ * or create/rename workspaces (team), regardless of custom permissions. */
24
+ export const AGENT_FORBIDDEN_RESOURCES = ['apiKey', 'team'];
24
25
  /**
25
26
  * Live-role ceiling at verify time (BA5 + BA4b-1):
26
27
  * effective = intersect(keyPermissions, liveMemberRole);
@@ -158,6 +159,25 @@ export async function createScopedAgentKey(input) {
158
159
  metadata,
159
160
  };
160
161
  }
162
+ /**
163
+ * Server-side mint for a workspace-scoped agent key.
164
+ * Metadata is `{ orgId, teamId }` — no `kind` so resolver treats absent as agent.
165
+ */
166
+ export async function createWorkspaceScopedAgentKey(input) {
167
+ const permissions = compactPermissions(input.permissions ?? DEFAULT_AGENT_KEY_PERMISSIONS);
168
+ const metadata = { orgId: input.orgId, teamId: input.teamId };
169
+ const minted = await mintBetterAuthApiKey({
170
+ auth: input.auth,
171
+ userId: input.userId,
172
+ name: input.name ?? 'agent',
173
+ permissions,
174
+ metadata,
175
+ });
176
+ return {
177
+ ...minted,
178
+ metadata,
179
+ };
180
+ }
161
181
  /**
162
182
  * Server-side mint for an org-wide owner key (BA4b-1).
163
183
  * No projectId in metadata → org-wide reach. kind: 'owner' retains apiKey
@@ -11,23 +11,36 @@ export type AuthContext = {
11
11
  orgId: string;
12
12
  /** Stable identity, e.g. `github:<numeric id>` — never the login. */
13
13
  userRef: string;
14
+ /** better-auth user id owning this session. */
15
+ userId: string;
14
16
  /** better-auth org role (owner/admin/member) for display. */
15
17
  role: OrgRole;
16
18
  /** Resolved permission set for resource:action checks. */
17
19
  permission: PermissionSet;
20
+ /**
21
+ * Workspace (team) ids in this org the user is a `teamMember` of. Gates a
22
+ * member to their own workspaces; owner/admin bypass by role (see scope.ts).
23
+ */
24
+ memberWorkspaceIds: string[];
18
25
  } | {
19
26
  kind: 'loopback';
20
27
  orgId: string;
21
28
  /** REQ-21: local loopback single-org is always owner — no login. */
22
29
  role: 'owner';
23
30
  permission: PermissionSet;
31
+ /** Optional local workspace scope from the x-plandesk-workspace-id header; not a security boundary. */
32
+ workspaceId?: string;
24
33
  } | {
25
34
  kind: 'apikey';
26
35
  orgId: string;
27
36
  /** better-auth user id that owns the key (referenceId). */
28
37
  userId: string;
38
+ /** Key profile from metadata: agent (scoped/ceilinged) vs owner (org-wide). */
39
+ profile: 'agent' | 'owner';
29
40
  /** Optional project scope from key metadata; cross-project → 404. */
30
41
  projectId?: string;
42
+ /** Optional workspace scope from key metadata; cross-workspace → 404. */
43
+ workspaceId?: string;
31
44
  /**
32
45
  * Live member role when present. Effective authority is always
33
46
  * `permission` (key ∩ live role; agent profile also strips apiKey —
@@ -39,7 +52,10 @@ export type AuthContext = {
39
52
  /** Portal participant after join — no org membership, one share only. */
40
53
  kind: 'guest';
41
54
  shareId: string;
42
- projectId: string;
55
+ /** Set for a project share; absent for a workspace share. */
56
+ projectId?: string;
57
+ /** Set for a workspace share; absent for a project share. */
58
+ workspaceId?: string;
43
59
  guestSessionId: string;
44
60
  };
45
61
  /** Org-bearing contexts (everything except portal guests). */
package/dist/auth.d.ts CHANGED
@@ -13,6 +13,16 @@ export type OrgAuthOptions = {
13
13
  */
14
14
  betterAuth?: BetterAuthInstance;
15
15
  };
16
+ /**
17
+ * Read better-auth key metadata. Absent/unknown `kind` → `'agent'` (BA4b-1
18
+ * back-compat: every key minted without kind keeps agent ceiling).
19
+ */
20
+ export declare function readApiKeyMetadata(metadata: unknown): {
21
+ orgId: string | undefined;
22
+ projectId: string | undefined;
23
+ workspaceId: string | undefined;
24
+ kind: 'agent' | 'owner';
25
+ };
16
26
  export declare function isInvitationAcceptPath(path: string): boolean;
17
27
  export declare function isPublicAuthPath(path: string): boolean;
18
28
  export declare function isPublicShareReadPath(path: string): boolean;
package/dist/auth.js CHANGED
@@ -2,7 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { DEFAULT_ORG_ID, hashShareToken, orgRoles, verifyGuestSession, } from '@plandesk/db';
3
3
  import { applyAgentKeyPermissionCeiling, verifyBetterAuthApiKey, } from './agent-keys.js';
4
4
  import { runWithAuthContext, tryGetAuthContext } from './auth-context.js';
5
- import { userRefFromGithubAccountId } from './identity.js';
5
+ import { userRefFromGithubAccountId, listMemberWorkspaceIds } from './identity.js';
6
6
  import { resolveDefaultOrganization } from './organizations.js';
7
7
  import { hasAnyWritePermission, orgRoleToPermissionSet, } from './permissions.js';
8
8
  import { readGuestSessionCookie } from './session.js';
@@ -115,22 +115,25 @@ async function resolveBetterAuthSessionContext(auth, headers) {
115
115
  kind: 'session',
116
116
  orgId: active.organizationId,
117
117
  userRef,
118
+ userId,
118
119
  role,
119
120
  permission: orgRoleToPermissionSet(role),
121
+ memberWorkspaceIds: await listMemberWorkspaceIds(auth, userId, active.organizationId),
120
122
  };
121
123
  }
122
124
  /**
123
125
  * Read better-auth key metadata. Absent/unknown `kind` → `'agent'` (BA4b-1
124
126
  * back-compat: every key minted without kind keeps agent ceiling).
125
127
  */
126
- function readApiKeyMetadata(metadata) {
128
+ export function readApiKeyMetadata(metadata) {
127
129
  if (metadata === null || metadata === undefined || typeof metadata !== 'object') {
128
- return { orgId: undefined, projectId: undefined, kind: 'agent' };
130
+ return { orgId: undefined, projectId: undefined, workspaceId: undefined, kind: 'agent' };
129
131
  }
130
132
  const m = metadata;
131
133
  return {
132
134
  orgId: typeof m.orgId === 'string' && m.orgId.length > 0 ? m.orgId : undefined,
133
135
  projectId: typeof m.projectId === 'string' && m.projectId.length > 0 ? m.projectId : undefined,
136
+ workspaceId: typeof m.teamId === 'string' && m.teamId.length > 0 ? m.teamId : undefined,
134
137
  kind: m.kind === 'owner' ? 'owner' : 'agent',
135
138
  };
136
139
  }
@@ -165,7 +168,7 @@ async function resolveBetterAuthApiKeyContext(auth, bearer) {
165
168
  return undefined;
166
169
  }
167
170
  const userId = verified.referenceId;
168
- const { orgId, projectId, kind } = readApiKeyMetadata(verified.metadata);
171
+ const { orgId, projectId, workspaceId, kind } = readApiKeyMetadata(verified.metadata);
169
172
  if (orgId === undefined) {
170
173
  return 'unauthorized';
171
174
  }
@@ -175,7 +178,9 @@ async function resolveBetterAuthApiKeyContext(auth, bearer) {
175
178
  kind: 'apikey',
176
179
  orgId,
177
180
  userId,
181
+ profile: kind,
178
182
  ...(projectId !== undefined ? { projectId } : {}),
183
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
179
184
  role: liveRole,
180
185
  permission,
181
186
  };
@@ -273,7 +278,8 @@ export function createOrgAuthMiddleware(options) {
273
278
  const ctx = {
274
279
  kind: 'guest',
275
280
  shareId: guest.shareId,
276
- projectId: guest.projectId,
281
+ ...(guest.projectId !== null ? { projectId: guest.projectId } : {}),
282
+ ...(guest.workspaceId !== null ? { workspaceId: guest.workspaceId } : {}),
277
283
  guestSessionId: guest.id,
278
284
  };
279
285
  await runWithAuthContext(ctx, async () => {
@@ -326,18 +332,31 @@ export function createOrgAuthMiddleware(options) {
326
332
  // organization table missing / adapter error — keep DEFAULT_ORG_ID
327
333
  }
328
334
  }
335
+ const headerWorkspaceId = c.req.header('x-plandesk-workspace-id');
329
336
  const ctx = {
330
337
  kind: 'loopback',
331
338
  orgId,
332
339
  role: 'owner',
333
340
  permission: orgRoleToPermissionSet('owner'),
341
+ ...(headerWorkspaceId !== undefined && headerWorkspaceId.trim() !== ''
342
+ ? { workspaceId: headerWorkspaceId.trim() }
343
+ : {}),
334
344
  };
335
345
  await runWithAuthContext(ctx, async () => {
336
346
  await next();
337
347
  });
338
348
  return;
339
349
  }
340
- return c.json({ error: 'unauthorized' }, 401);
350
+ // No credential resolved. API/auth/mcp namespaces require a credential →
351
+ // 401 challenge. Any other path is not an API route, so defer to routing
352
+ // (SPA handler or Hono 404): an unauthenticated probe of an unknown path
353
+ // gets a 404, not a 401 that implies a real route exists there.
354
+ if (c.req.path.startsWith('/api/v1') ||
355
+ c.req.path.startsWith('/api/auth') ||
356
+ c.req.path.startsWith('/mcp')) {
357
+ return c.json({ error: 'unauthorized' }, 401);
358
+ }
359
+ await next();
341
360
  };
342
361
  }
343
362
  /** Optional Basic auth gate (PLANDESK_AUTH_PASSWORD). Skips MCP paths. */
@@ -35,7 +35,7 @@ import { organization } from 'better-auth/plugins';
35
35
  import { apiKey } from '@better-auth/api-key';
36
36
  import { LibsqlDialect } from '@libsql/kysely-libsql';
37
37
  import { ac, admin, member, owner } from './access-control.js';
38
- import { provisionPersonalOrgIfNeeded, setDefaultActiveOrganization } from './identity.js';
38
+ import { provisionPersonalOrgIfNeeded, setDefaultActiveOrganization, setDefaultActiveTeam, } from './identity.js';
39
39
  /** Absent secret -> undefined, the supported no-auth-mounted state (REQ-5). */
40
40
  export function createBetterAuth(deps) {
41
41
  if (deps.secret === undefined || deps.secret.length === 0) {
@@ -77,14 +77,15 @@ export function createBetterAuth(deps) {
77
77
  if (authInstance === undefined)
78
78
  return;
79
79
  await provisionPersonalOrgIfNeeded(authInstance, appDb, session.userId);
80
- await setDefaultActiveOrganization(authInstance, session.userId, session.token);
80
+ const activeOrgId = await setDefaultActiveOrganization(authInstance, session.userId, session.token);
81
+ await setDefaultActiveTeam(authInstance, session.userId, session.token, activeOrgId);
81
82
  },
82
83
  },
83
84
  },
84
85
  },
85
86
  }),
86
87
  plugins: [
87
- organization({ ac, roles: { owner, admin, member } }),
88
+ organization({ ac, roles: { owner, admin, member }, teams: { enabled: true } }),
88
89
  // enableMetadata: projectId + orgId on agent keys (BA5). Rate limit off —
89
90
  // agent traffic is bursty; ceilings are permission-based, not request-count.
90
91
  apiKey({ enableMetadata: true, rateLimit: { enabled: false } }),
@@ -8,6 +8,12 @@ type OrganizationRow = {
8
8
  slug: string;
9
9
  createdAt: Date;
10
10
  };
11
+ type TeamRow = {
12
+ id: string;
13
+ name: string;
14
+ organizationId: string;
15
+ createdAt: Date;
16
+ };
11
17
  export type IdentityOrganization = Pick<OrganizationRow, 'id' | 'name' | 'slug'> & {
12
18
  role: string;
13
19
  };
@@ -18,6 +24,25 @@ export declare function resolveOrganizationsForGithubIdentity(auth: BetterAuthIn
18
24
  * Ensure the well-known local better-auth organization exists (user-less).
19
25
  * Idempotent. Used at serve boot for loopback owner-by-bind.
20
26
  */
27
+ export declare function createTeamForOrg(auth: BetterAuthInstance, organizationId: string, name: string): Promise<TeamRow>;
28
+ /** Read helpers for the dashboard's workspace surfaces (a workspace = a team). */
29
+ export type WorkspaceSummary = {
30
+ id: string;
31
+ name: string;
32
+ };
33
+ export declare function listTeamsForOrg(auth: BetterAuthInstance, organizationId: string): Promise<WorkspaceSummary[]>;
34
+ /** A team by id, only when it belongs to `organizationId` (no cross-org leak). */
35
+ export declare function getTeamInOrg(auth: BetterAuthInstance, teamId: string, organizationId: string): Promise<WorkspaceSummary | undefined>;
36
+ /** Resolve the session's active team for /auth/session, scoped to its org. */
37
+ export declare function getActiveTeamForSession(auth: BetterAuthInstance, sessionToken: string, organizationId: string): Promise<WorkspaceSummary | undefined>;
38
+ export declare function ensureDefaultTeamForOrg(auth: BetterAuthInstance, organizationId: string, _orgName?: string): Promise<string>;
39
+ export declare function backfillDefaultTeams(auth: BetterAuthInstance): Promise<{
40
+ orgsProcessed: number;
41
+ teamsCreated: number;
42
+ }>;
43
+ export declare function backfillProjectWorkspaces(db: Db, auth: BetterAuthInstance): Promise<{
44
+ projectsUpdated: number;
45
+ }>;
21
46
  export declare function ensureLocalBetterAuthOrganization(_db: Db, auth: BetterAuthInstance): Promise<OrganizationSummary>;
22
47
  export type ProvisionPersonalOrgResult = {
23
48
  created: true;
@@ -34,5 +59,14 @@ export type ProvisionPersonalOrgResult = {
34
59
  export declare function provisionPersonalOrgIfNeeded(auth: BetterAuthInstance, _db: Db, userId: string): Promise<ProvisionPersonalOrgResult>;
35
60
  /** Persist the best active organization for a newly-created session. */
36
61
  export declare function setDefaultActiveOrganization(auth: BetterAuthInstance, userId: string, sessionToken: string): Promise<string | undefined>;
62
+ /**
63
+ * The workspace (team) ids in `organizationId` that `userId` is a `teamMember`
64
+ * of, in teamMember-row order. Gates a session member to their own workspaces
65
+ * (owner/admin bypass by role); also backs the active-team self-heal. Empty
66
+ * when the user belongs to no team in the org.
67
+ */
68
+ export declare function listMemberWorkspaceIds(auth: BetterAuthInstance, userId: string, organizationId: string): Promise<string[]>;
69
+ /** Persist the best active team for a newly-created session. */
70
+ export declare function setDefaultActiveTeam(auth: BetterAuthInstance, userId: string, sessionToken: string, activeOrganizationId: string | undefined): Promise<string | undefined>;
37
71
  export {};
38
72
  //# sourceMappingURL=identity.d.ts.map
package/dist/identity.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_ORG_ID } from '@plandesk/db';
1
+ import { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID, updateProject } from '@plandesk/db';
2
2
  import { getOrganizationById } from './organizations.js';
3
3
  const GITHUB_PROVIDER_ID = 'github';
4
4
  const GITHUB_USER_REF_PREFIX = 'github:';
@@ -56,9 +56,138 @@ export async function resolveOrganizationsForGithubIdentity(auth, identity) {
56
56
  * Ensure the well-known local better-auth organization exists (user-less).
57
57
  * Idempotent. Used at serve boot for loopback owner-by-bind.
58
58
  */
59
+ export async function createTeamForOrg(auth, organizationId, name) {
60
+ const adapter = (await auth.$context).adapter;
61
+ const now = new Date();
62
+ return adapter.create({
63
+ model: 'team',
64
+ data: {
65
+ name,
66
+ organizationId,
67
+ createdAt: now,
68
+ },
69
+ });
70
+ }
71
+ export async function listTeamsForOrg(auth, organizationId) {
72
+ const adapter = (await auth.$context).adapter;
73
+ const teams = await adapter.findMany({
74
+ model: 'team',
75
+ where: [{ field: 'organizationId', value: organizationId }],
76
+ sortBy: { field: 'createdAt', direction: 'asc' },
77
+ });
78
+ return teams.map((team) => ({ id: team.id, name: team.name }));
79
+ }
80
+ /** A team by id, only when it belongs to `organizationId` (no cross-org leak). */
81
+ export async function getTeamInOrg(auth, teamId, organizationId) {
82
+ const adapter = (await auth.$context).adapter;
83
+ const team = await adapter.findOne({
84
+ model: 'team',
85
+ where: [
86
+ { field: 'id', value: teamId },
87
+ { field: 'organizationId', value: organizationId },
88
+ ],
89
+ });
90
+ return team === null ? undefined : { id: team.id, name: team.name };
91
+ }
92
+ /** Resolve the session's active team for /auth/session, scoped to its org. */
93
+ export async function getActiveTeamForSession(auth, sessionToken, organizationId) {
94
+ const context = await auth.$context;
95
+ const session = await context.adapter.findOne({
96
+ model: 'session',
97
+ where: [{ field: 'token', value: sessionToken }],
98
+ });
99
+ const activeTeamId = session?.activeTeamId === undefined || session.activeTeamId === null
100
+ ? undefined
101
+ : session.activeTeamId;
102
+ if (activeTeamId === undefined) {
103
+ return undefined;
104
+ }
105
+ return getTeamInOrg(auth, activeTeamId, organizationId);
106
+ }
107
+ export async function ensureDefaultTeamForOrg(auth, organizationId, _orgName) {
108
+ const adapter = (await auth.$context).adapter;
109
+ const existingTeams = await adapter.findMany({
110
+ model: 'team',
111
+ where: [{ field: 'organizationId', value: organizationId }],
112
+ });
113
+ let team;
114
+ if (existingTeams.length > 0) {
115
+ team = existingTeams[0];
116
+ }
117
+ else {
118
+ const now = new Date();
119
+ const isLocalOrg = organizationId === DEFAULT_ORG_ID;
120
+ team = await adapter.create({
121
+ model: 'team',
122
+ data: {
123
+ ...(isLocalOrg ? { id: DEFAULT_WORKSPACE_ID } : {}),
124
+ name: 'General',
125
+ organizationId,
126
+ createdAt: now,
127
+ },
128
+ ...(isLocalOrg ? { forceAllowId: true } : {}),
129
+ });
130
+ }
131
+ const members = await adapter.findMany({
132
+ model: 'member',
133
+ where: [{ field: 'organizationId', value: organizationId }],
134
+ });
135
+ const existingTeamMembers = await adapter.findMany({
136
+ model: 'teamMember',
137
+ where: [{ field: 'teamId', value: team.id }],
138
+ });
139
+ const userIdsInTeam = new Set(existingTeamMembers.map((tm) => tm.userId));
140
+ const now = new Date();
141
+ for (const member of members) {
142
+ if (!userIdsInTeam.has(member.userId)) {
143
+ await adapter.create({
144
+ model: 'teamMember',
145
+ data: {
146
+ teamId: team.id,
147
+ userId: member.userId,
148
+ createdAt: now,
149
+ },
150
+ });
151
+ }
152
+ }
153
+ return team.id;
154
+ }
155
+ export async function backfillDefaultTeams(auth) {
156
+ const adapter = (await auth.$context).adapter;
157
+ const orgs = await adapter.findMany({
158
+ model: 'organization',
159
+ });
160
+ let teamsCreated = 0;
161
+ for (const org of orgs) {
162
+ const existingTeams = await adapter.findMany({
163
+ model: 'team',
164
+ where: [{ field: 'organizationId', value: org.id }],
165
+ });
166
+ const hadTeam = existingTeams.length > 0;
167
+ await ensureDefaultTeamForOrg(auth, org.id, org.name);
168
+ if (!hadTeam) {
169
+ teamsCreated++;
170
+ }
171
+ }
172
+ return { orgsProcessed: orgs.length, teamsCreated };
173
+ }
174
+ export async function backfillProjectWorkspaces(db, auth) {
175
+ const result = await db.$client.execute('SELECT id, org_id, workspace_id FROM projects');
176
+ let projectsUpdated = 0;
177
+ for (const row of result.rows) {
178
+ const project = row;
179
+ const teamId = await ensureDefaultTeamForOrg(auth, project.org_id);
180
+ if (project.workspace_id !== teamId) {
181
+ await updateProject(db, project.id, { workspaceId: teamId });
182
+ projectsUpdated++;
183
+ }
184
+ }
185
+ return { projectsUpdated };
186
+ }
59
187
  export async function ensureLocalBetterAuthOrganization(_db, auth) {
60
188
  const existing = await getOrganizationById(auth, DEFAULT_ORG_ID);
61
189
  if (existing !== undefined) {
190
+ await ensureDefaultTeamForOrg(auth, DEFAULT_ORG_ID, 'Personal');
62
191
  return existing;
63
192
  }
64
193
  const now = new Date();
@@ -74,6 +203,7 @@ export async function ensureLocalBetterAuthOrganization(_db, auth) {
74
203
  data,
75
204
  forceAllowId: true,
76
205
  });
206
+ await ensureDefaultTeamForOrg(auth, DEFAULT_ORG_ID, 'Personal');
77
207
  return {
78
208
  id: created.id,
79
209
  name: created.name,
@@ -130,6 +260,7 @@ export async function provisionPersonalOrgIfNeeded(auth, _db, userId) {
130
260
  createdAt: now,
131
261
  },
132
262
  });
263
+ await ensureDefaultTeamForOrg(auth, organization.id, organization.name);
133
264
  return { created: true, orgId: organization.id, role: 'owner' };
134
265
  }
135
266
  /** Persist the best active organization for a newly-created session. */
@@ -160,4 +291,54 @@ export async function setDefaultActiveOrganization(auth, userId, sessionToken) {
160
291
  await context.internalAdapter.updateSession(sessionToken, { activeOrganizationId });
161
292
  return activeOrganizationId;
162
293
  }
294
+ /**
295
+ * The workspace (team) ids in `organizationId` that `userId` is a `teamMember`
296
+ * of, in teamMember-row order. Gates a session member to their own workspaces
297
+ * (owner/admin bypass by role); also backs the active-team self-heal. Empty
298
+ * when the user belongs to no team in the org.
299
+ */
300
+ export async function listMemberWorkspaceIds(auth, userId, organizationId) {
301
+ const context = await auth.$context;
302
+ const teamMembers = await context.adapter.findMany({
303
+ model: 'teamMember',
304
+ where: [{ field: 'userId', value: userId }],
305
+ });
306
+ if (teamMembers.length === 0) {
307
+ return [];
308
+ }
309
+ const candidateTeamIds = teamMembers.map((tm) => tm.teamId);
310
+ const teams = await context.adapter.findMany({
311
+ model: 'team',
312
+ where: [{ field: 'id', value: candidateTeamIds, operator: 'in' }],
313
+ });
314
+ const orgTeamIds = new Set(teams.filter((team) => team.organizationId === organizationId).map((team) => team.id));
315
+ return candidateTeamIds.filter((id) => orgTeamIds.has(id));
316
+ }
317
+ /** Persist the best active team for a newly-created session. */
318
+ export async function setDefaultActiveTeam(auth, userId, sessionToken, activeOrganizationId) {
319
+ if (activeOrganizationId === undefined) {
320
+ return undefined;
321
+ }
322
+ const orgTeamIds = await listMemberWorkspaceIds(auth, userId, activeOrganizationId);
323
+ if (orgTeamIds.length === 0) {
324
+ return undefined;
325
+ }
326
+ const orgTeamIdSet = new Set(orgTeamIds);
327
+ const context = await auth.$context;
328
+ const sessions = await context.adapter.findMany({
329
+ model: 'session',
330
+ where: [{ field: 'userId', value: userId }],
331
+ sortBy: { field: 'updatedAt', direction: 'desc' },
332
+ });
333
+ const priorActiveTeamId = sessions.find((session) => session.token !== sessionToken &&
334
+ session.activeTeamId !== undefined &&
335
+ session.activeTeamId !== null &&
336
+ orgTeamIdSet.has(session.activeTeamId))?.activeTeamId;
337
+ const activeTeamId = priorActiveTeamId ?? orgTeamIds[0];
338
+ if (activeTeamId === undefined) {
339
+ return undefined;
340
+ }
341
+ await context.internalAdapter.updateSession(sessionToken, { activeTeamId });
342
+ return activeTeamId;
343
+ }
163
344
  //# sourceMappingURL=identity.js.map
package/dist/index.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  export { createApp, type AppDeps } from './server.js';
2
2
  export { createBetterAuth, runBetterAuthMigrations, type BetterAuthDeps, type BetterAuthInstance, } from './better-auth.js';
3
- export { ensureLocalBetterAuthOrganization } from './identity.js';
3
+ export { backfillDefaultTeams, backfillProjectWorkspaces, createTeamForOrg, ensureDefaultTeamForOrg, ensureLocalBetterAuthOrganization, listTeamsForOrg, } from './identity.js';
4
4
  export { createAuthMiddleware, createOrgAuthMiddleware, createWriteGuardMiddleware, isLoopbackBind, isPublicAuthPath, isInvitationAcceptPath, type OrgAuthOptions, } from './auth.js';
5
5
  export { acceptOrganizationInvitation, createOrganizationInvitation, ensureShellOwner, invitationClaimUrl, isAuthApiError, isInvitationRole, mintOwnerInvitation, mintSessionCookieHeader, removeOrganizationMember, updateOrganizationMemberRole, INVITATION_ROLES, type InvitationRole, } from './invitations.js';
6
6
  export { authorizeUrl, githubConfigFromEnv, resolveGithubIdentity, userRefFromGithubId, GithubOAuthError, type FetchLike, type GithubConfig, type GithubEnv, type GithubIdentity, } from './github.js';
7
7
  export { GUEST_SESSION_COOKIE, readGuestSessionCookie } from './session.js';
8
8
  export { createAuthRouter, type AuthRouterDeps } from './routes/auth.js';
9
9
  export { runWithAuthContext, tryGetAuthContext, getAuthContext, ReadOnlyTokenError, type AuthContext, } from './auth-context.js';
10
- export { applyAgentKeyPermissionCeiling, createScopedAgentKey, createOrgOwnerKey, verifyBetterAuthApiKey, DEFAULT_AGENT_KEY_PERMISSIONS, DEFAULT_OWNER_KEY_PERMISSIONS, AGENT_FORBIDDEN_RESOURCES, type ApiKeyKind, type CreateScopedAgentKeyInput, type CreatedScopedAgentKey, type CreateOrgOwnerKeyInput, type CreatedOrgOwnerKey, type VerifiedApiKey, } from './agent-keys.js';
10
+ export { applyAgentKeyPermissionCeiling, createScopedAgentKey, createWorkspaceScopedAgentKey, createOrgOwnerKey, verifyBetterAuthApiKey, DEFAULT_AGENT_KEY_PERMISSIONS, DEFAULT_OWNER_KEY_PERMISSIONS, AGENT_FORBIDDEN_RESOURCES, type ApiKeyKind, type CreateScopedAgentKeyInput, type CreatedScopedAgentKey, type CreateWorkspaceScopedAgentKeyInput, type CreatedWorkspaceScopedAgentKey, type CreateOrgOwnerKeyInput, type CreatedOrgOwnerKey, type VerifiedApiKey, } from './agent-keys.js';
11
11
  export { requirePermission, hasPermission, hasAnyWritePermission, orgRoleToPermissionSet, PermissionDeniedError, type PermissionSet, } from './permissions.js';
12
12
  export { healthRouter } from './routes/health.js';
13
13
  export { mountStatic } from './static.js';
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  export { createApp } from './server.js';
2
2
  export { createBetterAuth, runBetterAuthMigrations, } from './better-auth.js';
3
- export { ensureLocalBetterAuthOrganization } from './identity.js';
3
+ export { backfillDefaultTeams, backfillProjectWorkspaces, createTeamForOrg, ensureDefaultTeamForOrg, ensureLocalBetterAuthOrganization, listTeamsForOrg, } from './identity.js';
4
4
  export { createAuthMiddleware, createOrgAuthMiddleware, createWriteGuardMiddleware, isLoopbackBind, isPublicAuthPath, isInvitationAcceptPath, } from './auth.js';
5
5
  export { acceptOrganizationInvitation, createOrganizationInvitation, ensureShellOwner, invitationClaimUrl, isAuthApiError, isInvitationRole, mintOwnerInvitation, mintSessionCookieHeader, removeOrganizationMember, updateOrganizationMemberRole, INVITATION_ROLES, } from './invitations.js';
6
6
  export { authorizeUrl, githubConfigFromEnv, resolveGithubIdentity, userRefFromGithubId, GithubOAuthError, } from './github.js';
7
7
  export { GUEST_SESSION_COOKIE, readGuestSessionCookie } from './session.js';
8
8
  export { createAuthRouter } from './routes/auth.js';
9
9
  export { runWithAuthContext, tryGetAuthContext, getAuthContext, ReadOnlyTokenError, } from './auth-context.js';
10
- export { applyAgentKeyPermissionCeiling, createScopedAgentKey, createOrgOwnerKey, verifyBetterAuthApiKey, DEFAULT_AGENT_KEY_PERMISSIONS, DEFAULT_OWNER_KEY_PERMISSIONS, AGENT_FORBIDDEN_RESOURCES, } from './agent-keys.js';
10
+ export { applyAgentKeyPermissionCeiling, createScopedAgentKey, createWorkspaceScopedAgentKey, createOrgOwnerKey, verifyBetterAuthApiKey, DEFAULT_AGENT_KEY_PERMISSIONS, DEFAULT_OWNER_KEY_PERMISSIONS, AGENT_FORBIDDEN_RESOURCES, } from './agent-keys.js';
11
11
  export { requirePermission, hasPermission, hasAnyWritePermission, orgRoleToPermissionSet, PermissionDeniedError, } from './permissions.js';
12
12
  export { healthRouter } from './routes/health.js';
13
13
  export { mountStatic } from './static.js';
@@ -8,6 +8,8 @@ type InvitationRow = {
8
8
  email: string;
9
9
  role: string;
10
10
  organizationId: string;
11
+ /** better-auth stores team ids comma-joined; null when no team. */
12
+ teamId: string | null;
11
13
  inviterId: string;
12
14
  status: string;
13
15
  expiresAt: Date;
@@ -30,18 +32,24 @@ export type AuthApiErrorShape = {
30
32
  };
31
33
  export declare function isAuthApiError(err: unknown): err is AuthApiErrorShape;
32
34
  /**
33
- * Create an invitation using the caller's better-auth session (Cookie headers).
34
- * No mailer: sendInvitationEmail is unset; caller delivers claimUrl by hand.
35
+ * Create a workspace-scoped invitation using the caller's better-auth session
36
+ * (Cookie headers). The invitee joins the org AND the team on accept
37
+ * (better-auth does both when teamId is set). No mailer: caller delivers
38
+ * claimUrl by hand.
35
39
  */
36
40
  export declare function createOrganizationInvitation(auth: BetterAuthInstance, opts: {
37
41
  email: string;
38
42
  role: InvitationRole;
39
43
  organizationId: string;
44
+ /** Workspace (better-auth team) the invitee will join. Required. */
45
+ teamId: string;
40
46
  headers: Headers;
41
47
  baseURL: string;
42
48
  }): Promise<{
43
49
  invitationId: string;
44
50
  claimUrl: string;
51
+ organizationId: string;
52
+ teamId: string;
45
53
  invitation: InvitationRow;
46
54
  }>;
47
55
  /**
@@ -81,6 +89,9 @@ export declare function ensureShellOwner(auth: BetterAuthInstance, organizationI
81
89
  }>;
82
90
  /**
83
91
  * Shell-side owner invitation for `plandesk admin invite-owner` (no GitHub, no mailer).
92
+ * The owner role is org-level; teamId targets the org's default workspace so
93
+ * the bootstrap satisfies the workspace-scoped invite contract (the owner
94
+ * reaches every workspace via the owner role regardless).
84
95
  */
85
96
  export declare function mintOwnerInvitation(auth: BetterAuthInstance, opts: {
86
97
  email: string;