@camox/api 0.2.0-alpha.5 → 0.3.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.
- package/package.json +1 -1
- package/src/authorization.ts +23 -21
- package/src/index.ts +15 -10
- package/src/orpc.ts +0 -14
- package/src/routes/auth.ts +21 -3
- package/src/routes/block-definitions.ts +13 -26
- package/src/routes/blocks.ts +1 -3
- package/src/routes/files.ts +2 -3
- package/src/routes/layouts.ts +6 -14
- package/src/routes/pages.ts +18 -0
- package/src/routes/projects.ts +129 -115
- package/src/schema.ts +4 -2
- package/src/types.ts +0 -2
package/package.json
CHANGED
package/src/authorization.ts
CHANGED
|
@@ -2,44 +2,46 @@ import { ORPCError } from "@orpc/server";
|
|
|
2
2
|
import { and, eq, or } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
import type { Database } from "./db";
|
|
5
|
-
import {
|
|
6
|
-
member,
|
|
7
|
-
organizationTable,
|
|
8
|
-
blocks,
|
|
9
|
-
files,
|
|
10
|
-
layouts,
|
|
11
|
-
pages,
|
|
12
|
-
projects,
|
|
13
|
-
repeatableItems,
|
|
14
|
-
} from "./schema";
|
|
5
|
+
import { member, blocks, files, layouts, pages, projects, repeatableItems } from "./schema";
|
|
15
6
|
|
|
16
|
-
// ---
|
|
7
|
+
// --- Sync Secret ---
|
|
17
8
|
|
|
18
|
-
export async function
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
9
|
+
export async function assertSyncSecret(db: Database, projectSlug: string, syncSecret: string) {
|
|
10
|
+
const project = await db.select().from(projects).where(eq(projects.slug, projectSlug)).get();
|
|
11
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
12
|
+
|
|
13
|
+
if (syncSecret !== project.syncSecret) {
|
|
14
|
+
throw new ORPCError("UNAUTHORIZED");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return project;
|
|
26
18
|
}
|
|
27
19
|
|
|
20
|
+
// --- Membership Helpers ---
|
|
21
|
+
|
|
28
22
|
/** Verify user is a member of the org that owns a project (by project ID). */
|
|
29
23
|
async function assertProjectMembership(db: Database, projectId: number, userId: string) {
|
|
30
24
|
const result = await db
|
|
31
25
|
.select({ id: member.id })
|
|
32
26
|
.from(projects)
|
|
33
|
-
.innerJoin(organizationTable, eq(organizationTable.slug, projects.organizationSlug))
|
|
34
27
|
.innerJoin(
|
|
35
28
|
member,
|
|
36
|
-
and(eq(member.organizationId,
|
|
29
|
+
and(eq(member.organizationId, projects.organizationId), eq(member.userId, userId)),
|
|
37
30
|
)
|
|
38
31
|
.where(eq(projects.id, projectId))
|
|
39
32
|
.get();
|
|
40
33
|
if (!result) throw new ORPCError("FORBIDDEN");
|
|
41
34
|
}
|
|
42
35
|
|
|
36
|
+
export async function assertOrgMembership(db: Database, userId: string, orgId: string) {
|
|
37
|
+
const result = await db
|
|
38
|
+
.select({ id: member.id })
|
|
39
|
+
.from(member)
|
|
40
|
+
.where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
|
|
41
|
+
.get();
|
|
42
|
+
if (!result) throw new ORPCError("FORBIDDEN");
|
|
43
|
+
}
|
|
44
|
+
|
|
43
45
|
// --- Authorization Helpers ---
|
|
44
46
|
|
|
45
47
|
export async function getAuthorizedProject(db: Database, projectId: number, userId: string) {
|
package/src/index.ts
CHANGED
|
@@ -28,13 +28,7 @@ app.use(
|
|
|
28
28
|
"*",
|
|
29
29
|
cors({
|
|
30
30
|
origin: (origin) => origin,
|
|
31
|
-
allowHeaders: [
|
|
32
|
-
"Content-Type",
|
|
33
|
-
"Authorization",
|
|
34
|
-
"Better-Auth-Cookie",
|
|
35
|
-
"x-sync-secret",
|
|
36
|
-
"x-environment-name",
|
|
37
|
-
],
|
|
31
|
+
allowHeaders: ["Content-Type", "Authorization", "Better-Auth-Cookie", "x-environment-name"],
|
|
38
32
|
allowMethods: ["POST", "GET", "OPTIONS"],
|
|
39
33
|
exposeHeaders: ["Content-Length", "Set-Better-Auth-Cookie"],
|
|
40
34
|
maxAge: 600,
|
|
@@ -44,7 +38,8 @@ app.use(
|
|
|
44
38
|
|
|
45
39
|
// Session middleware — populates c.var.user/session
|
|
46
40
|
app.use("*", async (c, next) => {
|
|
47
|
-
const
|
|
41
|
+
const url = new URL(c.req.url);
|
|
42
|
+
const auth = createAuth(c.var.db, c.env, url.origin);
|
|
48
43
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
49
44
|
|
|
50
45
|
if (!session) {
|
|
@@ -72,8 +67,18 @@ app.use(
|
|
|
72
67
|
options: {
|
|
73
68
|
onBeforeConnect: async (req, _lobby, c) => {
|
|
74
69
|
const db = createDb(c.env.DB);
|
|
75
|
-
const
|
|
76
|
-
const
|
|
70
|
+
const url = new URL(req.url);
|
|
71
|
+
const auth = createAuth(db, c.env, url.origin);
|
|
72
|
+
|
|
73
|
+
// WebSocket upgrades can't carry custom headers, so the client
|
|
74
|
+
// sends the cross-domain auth cookie as a query parameter instead.
|
|
75
|
+
const headers = new Headers(req.headers);
|
|
76
|
+
const authCookie = url.searchParams.get("_authCookie");
|
|
77
|
+
if (authCookie) {
|
|
78
|
+
headers.set("Better-Auth-Cookie", authCookie);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const session = await auth.api.getSession({ headers });
|
|
77
82
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
|
78
83
|
},
|
|
79
84
|
},
|
package/src/orpc.ts
CHANGED
|
@@ -35,20 +35,6 @@ export const pub = os.$context<BaseContext>().use(async ({ next, path }) => {
|
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
/** Sync procedure — requires either a valid session or the x-sync-secret header */
|
|
39
|
-
export const synced = pub.use(async ({ context, next }) => {
|
|
40
|
-
if (context.user && context.session) {
|
|
41
|
-
return next({ context });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const secret = context.headers.get("x-sync-secret");
|
|
45
|
-
if (!secret || secret !== context.env.SYNC_SECRET) {
|
|
46
|
-
throw new ORPCError("UNAUTHORIZED");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return next({ context });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
38
|
/** Authed procedure — requires authenticated user */
|
|
53
39
|
export const authed = pub.use(async ({ context, next }) => {
|
|
54
40
|
if (!context.user || !context.session) {
|
package/src/routes/auth.ts
CHANGED
|
@@ -38,13 +38,24 @@ function generateSlug(name: string): string {
|
|
|
38
38
|
return `${base}-${suffix}`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function createAuth(db: Database, env: Bindings) {
|
|
41
|
+
export function createAuth(db: Database, env: Bindings, baseURL: string) {
|
|
42
|
+
// Derive the cookie domain from SITE_URL so cookies are shared between
|
|
43
|
+
// the web app (camox.ai) and the API (api.camox.ai).
|
|
44
|
+
// In dev (localhost), this is undefined — cookies work without an explicit domain.
|
|
45
|
+
let cookieDomain: string | undefined;
|
|
46
|
+
try {
|
|
47
|
+
const siteHost = new URL(env.SITE_URL).hostname;
|
|
48
|
+
if (siteHost !== "localhost") cookieDomain = `.${siteHost}`;
|
|
49
|
+
} catch {
|
|
50
|
+
// SITE_URL missing or malformed — fall back to default cookie domain
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
const auth = betterAuth({
|
|
43
54
|
database: drizzleAdapter(db, {
|
|
44
55
|
provider: "sqlite",
|
|
45
56
|
schema: authSchema,
|
|
46
57
|
}),
|
|
47
|
-
baseURL
|
|
58
|
+
baseURL,
|
|
48
59
|
secret: env.BETTER_AUTH_SECRET,
|
|
49
60
|
emailAndPassword: {
|
|
50
61
|
enabled: true,
|
|
@@ -66,6 +77,12 @@ export function createAuth(db: Database, env: Bindings) {
|
|
|
66
77
|
},
|
|
67
78
|
// Accept requests from any origin — Camox sites run on arbitrary customer domains
|
|
68
79
|
trustedOrigins: ["*"],
|
|
80
|
+
advanced: {
|
|
81
|
+
crossSubDomainCookies: {
|
|
82
|
+
enabled: true,
|
|
83
|
+
domain: cookieDomain,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
69
86
|
plugins: [organization(), crossDomain({ siteUrl: env.SITE_URL }), oneTimeToken(), bearer()],
|
|
70
87
|
databaseHooks: {
|
|
71
88
|
user: {
|
|
@@ -105,6 +122,7 @@ export const authRoutes = new Hono<AppEnv>()
|
|
|
105
122
|
}),
|
|
106
123
|
)
|
|
107
124
|
.on(["POST", "GET"], "/*", async (c) => {
|
|
108
|
-
const
|
|
125
|
+
const url = new URL(c.req.url);
|
|
126
|
+
const auth = createAuth(c.var.db, c.env, url.origin);
|
|
109
127
|
return auth.handler(c.req.raw);
|
|
110
128
|
});
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { ORPCError } from "@orpc/server";
|
|
2
1
|
import { and, eq } from "drizzle-orm";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
|
|
4
|
+
import { assertSyncSecret } from "../authorization";
|
|
5
5
|
import { resolveEnvironment } from "../lib/resolve-environment";
|
|
6
|
-
import { pub
|
|
7
|
-
import { blockDefinitions
|
|
6
|
+
import { pub } from "../orpc";
|
|
7
|
+
import { blockDefinitions } from "../schema";
|
|
8
8
|
|
|
9
9
|
// --- Procedures ---
|
|
10
10
|
|
|
11
11
|
const definitionSchema = z.object({
|
|
12
12
|
projectSlug: z.string(),
|
|
13
|
+
syncSecret: z.string(),
|
|
13
14
|
blockId: z.string(),
|
|
14
15
|
title: z.string(),
|
|
15
16
|
description: z.string(),
|
|
@@ -22,6 +23,7 @@ const definitionSchema = z.object({
|
|
|
22
23
|
|
|
23
24
|
const syncSchema = z.object({
|
|
24
25
|
projectSlug: z.string(),
|
|
26
|
+
syncSecret: z.string(),
|
|
25
27
|
definitions: z.array(
|
|
26
28
|
z.object({
|
|
27
29
|
blockId: z.string(),
|
|
@@ -54,14 +56,9 @@ const list = pub.input(z.object({ projectId: z.number() })).handler(async ({ con
|
|
|
54
56
|
return result;
|
|
55
57
|
});
|
|
56
58
|
|
|
57
|
-
const sync =
|
|
59
|
+
const sync = pub.input(syncSchema).handler(async ({ context, input }) => {
|
|
58
60
|
const { projectSlug, definitions } = input;
|
|
59
|
-
const project = await context.db
|
|
60
|
-
.select()
|
|
61
|
-
.from(projects)
|
|
62
|
-
.where(eq(projects.slug, projectSlug))
|
|
63
|
-
.get();
|
|
64
|
-
if (!project) throw new ORPCError("NOT_FOUND");
|
|
61
|
+
const project = await assertSyncSecret(context.db, projectSlug, input.syncSecret);
|
|
65
62
|
const projectId = project.id;
|
|
66
63
|
const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
|
|
67
64
|
autoCreate: true,
|
|
@@ -125,14 +122,9 @@ const sync = synced.input(syncSchema).handler(async ({ context, input }) => {
|
|
|
125
122
|
return { results, environmentCreated: environment.created };
|
|
126
123
|
});
|
|
127
124
|
|
|
128
|
-
const upsert =
|
|
129
|
-
const { projectSlug, ...body } = input;
|
|
130
|
-
const project = await context.db
|
|
131
|
-
.select()
|
|
132
|
-
.from(projects)
|
|
133
|
-
.where(eq(projects.slug, projectSlug))
|
|
134
|
-
.get();
|
|
135
|
-
if (!project) throw new ORPCError("NOT_FOUND");
|
|
125
|
+
const upsert = pub.input(definitionSchema).handler(async ({ context, input }) => {
|
|
126
|
+
const { projectSlug, syncSecret: _, ...body } = input;
|
|
127
|
+
const project = await assertSyncSecret(context.db, projectSlug, input.syncSecret);
|
|
136
128
|
const projectId = project.id;
|
|
137
129
|
const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
|
|
138
130
|
autoCreate: true,
|
|
@@ -188,16 +180,11 @@ const upsert = synced.input(definitionSchema).handler(async ({ context, input })
|
|
|
188
180
|
return { ...result, action: "created" as const };
|
|
189
181
|
});
|
|
190
182
|
|
|
191
|
-
const deleteFn =
|
|
192
|
-
.input(z.object({ projectSlug: z.string(), blockId: z.string() }))
|
|
183
|
+
const deleteFn = pub
|
|
184
|
+
.input(z.object({ projectSlug: z.string(), syncSecret: z.string(), blockId: z.string() }))
|
|
193
185
|
.handler(async ({ context, input }) => {
|
|
194
186
|
const { projectSlug, blockId } = input;
|
|
195
|
-
const project = await context.db
|
|
196
|
-
.select()
|
|
197
|
-
.from(projects)
|
|
198
|
-
.where(eq(projects.slug, projectSlug))
|
|
199
|
-
.get();
|
|
200
|
-
if (!project) throw new ORPCError("NOT_FOUND");
|
|
187
|
+
const project = await assertSyncSecret(context.db, projectSlug, input.syncSecret);
|
|
201
188
|
const environment = await resolveEnvironment(context.db, project.id, context.environmentName);
|
|
202
189
|
const result = await context.db
|
|
203
190
|
.delete(blockDefinitions)
|
package/src/routes/blocks.ts
CHANGED
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
files,
|
|
21
21
|
layouts,
|
|
22
22
|
member,
|
|
23
|
-
organizationTable,
|
|
24
23
|
pages,
|
|
25
24
|
projects,
|
|
26
25
|
repeatableItems,
|
|
@@ -625,10 +624,9 @@ const deleteMany = authed
|
|
|
625
624
|
.leftJoin(pages, eq(blocks.pageId, pages.id))
|
|
626
625
|
.leftJoin(layouts, eq(blocks.layoutId, layouts.id))
|
|
627
626
|
.innerJoin(projects, or(eq(projects.id, pages.projectId), eq(projects.id, layouts.projectId)))
|
|
628
|
-
.innerJoin(organizationTable, eq(organizationTable.slug, projects.organizationSlug))
|
|
629
627
|
.innerJoin(
|
|
630
628
|
member,
|
|
631
|
-
and(eq(member.organizationId,
|
|
629
|
+
and(eq(member.organizationId, projects.organizationId), eq(member.userId, context.user.id)),
|
|
632
630
|
)
|
|
633
631
|
.where(inArray(blocks.id, blockIds));
|
|
634
632
|
if (authorizedBlocks.length !== blockIds.length) {
|
package/src/routes/files.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { queryKeys } from "../lib/query-keys";
|
|
|
13
13
|
import { resolveEnvironment } from "../lib/resolve-environment";
|
|
14
14
|
import { scheduleAiJob } from "../lib/schedule-ai-job";
|
|
15
15
|
import { pub, authed } from "../orpc";
|
|
16
|
-
import { blocks, files, member,
|
|
16
|
+
import { blocks, files, member, projects, repeatableItems } from "../schema";
|
|
17
17
|
import type { AppEnv } from "../types";
|
|
18
18
|
|
|
19
19
|
// --- AI Executor ---
|
|
@@ -270,10 +270,9 @@ const deleteMany = authed
|
|
|
270
270
|
.select({ id: files.id, blobId: files.blobId, projectId: files.projectId })
|
|
271
271
|
.from(files)
|
|
272
272
|
.innerJoin(projects, eq(projects.id, files.projectId))
|
|
273
|
-
.innerJoin(organizationTable, eq(organizationTable.slug, projects.organizationSlug))
|
|
274
273
|
.innerJoin(
|
|
275
274
|
member,
|
|
276
|
-
and(eq(member.organizationId,
|
|
275
|
+
and(eq(member.organizationId, projects.organizationId), eq(member.userId, context.user.id)),
|
|
277
276
|
)
|
|
278
277
|
.where(inArray(files.id, ids));
|
|
279
278
|
|
package/src/routes/layouts.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { ORPCError } from "@orpc/server";
|
|
2
1
|
import { and, eq } from "drizzle-orm";
|
|
3
2
|
import { generateKeyBetween } from "fractional-indexing";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
|
|
5
|
+
import { assertSyncSecret } from "../authorization";
|
|
6
6
|
import { broadcastInvalidation } from "../lib/broadcast-invalidation";
|
|
7
7
|
import { queryKeys } from "../lib/query-keys";
|
|
8
8
|
import { resolveEnvironment } from "../lib/resolve-environment";
|
|
9
|
-
import { pub
|
|
10
|
-
import { blocks, layouts,
|
|
9
|
+
import { pub } from "../orpc";
|
|
10
|
+
import { blocks, layouts, repeatableItems } from "../schema";
|
|
11
11
|
|
|
12
12
|
// --- Procedures ---
|
|
13
13
|
|
|
@@ -21,6 +21,7 @@ const repeatableItemSeedSchema = z.object({
|
|
|
21
21
|
|
|
22
22
|
const syncLayoutsSchema = z.object({
|
|
23
23
|
projectSlug: z.string(),
|
|
24
|
+
syncSecret: z.string(),
|
|
24
25
|
layouts: z.array(
|
|
25
26
|
z.object({
|
|
26
27
|
layoutId: z.string(),
|
|
@@ -47,18 +48,9 @@ const list = pub.input(z.object({ projectId: z.number() })).handler(async ({ con
|
|
|
47
48
|
.where(and(eq(layouts.projectId, projectId), eq(layouts.environmentId, environment.id)));
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
const sync =
|
|
51
|
+
const sync = pub.input(syncLayoutsSchema).handler(async ({ context, input }) => {
|
|
51
52
|
const { projectSlug, layouts: layoutDefs } = input;
|
|
52
|
-
const project = await context.db
|
|
53
|
-
.select()
|
|
54
|
-
.from(projects)
|
|
55
|
-
.where(eq(projects.slug, projectSlug))
|
|
56
|
-
.get();
|
|
57
|
-
|
|
58
|
-
if (!project) {
|
|
59
|
-
throw new ORPCError("NOT_FOUND");
|
|
60
|
-
}
|
|
61
|
-
|
|
53
|
+
const project = await assertSyncSecret(context.db, projectSlug, input.syncSecret);
|
|
62
54
|
const projectId = project.id;
|
|
63
55
|
const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
|
|
64
56
|
autoCreate: true,
|
package/src/routes/pages.ts
CHANGED
|
@@ -519,6 +519,23 @@ const list = pub.input(z.object({ projectId: z.number() })).handler(async ({ con
|
|
|
519
519
|
.where(and(eq(pages.projectId, input.projectId), eq(pages.environmentId, environment.id)));
|
|
520
520
|
});
|
|
521
521
|
|
|
522
|
+
const listBySlug = pub
|
|
523
|
+
.input(z.object({ projectSlug: z.string() }))
|
|
524
|
+
.handler(async ({ context, input }) => {
|
|
525
|
+
const project = await context.db
|
|
526
|
+
.select()
|
|
527
|
+
.from(projects)
|
|
528
|
+
.where(eq(projects.slug, input.projectSlug))
|
|
529
|
+
.get();
|
|
530
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
531
|
+
|
|
532
|
+
const environment = await resolveEnvironment(context.db, project.id, context.environmentName);
|
|
533
|
+
return await context.db
|
|
534
|
+
.select()
|
|
535
|
+
.from(pages)
|
|
536
|
+
.where(and(eq(pages.projectId, project.id), eq(pages.environmentId, environment.id)));
|
|
537
|
+
});
|
|
538
|
+
|
|
522
539
|
const get = pub.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
523
540
|
const { id } = input;
|
|
524
541
|
const result = await context.db.select().from(pages).where(eq(pages.id, id)).get();
|
|
@@ -806,6 +823,7 @@ export const pageProcedures = {
|
|
|
806
823
|
getByPath,
|
|
807
824
|
getStructure,
|
|
808
825
|
list,
|
|
826
|
+
listBySlug,
|
|
809
827
|
get,
|
|
810
828
|
create,
|
|
811
829
|
update,
|
package/src/routes/projects.ts
CHANGED
|
@@ -3,17 +3,25 @@ import { and, eq } from "drizzle-orm";
|
|
|
3
3
|
import { generateKeyBetween } from "fractional-indexing";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
|
|
6
|
-
import { assertOrgMembership, getAuthorizedProject } from "../authorization";
|
|
6
|
+
import { assertOrgMembership, assertSyncSecret, getAuthorizedProject } from "../authorization";
|
|
7
7
|
import { resolveEnvironment } from "../lib/resolve-environment";
|
|
8
8
|
import { generateUniqueSlug } from "../lib/slug";
|
|
9
|
-
import { authed,
|
|
10
|
-
import {
|
|
9
|
+
import { authed, pub } from "../orpc";
|
|
10
|
+
import {
|
|
11
|
+
blocks,
|
|
12
|
+
environments,
|
|
13
|
+
layouts,
|
|
14
|
+
organizationTable,
|
|
15
|
+
pages,
|
|
16
|
+
projects,
|
|
17
|
+
repeatableItems,
|
|
18
|
+
} from "../schema";
|
|
11
19
|
|
|
12
20
|
// --- Procedures ---
|
|
13
21
|
|
|
14
22
|
const createProjectSchema = z.object({
|
|
15
23
|
name: z.string(),
|
|
16
|
-
|
|
24
|
+
organizationId: z.string(),
|
|
17
25
|
});
|
|
18
26
|
|
|
19
27
|
const updateProjectSchema = z.object({
|
|
@@ -21,23 +29,23 @@ const updateProjectSchema = z.object({
|
|
|
21
29
|
});
|
|
22
30
|
|
|
23
31
|
const list = authed
|
|
24
|
-
.input(z.object({
|
|
32
|
+
.input(z.object({ organizationId: z.string() }))
|
|
25
33
|
.handler(async ({ context, input }) => {
|
|
26
|
-
await assertOrgMembership(context.db, context.user.id, input.
|
|
34
|
+
await assertOrgMembership(context.db, context.user.id, input.organizationId);
|
|
27
35
|
return context.db
|
|
28
36
|
.select()
|
|
29
37
|
.from(projects)
|
|
30
|
-
.where(eq(projects.
|
|
38
|
+
.where(eq(projects.organizationId, input.organizationId));
|
|
31
39
|
});
|
|
32
40
|
|
|
33
41
|
const getFirst = authed
|
|
34
|
-
.input(z.object({
|
|
42
|
+
.input(z.object({ organizationId: z.string() }))
|
|
35
43
|
.handler(async ({ context, input }) => {
|
|
36
|
-
await assertOrgMembership(context.db, context.user.id, input.
|
|
44
|
+
await assertOrgMembership(context.db, context.user.id, input.organizationId);
|
|
37
45
|
const result = await context.db
|
|
38
46
|
.select()
|
|
39
47
|
.from(projects)
|
|
40
|
-
.where(eq(projects.
|
|
48
|
+
.where(eq(projects.organizationId, input.organizationId))
|
|
41
49
|
.limit(1)
|
|
42
50
|
.get();
|
|
43
51
|
if (!result) throw new ORPCError("NOT_FOUND");
|
|
@@ -48,24 +56,36 @@ const getBySlug = authed
|
|
|
48
56
|
.input(z.object({ slug: z.string() }))
|
|
49
57
|
.handler(async ({ context, input }) => {
|
|
50
58
|
const result = await context.db
|
|
51
|
-
.select(
|
|
59
|
+
.select({
|
|
60
|
+
project: projects,
|
|
61
|
+
organizationSlug: organizationTable.slug,
|
|
62
|
+
})
|
|
52
63
|
.from(projects)
|
|
64
|
+
.innerJoin(organizationTable, eq(organizationTable.id, projects.organizationId))
|
|
53
65
|
.where(eq(projects.slug, input.slug))
|
|
54
66
|
.get();
|
|
55
67
|
if (!result) throw new ORPCError("NOT_FOUND");
|
|
56
|
-
await assertOrgMembership(context.db, context.user.id, result.
|
|
57
|
-
return result;
|
|
68
|
+
await assertOrgMembership(context.db, context.user.id, result.project.organizationId);
|
|
69
|
+
return { ...result.project, organizationSlug: result.organizationSlug };
|
|
58
70
|
});
|
|
59
71
|
|
|
60
72
|
const get = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
61
|
-
const result = await context.db
|
|
73
|
+
const result = await context.db
|
|
74
|
+
.select({
|
|
75
|
+
project: projects,
|
|
76
|
+
organizationSlug: organizationTable.slug,
|
|
77
|
+
})
|
|
78
|
+
.from(projects)
|
|
79
|
+
.innerJoin(organizationTable, eq(organizationTable.id, projects.organizationId))
|
|
80
|
+
.where(eq(projects.id, input.id))
|
|
81
|
+
.get();
|
|
62
82
|
if (!result) throw new ORPCError("NOT_FOUND");
|
|
63
|
-
await assertOrgMembership(context.db, context.user.id, result.
|
|
64
|
-
return result;
|
|
83
|
+
await assertOrgMembership(context.db, context.user.id, result.project.organizationId);
|
|
84
|
+
return { ...result.project, organizationSlug: result.organizationSlug };
|
|
65
85
|
});
|
|
66
86
|
|
|
67
87
|
const create = authed.input(createProjectSchema).handler(async ({ context, input }) => {
|
|
68
|
-
await assertOrgMembership(context.db, context.user.id, input.
|
|
88
|
+
await assertOrgMembership(context.db, context.user.id, input.organizationId);
|
|
69
89
|
|
|
70
90
|
const slug = await generateUniqueSlug(context.db);
|
|
71
91
|
const syncSecret = crypto.randomUUID();
|
|
@@ -77,7 +97,7 @@ const create = authed.input(createProjectSchema).handler(async ({ context, input
|
|
|
77
97
|
name: input.name,
|
|
78
98
|
slug,
|
|
79
99
|
syncSecret,
|
|
80
|
-
|
|
100
|
+
organizationId: input.organizationId,
|
|
81
101
|
createdAt: now,
|
|
82
102
|
updatedAt: now,
|
|
83
103
|
})
|
|
@@ -131,6 +151,7 @@ const repeatableItemSeedSchema = z.object({
|
|
|
131
151
|
|
|
132
152
|
const initializeContentSchema = z.object({
|
|
133
153
|
projectSlug: z.string(),
|
|
154
|
+
syncSecret: z.string(),
|
|
134
155
|
layoutId: z.string(),
|
|
135
156
|
blocks: z.array(
|
|
136
157
|
z.object({
|
|
@@ -142,118 +163,111 @@ const initializeContentSchema = z.object({
|
|
|
142
163
|
),
|
|
143
164
|
});
|
|
144
165
|
|
|
145
|
-
const initializeContent =
|
|
146
|
-
.input
|
|
147
|
-
.handler(async ({ context, input }) => {
|
|
148
|
-
const project = await context.db
|
|
149
|
-
.select()
|
|
150
|
-
.from(projects)
|
|
151
|
-
.where(eq(projects.slug, input.projectSlug))
|
|
152
|
-
.get();
|
|
153
|
-
if (!project) throw new ORPCError("NOT_FOUND");
|
|
166
|
+
const initializeContent = pub.input(initializeContentSchema).handler(async ({ context, input }) => {
|
|
167
|
+
const project = await assertSyncSecret(context.db, input.projectSlug, input.syncSecret);
|
|
154
168
|
|
|
155
|
-
|
|
169
|
+
const environment = await resolveEnvironment(context.db, project.id, context.environmentName);
|
|
156
170
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
171
|
+
// Check if environment already has pages — if so, skip (idempotent)
|
|
172
|
+
const existingPage = await context.db
|
|
173
|
+
.select()
|
|
174
|
+
.from(pages)
|
|
175
|
+
.where(eq(pages.environmentId, environment.id))
|
|
176
|
+
.limit(1)
|
|
177
|
+
.get();
|
|
178
|
+
if (existingPage) {
|
|
179
|
+
return { created: false };
|
|
180
|
+
}
|
|
167
181
|
|
|
168
|
-
|
|
182
|
+
const now = Date.now();
|
|
169
183
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
// Find the specified layout
|
|
185
|
+
const layout = await context.db
|
|
186
|
+
.select()
|
|
187
|
+
.from(layouts)
|
|
188
|
+
.where(
|
|
189
|
+
and(
|
|
190
|
+
eq(layouts.projectId, project.id),
|
|
191
|
+
eq(layouts.environmentId, environment.id),
|
|
192
|
+
eq(layouts.layoutId, input.layoutId),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
.get();
|
|
196
|
+
if (!layout) {
|
|
197
|
+
return { created: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Create homepage
|
|
201
|
+
const homepage = await context.db
|
|
202
|
+
.insert(pages)
|
|
203
|
+
.values({
|
|
204
|
+
projectId: project.id,
|
|
205
|
+
environmentId: environment.id,
|
|
206
|
+
pathSegment: "",
|
|
207
|
+
fullPath: "/",
|
|
208
|
+
layoutId: layout.id,
|
|
209
|
+
metaTitle: "Untitled page",
|
|
210
|
+
metaDescription:
|
|
211
|
+
"Title and description will be generated by AI as you edit the page's content.",
|
|
212
|
+
createdAt: now,
|
|
213
|
+
updatedAt: now,
|
|
214
|
+
})
|
|
215
|
+
.returning()
|
|
216
|
+
.get();
|
|
185
217
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
218
|
+
// Create blocks on the homepage
|
|
219
|
+
let prevPosition: string | null = null;
|
|
220
|
+
let blockCount = 0;
|
|
221
|
+
|
|
222
|
+
for (const blockDef of input.blocks) {
|
|
223
|
+
const position = generateKeyBetween(prevPosition, null);
|
|
224
|
+
prevPosition = position;
|
|
225
|
+
|
|
226
|
+
const block = await context.db
|
|
227
|
+
.insert(blocks)
|
|
189
228
|
.values({
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
metaDescription:
|
|
197
|
-
"Title and description will be generated by AI as you edit the page's content.",
|
|
229
|
+
pageId: homepage.id,
|
|
230
|
+
type: blockDef.type,
|
|
231
|
+
content: blockDef.content,
|
|
232
|
+
settings: blockDef.settings ?? null,
|
|
233
|
+
position,
|
|
234
|
+
summary: "",
|
|
198
235
|
createdAt: now,
|
|
199
236
|
updatedAt: now,
|
|
200
237
|
})
|
|
201
238
|
.returning()
|
|
202
239
|
.get();
|
|
203
240
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
const itemSeeds = blockDef.repeatableItems;
|
|
228
|
-
if (itemSeeds && itemSeeds.length > 0) {
|
|
229
|
-
const tempIdToRealId = new Map<string, number>();
|
|
230
|
-
for (const seed of itemSeeds) {
|
|
231
|
-
const parentItemId = seed.parentTempId
|
|
232
|
-
? (tempIdToRealId.get(seed.parentTempId) ?? null)
|
|
233
|
-
: null;
|
|
234
|
-
const inserted = await context.db
|
|
235
|
-
.insert(repeatableItems)
|
|
236
|
-
.values({
|
|
237
|
-
blockId: block.id,
|
|
238
|
-
parentItemId,
|
|
239
|
-
fieldName: seed.fieldName,
|
|
240
|
-
content: seed.content,
|
|
241
|
-
summary: "",
|
|
242
|
-
position: seed.position,
|
|
243
|
-
createdAt: now,
|
|
244
|
-
updatedAt: now,
|
|
245
|
-
})
|
|
246
|
-
.returning()
|
|
247
|
-
.get();
|
|
248
|
-
tempIdToRealId.set(seed.tempId, inserted.id);
|
|
249
|
-
}
|
|
241
|
+
const itemSeeds = blockDef.repeatableItems;
|
|
242
|
+
if (itemSeeds && itemSeeds.length > 0) {
|
|
243
|
+
const tempIdToRealId = new Map<string, number>();
|
|
244
|
+
for (const seed of itemSeeds) {
|
|
245
|
+
const parentItemId = seed.parentTempId
|
|
246
|
+
? (tempIdToRealId.get(seed.parentTempId) ?? null)
|
|
247
|
+
: null;
|
|
248
|
+
const inserted = await context.db
|
|
249
|
+
.insert(repeatableItems)
|
|
250
|
+
.values({
|
|
251
|
+
blockId: block.id,
|
|
252
|
+
parentItemId,
|
|
253
|
+
fieldName: seed.fieldName,
|
|
254
|
+
content: seed.content,
|
|
255
|
+
summary: "",
|
|
256
|
+
position: seed.position,
|
|
257
|
+
createdAt: now,
|
|
258
|
+
updatedAt: now,
|
|
259
|
+
})
|
|
260
|
+
.returning()
|
|
261
|
+
.get();
|
|
262
|
+
tempIdToRealId.set(seed.tempId, inserted.id);
|
|
250
263
|
}
|
|
251
|
-
|
|
252
|
-
blockCount++;
|
|
253
264
|
}
|
|
254
265
|
|
|
255
|
-
|
|
256
|
-
}
|
|
266
|
+
blockCount++;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { created: true, pageId: homepage.id, blockCount };
|
|
270
|
+
});
|
|
257
271
|
|
|
258
272
|
export const projectProcedures = {
|
|
259
273
|
list,
|
package/src/schema.ts
CHANGED
|
@@ -97,13 +97,15 @@ export const projects = sqliteTable(
|
|
|
97
97
|
slug: text().notNull(),
|
|
98
98
|
name: text().notNull(),
|
|
99
99
|
syncSecret: text("sync_secret").notNull().default(""),
|
|
100
|
-
|
|
100
|
+
organizationId: text("organization_id")
|
|
101
|
+
.notNull()
|
|
102
|
+
.references(() => organizationTable.id),
|
|
101
103
|
createdAt: int("created_at").notNull(),
|
|
102
104
|
updatedAt: int("updated_at").notNull(),
|
|
103
105
|
},
|
|
104
106
|
(table) => [
|
|
105
107
|
uniqueIndex("projects_slug_idx").on(table.slug),
|
|
106
|
-
index("projects_organization_idx").on(table.
|
|
108
|
+
index("projects_organization_idx").on(table.organizationId),
|
|
107
109
|
],
|
|
108
110
|
);
|
|
109
111
|
|
package/src/types.ts
CHANGED
|
@@ -5,7 +5,6 @@ import type { Auth } from "./routes/auth";
|
|
|
5
5
|
export type Bindings = {
|
|
6
6
|
DB: D1Database;
|
|
7
7
|
BETTER_AUTH_SECRET: string;
|
|
8
|
-
BETTER_AUTH_URL: string;
|
|
9
8
|
GITHUB_CLIENT_ID: string;
|
|
10
9
|
GITHUB_CLIENT_SECRET: string;
|
|
11
10
|
GOOGLE_CLIENT_ID: string;
|
|
@@ -15,7 +14,6 @@ export type Bindings = {
|
|
|
15
14
|
AI_JOB_SCHEDULER: DurableObjectNamespace;
|
|
16
15
|
ProjectRoom: DurableObjectNamespace;
|
|
17
16
|
FILES_BUCKET: R2Bucket;
|
|
18
|
-
SYNC_SECRET: string;
|
|
19
17
|
};
|
|
20
18
|
|
|
21
19
|
export type AppEnv = {
|