@plandesk/api 0.19.0 → 1.0.0-beta.1

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 (101) hide show
  1. package/dist/access-control.d.ts +150 -0
  2. package/dist/access-control.js +63 -0
  3. package/dist/agent-keys.d.ts +81 -0
  4. package/dist/agent-keys.js +181 -0
  5. package/dist/auth-context.d.ts +60 -0
  6. package/dist/auth-context.js +41 -0
  7. package/dist/auth.d.ts +33 -0
  8. package/dist/auth.js +357 -0
  9. package/dist/better-auth.d.ts +66 -0
  10. package/dist/better-auth.js +106 -0
  11. package/dist/github.d.ts +57 -0
  12. package/dist/github.js +115 -0
  13. package/dist/hosted-auth.d.ts +23 -0
  14. package/dist/hosted-auth.js +49 -0
  15. package/dist/identity.d.ts +38 -0
  16. package/dist/identity.js +163 -0
  17. package/dist/index.d.ts +13 -2
  18. package/dist/index.js +12 -2
  19. package/dist/invitations.d.ts +94 -0
  20. package/dist/invitations.js +211 -0
  21. package/dist/organizations.d.ts +32 -0
  22. package/dist/organizations.js +63 -0
  23. package/dist/permissions.d.ts +19 -0
  24. package/dist/permissions.js +42 -0
  25. package/dist/projection.d.ts +1 -1
  26. package/dist/projection.js +5 -5
  27. package/dist/routes/agent-runs.js +3 -3
  28. package/dist/routes/artifacts.js +6 -6
  29. package/dist/routes/auth.d.ts +13 -0
  30. package/dist/routes/auth.js +83 -0
  31. package/dist/routes/canvas.js +5 -5
  32. package/dist/routes/comments.js +11 -11
  33. package/dist/routes/documents.js +10 -10
  34. package/dist/routes/folders.js +8 -8
  35. package/dist/routes/goals.js +11 -11
  36. package/dist/routes/notes.js +8 -8
  37. package/dist/routes/orgs.d.ts +17 -0
  38. package/dist/routes/orgs.js +241 -0
  39. package/dist/routes/projects.js +13 -13
  40. package/dist/routes/shares.js +124 -2
  41. package/dist/routes/submissions.js +7 -8
  42. package/dist/routes/tags.js +6 -6
  43. package/dist/routes/tasks.js +14 -3
  44. package/dist/routes/tokens.js +4 -4
  45. package/dist/serialize.d.ts +3 -3
  46. package/dist/server.d.ts +20 -2
  47. package/dist/server.js +57 -9
  48. package/dist/services/agent-runs.d.ts +13 -14
  49. package/dist/services/agent-runs.js +39 -41
  50. package/dist/services/artifacts.d.ts +6 -7
  51. package/dist/services/artifacts.js +30 -26
  52. package/dist/services/canvas.d.ts +11 -12
  53. package/dist/services/canvas.js +58 -41
  54. package/dist/services/comments.d.ts +11 -12
  55. package/dist/services/comments.js +44 -64
  56. package/dist/services/documents.d.ts +10 -11
  57. package/dist/services/documents.js +74 -55
  58. package/dist/services/files.d.ts +2 -1
  59. package/dist/services/files.js +13 -5
  60. package/dist/services/folders.d.ts +7 -8
  61. package/dist/services/folders.js +45 -33
  62. package/dist/services/goals.d.ts +24 -25
  63. package/dist/services/goals.js +60 -53
  64. package/dist/services/index.d.ts +2 -5
  65. package/dist/services/index.js +15 -20
  66. package/dist/services/notes.d.ts +7 -8
  67. package/dist/services/notes.js +35 -23
  68. package/dist/services/org-scope.d.ts +22 -0
  69. package/dist/services/org-scope.js +38 -0
  70. package/dist/services/projects.d.ts +12 -13
  71. package/dist/services/projects.js +90 -58
  72. package/dist/services/scope.d.ts +12 -0
  73. package/dist/services/scope.js +29 -0
  74. package/dist/services/share.d.ts +74 -10
  75. package/dist/services/share.js +213 -23
  76. package/dist/services/sync.d.ts +7 -29
  77. package/dist/services/sync.js +37 -142
  78. package/dist/services/tags.d.ts +6 -7
  79. package/dist/services/tags.js +34 -22
  80. package/dist/services/tasks.d.ts +24 -17
  81. package/dist/services/tasks.js +97 -58
  82. package/dist/services/tokens.d.ts +6 -5
  83. package/dist/services/tokens.js +13 -6
  84. package/dist/session.d.ts +5 -0
  85. package/dist/session.js +8 -0
  86. package/dist/storage/local.js +16 -10
  87. package/dist/storage/s3.js +11 -6
  88. package/dist/test-ba-org.d.ts +16 -0
  89. package/dist/test-ba-org.js +35 -0
  90. package/dist/test-helpers.d.ts +21 -5
  91. package/dist/test-helpers.js +45 -8
  92. package/dist/vercel.d.ts +3 -0
  93. package/dist/vercel.js +62 -0
  94. package/dist/worker.d.ts +44 -0
  95. package/dist/worker.js +101 -0
  96. package/package.json +8 -5
  97. package/web/assets/index-D29SyYFS.css +2 -0
  98. package/web/assets/index-tBJ_K3ur.js +358 -0
  99. package/web/index.html +2 -2
  100. package/web/assets/index-CZs-KaPG.css +0 -2
  101. package/web/assets/index-zUcwwGtr.js +0 -358
package/dist/auth.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
+ import { DEFAULT_ORG_ID, hashShareToken, orgRoles, verifyGuestSession, } from '@plandesk/db';
3
+ import { applyAgentKeyPermissionCeiling, verifyBetterAuthApiKey, } from './agent-keys.js';
4
+ import { runWithAuthContext, tryGetAuthContext } from './auth-context.js';
5
+ import { userRefFromGithubAccountId } from './identity.js';
6
+ import { resolveDefaultOrganization } from './organizations.js';
7
+ import { hasAnyWritePermission, orgRoleToPermissionSet, } from './permissions.js';
8
+ import { readGuestSessionCookie } from './session.js';
2
9
  const BASIC_PREFIX = 'Basic ';
3
10
  const BASIC_USER = 'plandesk';
11
+ const BEARER_PREFIX = 'Bearer ';
12
+ const GITHUB_PROVIDER_ID = 'github';
13
+ function parseOrgRole(role) {
14
+ for (const candidate of orgRoles) {
15
+ if (candidate === role) {
16
+ return candidate;
17
+ }
18
+ }
19
+ return undefined;
20
+ }
4
21
  function decodeBasicAuth(header) {
5
22
  if (!header.startsWith(BASIC_PREFIX)) {
6
23
  return undefined;
@@ -18,6 +35,308 @@ function credentialsMatch(provided, expected) {
18
35
  }
19
36
  return timingSafeEqual(provided, expected);
20
37
  }
38
+ function extractBearerToken(header) {
39
+ if (header === undefined || !header.toLowerCase().startsWith(BEARER_PREFIX.toLowerCase())) {
40
+ return undefined;
41
+ }
42
+ const raw = header.slice(BEARER_PREFIX.length).trim();
43
+ return raw.length > 0 ? raw : undefined;
44
+ }
45
+ /** True when the server is bound only to loopback (trusted local network boundary). */
46
+ export function isLoopbackBind(host) {
47
+ const h = host.trim().toLowerCase();
48
+ return h === '127.0.0.1' || h === '::1' || h === 'localhost';
49
+ }
50
+ /**
51
+ * Resolve a better-auth session into AuthContext.
52
+ * - null session → undefined (caller falls through to loopback)
53
+ * - session but no org membership → 'unauthorized' (authenticated-but-org-less)
54
+ * - session + membership → AuthContext kind session
55
+ */
56
+ async function resolveBetterAuthSessionContext(auth, headers) {
57
+ const session = await auth.api.getSession({ headers });
58
+ if (session === null) {
59
+ return undefined;
60
+ }
61
+ const adapter = (await auth.$context).adapter;
62
+ const userId = session.user.id;
63
+ const members = await adapter.findMany({
64
+ model: 'member',
65
+ where: [{ field: 'userId', value: userId }],
66
+ sortBy: { field: 'createdAt', direction: 'asc' },
67
+ });
68
+ // Membership revoked or never granted: cookie carries no org authority.
69
+ if (members.length === 0) {
70
+ return 'unauthorized';
71
+ }
72
+ const sessionRow = await adapter.findOne({
73
+ model: 'session',
74
+ where: [{ field: 'token', value: session.session.token }],
75
+ });
76
+ const activeOrganizationId = sessionRow?.activeOrganizationId;
77
+ const active = (activeOrganizationId === undefined || activeOrganizationId === null
78
+ ? undefined
79
+ : members.find((member) => member.organizationId === activeOrganizationId)) ?? members[0];
80
+ if (active === undefined) {
81
+ return 'unauthorized';
82
+ }
83
+ if (active.organizationId !== activeOrganizationId) {
84
+ await (await auth.$context).internalAdapter.updateSession(session.session.token, {
85
+ activeOrganizationId: active.organizationId,
86
+ });
87
+ }
88
+ const role = parseOrgRole(active.role);
89
+ if (role === undefined) {
90
+ return 'unauthorized';
91
+ }
92
+ // GitHub-linked users keep github:<id> as the stable ref (BA4a).
93
+ // Password-only members (invite bootstrap, no GitHub app — REQ-20 / BA3c)
94
+ // use user:<better-auth user id> so they can hold a session AuthContext.
95
+ const account = await adapter.findOne({
96
+ model: 'account',
97
+ where: [
98
+ { field: 'userId', value: userId },
99
+ { field: 'providerId', value: GITHUB_PROVIDER_ID },
100
+ ],
101
+ });
102
+ let userRef;
103
+ if (account !== null) {
104
+ try {
105
+ userRef = userRefFromGithubAccountId(account.accountId);
106
+ }
107
+ catch {
108
+ return 'unauthorized';
109
+ }
110
+ }
111
+ else {
112
+ userRef = `user:${userId}`;
113
+ }
114
+ return {
115
+ kind: 'session',
116
+ orgId: active.organizationId,
117
+ userRef,
118
+ role,
119
+ permission: orgRoleToPermissionSet(role),
120
+ };
121
+ }
122
+ /**
123
+ * Read better-auth key metadata. Absent/unknown `kind` → `'agent'` (BA4b-1
124
+ * back-compat: every key minted without kind keeps agent ceiling).
125
+ */
126
+ function readApiKeyMetadata(metadata) {
127
+ if (metadata === null || metadata === undefined || typeof metadata !== 'object') {
128
+ return { orgId: undefined, projectId: undefined, kind: 'agent' };
129
+ }
130
+ const m = metadata;
131
+ return {
132
+ orgId: typeof m.orgId === 'string' && m.orgId.length > 0 ? m.orgId : undefined,
133
+ projectId: typeof m.projectId === 'string' && m.projectId.length > 0 ? m.projectId : undefined,
134
+ kind: m.kind === 'owner' ? 'owner' : 'agent',
135
+ };
136
+ }
137
+ /**
138
+ * Resolve live better-auth member role for (userId, orgId).
139
+ * No member row → undefined (ceiling becomes empty permissions).
140
+ */
141
+ async function resolveBetterAuthLiveRole(auth, userId, orgId) {
142
+ const adapter = (await auth.$context).adapter;
143
+ const members = await adapter.findMany({
144
+ model: 'member',
145
+ where: [
146
+ { field: 'userId', value: userId },
147
+ { field: 'organizationId', value: orgId },
148
+ ],
149
+ });
150
+ const active = members[0];
151
+ if (active === undefined) {
152
+ return undefined;
153
+ }
154
+ return parseOrgRole(active.role);
155
+ }
156
+ /**
157
+ * Try better-auth API key first (BA5). Returns:
158
+ * - AuthContext when the bearer is a valid better-auth key with org metadata
159
+ * - 'unauthorized' when the key is valid but unusable (no org)
160
+ * - undefined when not a better-auth key (caller rejects as 401)
161
+ */
162
+ async function resolveBetterAuthApiKeyContext(auth, bearer) {
163
+ const verified = await verifyBetterAuthApiKey(auth, bearer);
164
+ if (verified === undefined) {
165
+ return undefined;
166
+ }
167
+ const userId = verified.referenceId;
168
+ const { orgId, projectId, kind } = readApiKeyMetadata(verified.metadata);
169
+ if (orgId === undefined) {
170
+ return 'unauthorized';
171
+ }
172
+ const liveRole = await resolveBetterAuthLiveRole(auth, userId, orgId);
173
+ const permission = applyAgentKeyPermissionCeiling(verified.permissions, liveRole, kind);
174
+ return {
175
+ kind: 'apikey',
176
+ orgId,
177
+ userId,
178
+ ...(projectId !== undefined ? { projectId } : {}),
179
+ role: liveRole,
180
+ permission,
181
+ };
182
+ }
183
+ /**
184
+ * Endpoints that must answer before the caller holds a credential: the method
185
+ * probe the sign-in UI reads, and better-auth's own sign-in surface at /api/auth/*
186
+ * (chicken-and-egg: cannot require a token to obtain one).
187
+ */
188
+ const PUBLIC_AUTH_PATHS = new Set([
189
+ '/api/v1/auth/methods',
190
+ '/api/auth/*',
191
+ ]);
192
+ /** Invitation accept: invitee may have a session but zero org memberships yet. */
193
+ const INVITATION_ACCEPT_PATH = /^\/api\/v1\/invitations\/[^/]+\/accept$/;
194
+ export function isInvitationAcceptPath(path) {
195
+ return INVITATION_ACCEPT_PATH.test(path);
196
+ }
197
+ export function isPublicAuthPath(path) {
198
+ if (PUBLIC_AUTH_PATHS.has(path)) {
199
+ return true;
200
+ }
201
+ // better-auth mounts under /api/auth/*; the set holds the prefix pattern.
202
+ if (path === '/api/auth' || path.startsWith('/api/auth/')) {
203
+ return true;
204
+ }
205
+ // BA3c: accept is session-checked in-handler, not org-gated.
206
+ return isInvitationAcceptPath(path);
207
+ }
208
+ /**
209
+ * Pre-join share surfaces: meta (render "X invited you"), join (claim a guest
210
+ * session), and agent markdown links. The portal *view* is NOT public — it
211
+ * requires a guest session issued by join. Share *creation* lives under
212
+ * /tasks/:id/share and /documents/:id/share (org-gated).
213
+ */
214
+ const PUBLIC_SHARE_PATH = /^\/api\/v1\/share\/[^/]+(\.md|\/meta|\/join)$/;
215
+ export function isPublicShareReadPath(path) {
216
+ return PUBLIC_SHARE_PATH.test(path);
217
+ }
218
+ /**
219
+ * Guest-gated portal surfaces: view + submissions (list/submit).
220
+ * Never fall through to loopback/org auth — that would re-open the pre-join bypass.
221
+ */
222
+ const SHARE_GUEST_PATH = /^\/api\/v1\/share\/([^/]+)\/(view|submissions)$/;
223
+ export function isShareGuestViewPath(path) {
224
+ return SHARE_GUEST_PATH.test(path);
225
+ }
226
+ export function extractShareTokenFromViewPath(path) {
227
+ const match = SHARE_GUEST_PATH.exec(path);
228
+ return match?.[1];
229
+ }
230
+ function extractGuestCredential(authorizationHeader, cookieToken) {
231
+ return extractBearerToken(authorizationHeader) ?? cookieToken;
232
+ }
233
+ /**
234
+ * Always-on org resolver for every request:
235
+ * 1. Bearer → better-auth API key (BA5 live-role ceiling) when configured
236
+ * 2. else better-auth session cookie → active org + member role (when configured)
237
+ * 3. else loopback bind + single-org → default org as owner (local, zero friction)
238
+ * 4. else 401
239
+ *
240
+ * A stranger bearer that is not a better-auth key is always 401 (no mcp_token fallback).
241
+ * Every branch yields the same `{ orgId, permission }`, so services downstream
242
+ * are identical for a browser, the CLI, and an agent.
243
+ */
244
+ export function createOrgAuthMiddleware(options) {
245
+ const { db, bindHost, betterAuth } = options;
246
+ return async (c, next) => {
247
+ if (isPublicAuthPath(c.req.path) || isPublicShareReadPath(c.req.path)) {
248
+ await next();
249
+ return;
250
+ }
251
+ // Portal view: guest session only — never loopback/org. No credential → 401
252
+ // closes the pre-join bypass; wrong-share session → 404 (no existence leak).
253
+ if (isShareGuestViewPath(c.req.path)) {
254
+ const shareToken = extractShareTokenFromViewPath(c.req.path);
255
+ if (shareToken === undefined) {
256
+ return c.json({ error: 'not_found' }, 404);
257
+ }
258
+ const guestRaw = extractGuestCredential(c.req.header('Authorization'), readGuestSessionCookie(c));
259
+ if (guestRaw === undefined) {
260
+ return c.json({ error: 'unauthorized' }, 401);
261
+ }
262
+ const guest = await verifyGuestSession(db, guestRaw);
263
+ if (guest === undefined) {
264
+ return c.json({ error: 'unauthorized' }, 401);
265
+ }
266
+ if (guest.share.tokenHash !== hashShareToken(shareToken)) {
267
+ return c.json({ error: 'not_found' }, 404);
268
+ }
269
+ const ctx = {
270
+ kind: 'guest',
271
+ shareId: guest.shareId,
272
+ projectId: guest.projectId,
273
+ guestSessionId: guest.id,
274
+ };
275
+ await runWithAuthContext(ctx, async () => {
276
+ await next();
277
+ });
278
+ return;
279
+ }
280
+ const bearer = extractBearerToken(c.req.header('Authorization'));
281
+ if (bearer !== undefined) {
282
+ if (betterAuth !== undefined) {
283
+ const apiKeyCtx = await resolveBetterAuthApiKeyContext(betterAuth, bearer);
284
+ if (apiKeyCtx === 'unauthorized') {
285
+ return c.json({ error: 'unauthorized' }, 401);
286
+ }
287
+ if (apiKeyCtx !== undefined) {
288
+ await runWithAuthContext(apiKeyCtx, async () => {
289
+ await next();
290
+ });
291
+ return;
292
+ }
293
+ }
294
+ // Not a better-auth key (or better-auth not configured): no mcp_token fallback.
295
+ return c.json({ error: 'unauthorized' }, 401);
296
+ }
297
+ if (betterAuth !== undefined) {
298
+ const betterAuthCtx = await resolveBetterAuthSessionContext(betterAuth, c.req.raw.headers);
299
+ if (betterAuthCtx === 'unauthorized') {
300
+ return c.json({ error: 'unauthorized' }, 401);
301
+ }
302
+ if (betterAuthCtx !== undefined) {
303
+ await runWithAuthContext(betterAuthCtx, async () => {
304
+ await next();
305
+ });
306
+ return;
307
+ }
308
+ }
309
+ if (isLoopbackBind(bindHost)) {
310
+ // REQ-21: local loopback is always owner — no login, no member row required.
311
+ // Prefer the ensured better-auth default org when present; else DEFAULT_ORG_ID.
312
+ // Soft-fail if BA tables are not migrated yet (createApp boots before migrator).
313
+ let orgId = DEFAULT_ORG_ID;
314
+ if (betterAuth !== undefined) {
315
+ try {
316
+ const org = await resolveDefaultOrganization(betterAuth);
317
+ if (org !== undefined) {
318
+ orgId = org.id;
319
+ }
320
+ }
321
+ catch {
322
+ // organization table missing / adapter error — keep DEFAULT_ORG_ID
323
+ }
324
+ }
325
+ const ctx = {
326
+ kind: 'loopback',
327
+ orgId,
328
+ role: 'owner',
329
+ permission: orgRoleToPermissionSet('owner'),
330
+ };
331
+ await runWithAuthContext(ctx, async () => {
332
+ await next();
333
+ });
334
+ return;
335
+ }
336
+ return c.json({ error: 'unauthorized' }, 401);
337
+ };
338
+ }
339
+ /** Optional Basic auth gate (PLANDESK_AUTH_PASSWORD). Skips MCP paths. */
21
340
  export function createAuthMiddleware(password) {
22
341
  const expected = Buffer.from(`${BASIC_USER}:${password}`, 'utf8');
23
342
  return async (c, next) => {
@@ -25,6 +344,16 @@ export function createAuthMiddleware(password) {
25
344
  await next();
26
345
  return;
27
346
  }
347
+ // Method probe and better-auth sign-in must stay reachable without Basic.
348
+ if (isPublicAuthPath(c.req.path) || isPublicShareReadPath(c.req.path)) {
349
+ await next();
350
+ return;
351
+ }
352
+ // Bearer tokens are handled by org auth; do not require Basic on top of them.
353
+ if (extractBearerToken(c.req.header('Authorization')) !== undefined) {
354
+ await next();
355
+ return;
356
+ }
28
357
  const decoded = decodeBasicAuth(c.req.header('Authorization') ?? '');
29
358
  if (decoded === undefined || !credentialsMatch(decoded, expected)) {
30
359
  return c.json({ error: 'unauthorized' }, 401, {
@@ -34,4 +363,32 @@ export function createAuthMiddleware(password) {
34
363
  await next();
35
364
  };
36
365
  }
366
+ /**
367
+ * Reject pure read-only callers (empty permission set) on write HTTP methods.
368
+ * Resource:action checks are enforced in services via requirePermission.
369
+ */
370
+ /** Guest may POST only moderated submissions for their share (BA6b). */
371
+ const SHARE_GUEST_SUBMIT_PATH = /^\/api\/v1\/share\/[^/]+\/submissions$/;
372
+ export function createWriteGuardMiddleware() {
373
+ return async (c, next) => {
374
+ const method = c.req.method.toUpperCase();
375
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
376
+ const ctx = tryGetAuthContext();
377
+ if (ctx !== undefined) {
378
+ if (ctx.kind === 'guest') {
379
+ // Moderated inbox only — every other write stays forbidden for guests.
380
+ if (method === 'POST' && SHARE_GUEST_SUBMIT_PATH.test(c.req.path)) {
381
+ await next();
382
+ return;
383
+ }
384
+ return c.json({ error: 'forbidden' }, 403);
385
+ }
386
+ if (!hasAnyWritePermission(ctx.permission)) {
387
+ return c.json({ error: 'forbidden' }, 403);
388
+ }
389
+ }
390
+ }
391
+ await next();
392
+ };
393
+ }
37
394
  //# sourceMappingURL=auth.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * better-auth foundation (migration slice 1/6).
3
+ *
4
+ * Nothing in this app consumes this yet — `createApp` mounts the handler
5
+ * (see server.ts) but every existing route keeps going through
6
+ * `createOrgAuthMiddleware` exactly as before. This file only proves the
7
+ * config boots on our driver.
8
+ *
9
+ * Dialect choice (REQ-3): a Kysely `LibsqlDialect`, not `drizzleAdapter`.
10
+ * `@better-auth/cli` (which would normally generate a Drizzle schema for
11
+ * the adapter) is pinned two minors behind this `better-auth` release, so
12
+ * its output can't be trusted to match — hand-rolling a Drizzle schema for
13
+ * the tables we don't own the shape of is the same trust problem by hand.
14
+ * The Kysely dialect needs no generated schema: better-auth introspects and
15
+ * creates its own tables at runtime (see `runBetterAuthMigrations` below).
16
+ * This is exactly what `scratchpad/ba-spike/spike.mjs` proved 9/9 on.
17
+ *
18
+ * Migration seam (REQ-4): better-auth's tables are created by
19
+ * `better-auth/db/migration`'s runtime migrator, which diffs the live
20
+ * schema via Kysely's introspector and emits `CREATE TABLE` for whatever
21
+ * is missing — it keeps no ledger table of its own. Our 16 Drizzle
22
+ * migrations keep bookkeeping in `__drizzle_migrations`. Two independent,
23
+ * non-overlapping table sets, two independent, non-overlapping bookkeeping
24
+ * mechanisms: neither can fight over the other's tables or history.
25
+ *
26
+ * The dialect wraps the SAME libSQL `Client` the caller's `Db` already
27
+ * holds (`db.$client`), rather than opening a second connection from a
28
+ * URL. A fresh `:memory:` URL would open a second, empty, unrelated
29
+ * database — see the connection-per-transaction footgun documented next to
30
+ * `withTransaction` in `@plandesk/db`'s client.ts — so sharing the
31
+ * connection is required for correctness, not just efficiency.
32
+ */
33
+ import { type Auth } from 'better-auth';
34
+ import type { Client, Db } from '@plandesk/db';
35
+ export type BetterAuthDeps = {
36
+ /** The app's existing libSQL connection — shared, never a second one. */
37
+ client: Client;
38
+ /**
39
+ * App Db for personal-org provisioning on first GitHub session (BA4c).
40
+ * When omitted, databaseHooks are not installed (foundation tests only).
41
+ */
42
+ db?: Db;
43
+ /** Absent secret -> feature is off, still boots (mirrors `github: undefined`). */
44
+ secret: string | undefined;
45
+ baseURL: string;
46
+ github?: {
47
+ clientId: string;
48
+ clientSecret: string;
49
+ };
50
+ };
51
+ /**
52
+ * Erase the exact plugin tuple from the public type so TypeScript can emit a
53
+ * stable declaration while retaining the handler, adapter context, and API.
54
+ * Plugin endpoints (verifyApiKey / createApiKey) are reached via runtime-
55
+ * validated accessors in agent-keys.ts (BA5).
56
+ */
57
+ export type BetterAuthInstance = Auth;
58
+ /** Absent secret -> undefined, the supported no-auth-mounted state (REQ-5). */
59
+ export declare function createBetterAuth(deps: BetterAuthDeps): BetterAuthInstance | undefined;
60
+ /**
61
+ * Create better-auth's tables against the shared connection. Node CLI boot
62
+ * and operator migration paths call this explicitly; edge request handlers do
63
+ * not migrate.
64
+ */
65
+ export declare function runBetterAuthMigrations(auth: BetterAuthInstance): Promise<void>;
66
+ //# sourceMappingURL=better-auth.d.ts.map
@@ -0,0 +1,106 @@
1
+ /**
2
+ * better-auth foundation (migration slice 1/6).
3
+ *
4
+ * Nothing in this app consumes this yet — `createApp` mounts the handler
5
+ * (see server.ts) but every existing route keeps going through
6
+ * `createOrgAuthMiddleware` exactly as before. This file only proves the
7
+ * config boots on our driver.
8
+ *
9
+ * Dialect choice (REQ-3): a Kysely `LibsqlDialect`, not `drizzleAdapter`.
10
+ * `@better-auth/cli` (which would normally generate a Drizzle schema for
11
+ * the adapter) is pinned two minors behind this `better-auth` release, so
12
+ * its output can't be trusted to match — hand-rolling a Drizzle schema for
13
+ * the tables we don't own the shape of is the same trust problem by hand.
14
+ * The Kysely dialect needs no generated schema: better-auth introspects and
15
+ * creates its own tables at runtime (see `runBetterAuthMigrations` below).
16
+ * This is exactly what `scratchpad/ba-spike/spike.mjs` proved 9/9 on.
17
+ *
18
+ * Migration seam (REQ-4): better-auth's tables are created by
19
+ * `better-auth/db/migration`'s runtime migrator, which diffs the live
20
+ * schema via Kysely's introspector and emits `CREATE TABLE` for whatever
21
+ * is missing — it keeps no ledger table of its own. Our 16 Drizzle
22
+ * migrations keep bookkeeping in `__drizzle_migrations`. Two independent,
23
+ * non-overlapping table sets, two independent, non-overlapping bookkeeping
24
+ * mechanisms: neither can fight over the other's tables or history.
25
+ *
26
+ * The dialect wraps the SAME libSQL `Client` the caller's `Db` already
27
+ * holds (`db.$client`), rather than opening a second connection from a
28
+ * URL. A fresh `:memory:` URL would open a second, empty, unrelated
29
+ * database — see the connection-per-transaction footgun documented next to
30
+ * `withTransaction` in `@plandesk/db`'s client.ts — so sharing the
31
+ * connection is required for correctness, not just efficiency.
32
+ */
33
+ import { betterAuth } from 'better-auth';
34
+ import { organization } from 'better-auth/plugins';
35
+ import { apiKey } from '@better-auth/api-key';
36
+ import { LibsqlDialect } from '@libsql/kysely-libsql';
37
+ import { ac, admin, member, owner } from './access-control.js';
38
+ import { provisionPersonalOrgIfNeeded, setDefaultActiveOrganization } from './identity.js';
39
+ /** Absent secret -> undefined, the supported no-auth-mounted state (REQ-5). */
40
+ export function createBetterAuth(deps) {
41
+ if (deps.secret === undefined || deps.secret.length === 0) {
42
+ return undefined;
43
+ }
44
+ // Closed over after construction so session.create.after can use the instance.
45
+ let authInstance;
46
+ const appDb = deps.db;
47
+ const options = {
48
+ database: { dialect: new LibsqlDialect({ client: deps.client }), type: 'sqlite' },
49
+ secret: deps.secret,
50
+ baseURL: deps.baseURL,
51
+ emailAndPassword: { enabled: true, disableSignUp: true },
52
+ account: {
53
+ accountLinking: {
54
+ enabled: true,
55
+ requireLocalEmailVerified: true,
56
+ trustedProviders: ['github'],
57
+ },
58
+ },
59
+ ...(deps.github === undefined
60
+ ? {}
61
+ : {
62
+ socialProviders: {
63
+ github: { clientId: deps.github.clientId, clientSecret: deps.github.clientSecret },
64
+ },
65
+ }),
66
+ // BA4c: session.create fires after OAuth has linked the github account
67
+ // (user+account already exist). Invited members already hold a member row
68
+ // so provision is a no-op. Public adapter.create (test seeds) does not fire
69
+ // these hooks — only internalAdapter / real OAuth paths do.
70
+ ...(appDb === undefined
71
+ ? {}
72
+ : {
73
+ databaseHooks: {
74
+ session: {
75
+ create: {
76
+ after: async (session) => {
77
+ if (authInstance === undefined)
78
+ return;
79
+ await provisionPersonalOrgIfNeeded(authInstance, appDb, session.userId);
80
+ await setDefaultActiveOrganization(authInstance, session.userId, session.token);
81
+ },
82
+ },
83
+ },
84
+ },
85
+ }),
86
+ plugins: [
87
+ organization({ ac, roles: { owner, admin, member } }),
88
+ // enableMetadata: projectId + orgId on agent keys (BA5). Rate limit off —
89
+ // agent traffic is bursty; ceilings are permission-based, not request-count.
90
+ apiKey({ enableMetadata: true, rateLimit: { enabled: false } }),
91
+ ],
92
+ };
93
+ authInstance = betterAuth(options);
94
+ return authInstance;
95
+ }
96
+ /**
97
+ * Create better-auth's tables against the shared connection. Node CLI boot
98
+ * and operator migration paths call this explicitly; edge request handlers do
99
+ * not migrate.
100
+ */
101
+ export async function runBetterAuthMigrations(auth) {
102
+ const { getMigrations } = await import('better-auth/db/migration');
103
+ const { runMigrations } = await getMigrations(auth.options);
104
+ await runMigrations();
105
+ }
106
+ //# sourceMappingURL=better-auth.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * GitHub OAuth helpers (authorize URL + code exchange for identity).
3
+ * Browser sign-in is owned by better-auth social. GitHub is optional (REQ-20).
4
+ */
5
+ /**
6
+ * The slice of fetch this module uses. Declared rather than `typeof fetch`:
7
+ * the ambient global signature varies by runtime (the Workers types widen it),
8
+ * and pinning our own keeps the injected test double honestly typed.
9
+ */
10
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
11
+ export type GithubConfig = {
12
+ clientId: string;
13
+ clientSecret: string;
14
+ /** Absolute callback URL registered with the GitHub app. */
15
+ callbackUrl: string;
16
+ /** Where to land the browser once the cookie is set. Defaults to '/'. */
17
+ dashboardUrl?: string;
18
+ /** Injectable for tests — never call the network in a test. */
19
+ fetch?: FetchLike;
20
+ };
21
+ /** Identity we keep. The access token is deliberately discarded after this. */
22
+ export type GithubIdentity = {
23
+ /** GitHub's numeric account id — stable across renames. */
24
+ id: number;
25
+ login: string;
26
+ name: string | null;
27
+ };
28
+ export declare class GithubOAuthError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ export type GithubEnv = {
32
+ PLANDESK_GITHUB_CLIENT_ID?: string;
33
+ PLANDESK_GITHUB_CLIENT_SECRET?: string;
34
+ PLANDESK_GITHUB_CALLBACK_URL?: string;
35
+ PLANDESK_DASHBOARD_URL?: string;
36
+ };
37
+ /**
38
+ * Read the GitHub app from the environment. Every runtime entry uses this, so
39
+ * "is GitHub sign-in on?" has exactly one answer everywhere.
40
+ *
41
+ * No client id/secret → undefined → the instance runs without GitHub sign-in.
42
+ * That is the supported self-host path, not a degraded one (REQ-20).
43
+ *
44
+ * A half-configured app throws instead of silently disabling: someone who set
45
+ * a client id meant to turn this on and should be told what is missing.
46
+ */
47
+ export declare function githubConfigFromEnv(env: GithubEnv): GithubConfig | undefined;
48
+ /** `github:<numeric id>` — never the login, which a rename would orphan. */
49
+ export declare function userRefFromGithubId(id: number): string;
50
+ export declare function authorizeUrl(config: GithubConfig, state: string): string;
51
+ /**
52
+ * Exchange the callback code for the caller's identity.
53
+ * The access token is used once, here, and then dropped: storing it would make
54
+ * Plan Desk a breach target for no product benefit.
55
+ */
56
+ export declare function resolveGithubIdentity(config: GithubConfig, code: string): Promise<GithubIdentity>;
57
+ //# sourceMappingURL=github.d.ts.map