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

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/auth.js CHANGED
@@ -187,6 +187,7 @@ async function resolveBetterAuthApiKeyContext(auth, bearer) {
187
187
  */
188
188
  const PUBLIC_AUTH_PATHS = new Set([
189
189
  '/api/v1/auth/methods',
190
+ '/api/v1/health',
190
191
  '/api/auth/*',
191
192
  ]);
192
193
  /** Invitation accept: invitee may have a session but zero org memberships yet. */
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { requirePermission, hasPermission, hasAnyWritePermission, orgRoleToPermi
12
12
  export { healthRouter } from './routes/health.js';
13
13
  export { mountStatic } from './static.js';
14
14
  export { createS3Adapter, type S3AdapterConfig } from './storage/s3.js';
15
+ export { createR2Adapter, type R2BucketLike } from './storage/r2.js';
15
16
  export type { StorageAdapter } from './storage/adapter.js';
16
17
  export { createServices, type Services, type ServicesDeps } from './services/index.js';
17
18
  export { assertProjectInOrg, ProjectNotInOrgError } from './services/scope.js';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { requirePermission, hasPermission, hasAnyWritePermission, orgRoleToPermi
12
12
  export { healthRouter } from './routes/health.js';
13
13
  export { mountStatic } from './static.js';
14
14
  export { createS3Adapter } from './storage/s3.js';
15
+ export { createR2Adapter } from './storage/r2.js';
15
16
  export { createServices } from './services/index.js';
16
17
  export { assertProjectInOrg, ProjectNotInOrgError } from './services/scope.js';
17
18
  export { GoalCompletionBlockedError, GoalVerificationRequiredError, InvalidGoalTransitionError, InvalidVerificationSurfaceError, } from './services/goals.js';
@@ -20,6 +20,16 @@ export declare function getOrganizationById(auth: BetterAuthInstance, organizati
20
20
  */
21
21
  export declare function resolveDefaultOrganization(auth: BetterAuthInstance): Promise<OrganizationSummary | undefined>;
22
22
  export declare function listOrganizationsForUser(auth: BetterAuthInstance, userId: string): Promise<UserOrganizationSummary[]>;
23
+ export type OrganizationMemberSummary = {
24
+ id: string;
25
+ userId: string;
26
+ email: string;
27
+ name: string;
28
+ role: string;
29
+ createdAt: string;
30
+ };
31
+ /** List better-auth members of an organization (email/name joined from user). */
32
+ export declare function listOrganizationMembers(auth: BetterAuthInstance, organizationId: string): Promise<OrganizationMemberSummary[]>;
23
33
  /**
24
34
  * Display-friendly org for /auth/session and similar.
25
35
  * Falls back to DEFAULT_ORG_ID → "Personal" when better-auth is not configured
@@ -43,6 +43,31 @@ export async function listOrganizationsForUser(auth, userId) {
43
43
  return { id: organization.id, name: organization.name, role: member.role };
44
44
  }));
45
45
  }
46
+ /** List better-auth members of an organization (email/name joined from user). */
47
+ export async function listOrganizationMembers(auth, organizationId) {
48
+ const adapter = (await auth.$context).adapter;
49
+ const members = await adapter.findMany({
50
+ model: 'member',
51
+ where: [{ field: 'organizationId', value: organizationId }],
52
+ sortBy: { field: 'createdAt', direction: 'asc' },
53
+ });
54
+ return Promise.all(members.map(async (member) => {
55
+ const user = await adapter.findOne({
56
+ model: 'user',
57
+ where: [{ field: 'id', value: member.userId }],
58
+ });
59
+ return {
60
+ id: member.id,
61
+ userId: member.userId,
62
+ email: user?.email ?? '',
63
+ name: user?.name ?? '',
64
+ role: member.role,
65
+ createdAt: member.createdAt instanceof Date
66
+ ? member.createdAt.toISOString()
67
+ : String(member.createdAt),
68
+ };
69
+ }));
70
+ }
46
71
  /**
47
72
  * Display-friendly org for /auth/session and similar.
48
73
  * 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 } from '../organizations.js';
6
+ import { 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)) {
@@ -107,6 +107,20 @@ export function createOrgsRouter(db, options = {}) {
107
107
  const minted = await createScopedAgentKey(mintInput);
108
108
  return c.json({ token: minted.key, project_id: projectId }, 200);
109
109
  });
110
+ /**
111
+ * List better-auth org members (role + email). Any org member may read.
112
+ */
113
+ router.get('/orgs/:id/members', async (c) => {
114
+ const orgId = c.req.param('id');
115
+ if (!(await requireKnownOrg(orgId))) {
116
+ return c.json({ error: 'not_found' }, 404);
117
+ }
118
+ if (betterAuth === undefined) {
119
+ return c.json({ error: 'unavailable' }, 503);
120
+ }
121
+ const members = await listOrganizationMembers(betterAuth, orgId);
122
+ return c.json({ members }, 200);
123
+ });
110
124
  /**
111
125
  * BA3c: invite by email (link-only, no mailer). Session owner only
112
126
  * (member:create). Returns claimUrl for the inviter to deliver by hand.
@@ -1,11 +1,15 @@
1
1
  import type { Db } from '@plandesk/db';
2
2
  import type { StorageAdapter } from './adapter.js';
3
+ import { type R2BucketLike } from './r2.js';
3
4
  export * from './adapter.js';
4
5
  export { createLocalBlobAdapter } from './local.js';
6
+ export { createR2Adapter, type R2BucketLike } from './r2.js';
5
7
  export { createS3Adapter, readS3ConfigFromEnv, type S3AdapterConfig } from './s3.js';
6
8
  export type CreateStorageAdapterDeps = {
7
9
  db: Db;
8
10
  env?: NodeJS.ProcessEnv;
11
+ /** When set (Workers), prefer the native R2 binding over S3/local. */
12
+ r2?: R2BucketLike;
9
13
  };
10
14
  export declare function createStorageAdapter(deps: CreateStorageAdapterDeps): StorageAdapter;
11
15
  //# sourceMappingURL=index.d.ts.map
@@ -1,9 +1,14 @@
1
1
  import { createLocalBlobAdapter } from './local.js';
2
+ import { createR2Adapter } from './r2.js';
2
3
  import { createS3Adapter, readS3ConfigFromEnv } from './s3.js';
3
4
  export * from './adapter.js';
4
5
  export { createLocalBlobAdapter } from './local.js';
6
+ export { createR2Adapter } from './r2.js';
5
7
  export { createS3Adapter, readS3ConfigFromEnv } from './s3.js';
6
8
  export function createStorageAdapter(deps) {
9
+ if (deps.r2 !== undefined) {
10
+ return createR2Adapter({ db: deps.db, bucket: deps.r2 });
11
+ }
7
12
  const env = deps.env ?? process.env;
8
13
  const kind = env.PLANDESK_STORAGE ?? 'local';
9
14
  if (kind === 's3') {
@@ -0,0 +1,40 @@
1
+ import { type Db } from '@plandesk/db';
2
+ import { type StorageAdapter } from './adapter.js';
3
+ /**
4
+ * Minimal R2Bucket surface used by the adapter (matches Workers binding API).
5
+ * Tests supply an in-memory fake; production passes env.FILES.
6
+ */
7
+ export type R2BucketLike = {
8
+ put(key: string, value: ArrayBuffer | ArrayBufferView | string | null | Blob, options?: {
9
+ httpMetadata?: {
10
+ contentType?: string;
11
+ };
12
+ customMetadata?: Record<string, string>;
13
+ }): Promise<unknown>;
14
+ get(key: string): Promise<{
15
+ arrayBuffer(): Promise<ArrayBuffer>;
16
+ httpMetadata?: {
17
+ contentType?: string;
18
+ };
19
+ customMetadata?: Record<string, string>;
20
+ } | null>;
21
+ head(key: string): Promise<{
22
+ httpMetadata?: {
23
+ contentType?: string;
24
+ };
25
+ customMetadata?: Record<string, string>;
26
+ } | null>;
27
+ };
28
+ export type R2AdapterDeps = {
29
+ db: Db;
30
+ bucket: R2BucketLike;
31
+ };
32
+ /**
33
+ * Content-addressed R2 storage via the native Workers binding (not S3 creds).
34
+ * Bytes live in the bucket under `{projectId}/{sha256}`; the `files` row records
35
+ * metadata + org linkage. `resolve` is org-scoped through `getFileInOrg` — the
36
+ * bare content id is never a lookup key, so one tenant cannot read another's
37
+ * bytes by knowing the hash (mirrors the local/s3 adapters).
38
+ */
39
+ export declare function createR2Adapter(deps: R2AdapterDeps): StorageAdapter;
40
+ //# sourceMappingURL=r2.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createFile, getFileInOrg } from '@plandesk/db';
3
+ import { tryGetAuthContext } from '../auth-context.js';
4
+ import { fileUrl } from './adapter.js';
5
+ // Per-project object key, mirroring the (project_id, id) primary key of the
6
+ // `files` table: identical bytes in different projects never collide.
7
+ function objectKey(projectId, id) {
8
+ return `${projectId}/${id}`;
9
+ }
10
+ /**
11
+ * Content-addressed R2 storage via the native Workers binding (not S3 creds).
12
+ * Bytes live in the bucket under `{projectId}/{sha256}`; the `files` row records
13
+ * metadata + org linkage. `resolve` is org-scoped through `getFileInOrg` — the
14
+ * bare content id is never a lookup key, so one tenant cannot read another's
15
+ * bytes by knowing the hash (mirrors the local/s3 adapters).
16
+ */
17
+ export function createR2Adapter(deps) {
18
+ const { db, bucket } = deps;
19
+ return {
20
+ async put(input) {
21
+ const id = createHash('sha256').update(input.bytes).digest('hex');
22
+ const body = new Uint8Array(input.bytes);
23
+ await bucket.put(objectKey(input.projectId, id), body, {
24
+ httpMetadata: { contentType: input.mime },
25
+ customMetadata: {
26
+ filename: input.filename,
27
+ mime: input.mime,
28
+ projectId: input.projectId,
29
+ },
30
+ });
31
+ // Persist the metadata row so FileService.create can read it back and so
32
+ // resolve() can org-scope. Bytes stay in R2 (bytes: null in the DB).
33
+ await createFile(db, {
34
+ id,
35
+ projectId: input.projectId,
36
+ filename: input.filename,
37
+ mime: input.mime,
38
+ size: input.bytes.length,
39
+ bytes: null,
40
+ externalUrl: null,
41
+ });
42
+ return { id, url: fileUrl(id) };
43
+ },
44
+ async resolve(id) {
45
+ const auth = tryGetAuthContext();
46
+ if (auth === undefined || auth.kind === 'guest') {
47
+ return null;
48
+ }
49
+ const file = await getFileInOrg(db, id, auth.orgId);
50
+ if (!file) {
51
+ return null;
52
+ }
53
+ if (file.externalUrl) {
54
+ return { redirectUrl: file.externalUrl };
55
+ }
56
+ const obj = await bucket.get(objectKey(file.projectId, id));
57
+ if (obj === null) {
58
+ return null;
59
+ }
60
+ const bytes = Buffer.from(await obj.arrayBuffer());
61
+ return { bytes, mime: file.mime, filename: file.filename };
62
+ },
63
+ };
64
+ }
65
+ //# sourceMappingURL=r2.js.map
package/dist/worker.d.ts CHANGED
@@ -3,10 +3,10 @@ export interface Env {
3
3
  PLANDESK_DB_URL: string;
4
4
  /** Turso/libSQL auth token — set via `wrangler secret put PLANDESK_DB_TOKEN` */
5
5
  PLANDESK_DB_TOKEN: string;
6
- PLANDESK_S3_BUCKET: string;
7
- PLANDESK_S3_REGION: string;
8
- PLANDESK_S3_ACCESS_KEY_ID: string;
9
- PLANDESK_S3_SECRET_ACCESS_KEY: string;
6
+ PLANDESK_S3_BUCKET?: string;
7
+ PLANDESK_S3_REGION?: string;
8
+ PLANDESK_S3_ACCESS_KEY_ID?: string;
9
+ PLANDESK_S3_SECRET_ACCESS_KEY?: string;
10
10
  PLANDESK_S3_ENDPOINT?: string;
11
11
  PLANDESK_AUTH_PASSWORD?: string;
12
12
  /**
@@ -32,8 +32,8 @@ export interface Env {
32
32
  */
33
33
  PLANDESK_GITHUB_CALLBACK_URL?: string;
34
34
  PLANDESK_DASHBOARD_URL?: string;
35
- /** R2 bucket for file blobs (S3-compatible credentials above target this bucket). */
36
- FILES: R2Bucket;
35
+ /** R2 bucket for file blobs (native Workers binding preferred over S3 creds). */
36
+ FILES?: R2Bucket;
37
37
  /** Built web SPA (wrangler [assets]). */
38
38
  ASSETS?: Fetcher;
39
39
  }
package/dist/worker.js CHANGED
@@ -9,19 +9,45 @@ import { createApp } from './server.js';
9
9
  import { createBetterAuth } from './better-auth.js';
10
10
  import { createServices } from './services/index.js';
11
11
  import { githubConfigFromEnv } from './github.js';
12
+ import { createR2Adapter } from './storage/r2.js';
12
13
  import { createS3Adapter } from './storage/s3.js';
13
14
  import { hostedMisconfigResponse, resolveHostedBetterAuth } from './hosted-auth.js';
14
15
  let cache;
15
16
  let authCache;
16
17
  function s3ConfigFromEnv(env) {
18
+ const bucket = env.PLANDESK_S3_BUCKET;
19
+ const region = env.PLANDESK_S3_REGION;
20
+ const accessKeyId = env.PLANDESK_S3_ACCESS_KEY_ID;
21
+ const secretAccessKey = env.PLANDESK_S3_SECRET_ACCESS_KEY;
22
+ if (bucket === undefined ||
23
+ bucket === '' ||
24
+ region === undefined ||
25
+ region === '' ||
26
+ accessKeyId === undefined ||
27
+ accessKeyId === '' ||
28
+ secretAccessKey === undefined ||
29
+ secretAccessKey === '') {
30
+ return undefined;
31
+ }
17
32
  return {
18
- bucket: env.PLANDESK_S3_BUCKET,
19
- region: env.PLANDESK_S3_REGION,
20
- accessKeyId: env.PLANDESK_S3_ACCESS_KEY_ID,
21
- secretAccessKey: env.PLANDESK_S3_SECRET_ACCESS_KEY,
33
+ bucket,
34
+ region,
35
+ accessKeyId,
36
+ secretAccessKey,
22
37
  endpoint: env.PLANDESK_S3_ENDPOINT,
23
38
  };
24
39
  }
40
+ function resolveWorkerStorage(env, db) {
41
+ // Prefer the native R2 binding over S3 credentials when present.
42
+ if (env.FILES !== undefined) {
43
+ return createR2Adapter({ db, bucket: env.FILES });
44
+ }
45
+ const s3 = s3ConfigFromEnv(env);
46
+ if (s3 !== undefined) {
47
+ return createS3Adapter({ db, config: s3 });
48
+ }
49
+ return undefined;
50
+ }
25
51
  async function getDb(env) {
26
52
  const key = `${env.PLANDESK_DB_URL}\0${env.PLANDESK_DB_TOKEN}`;
27
53
  if (cache !== undefined && cache.key === key) {
@@ -83,12 +109,9 @@ export default {
83
109
  }
84
110
  const db = await getDb(env);
85
111
  const authInstance = await getBetterAuth(env, db, betterAuth);
86
- // Storage is optional: only wire S3/R2 when its credentials are present.
87
- // Without them the app still runs (file uploads/artifacts are unavailable),
88
- // rather than throwing on every request — mirrors `plandesk serve`.
89
- const storage = env.PLANDESK_S3_BUCKET !== undefined && env.PLANDESK_S3_BUCKET !== ''
90
- ? createS3Adapter({ db, config: s3ConfigFromEnv(env) })
91
- : undefined;
112
+ // Storage is optional: prefer R2 binding, then S3 creds, else unavailable
113
+ // (file uploads/artifacts off — no crash). Mirrors `plandesk serve`.
114
+ const storage = resolveWorkerStorage(env, db);
92
115
  const services = createServices({ db, ...(storage !== undefined ? { storage } : {}) });
93
116
  const app = createApp({
94
117
  db,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plandesk/api",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
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.2"
29
+ "@plandesk/db": "1.0.0-beta.3"
30
30
  },
31
31
  "description": "Plan Desk REST + SSE API and service layer (local-first planning workspace).",
32
32
  "license": "MIT",