Merge branch 'master' into cli-improvements-1

This commit is contained in:
RevIQ
2026-01-09 17:59:33 +08:00
21 changed files with 667 additions and 178 deletions

View File

@@ -1,11 +1,11 @@
{
"name": "api-server",
"name": "@reviq/api-server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist",
"build": "bun build src/index.ts --compile --outfile dist/api-server",
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",

View File

@@ -112,36 +112,43 @@ export const createAuthMiddleware = () => {
isSuperuser: user.is_superuser,
};
const sessionInfo: Session = session
? {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
}
: {
// For API token auth, create a synthetic session object
// We know apiToken exists because userId came from it
id: "0",
trustedMode: true,
createdAt: apiToken?.created_at ?? new Date(),
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
// Build auth info based on authentication method
const authInfo: AuthInfo = session
? {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
}
: {
method: "api_token",
tokenId: apiToken?.id,
tokenName: apiToken?.name,
expiresAt: apiToken?.expires_at,
lastUsedAt: apiToken?.last_used_at,
createdAt: apiToken?.created_at,
};
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {

View File

@@ -0,0 +1,43 @@
/**
* Admin routes - consolidated exports for os.router()
*/
import { adminAuthCompleteLogin } from "./auth/complete-login.js";
import { adminOrgsCreate } from "./orgs/create.js";
import { adminOrgsDelete } from "./orgs/delete.js";
import { adminOrgsGet } from "./orgs/get.js";
import { adminOrgsList } from "./orgs/list.js";
import {
adminOrgsAddSite,
adminOrgsListSites,
adminOrgsRemoveSite,
} from "./orgs/sites.js";
import { adminOrgsUpdate } from "./orgs/update.js";
import { adminUsersConfirmEmail } from "./users/confirm-email.js";
import { adminUsersCreate } from "./users/create.js";
import { adminUsersGet } from "./users/get.js";
import { adminUsersList } from "./users/list.js";
import { adminUsersUpdate } from "./users/update.js";
export const adminRoutes = {
orgs: {
list: adminOrgsList,
get: adminOrgsGet,
create: adminOrgsCreate,
update: adminOrgsUpdate,
delete: adminOrgsDelete,
listSites: adminOrgsListSites,
addSite: adminOrgsAddSite,
removeSite: adminOrgsRemoveSite,
},
users: {
list: adminUsersList,
get: adminUsersGet,
create: adminUsersCreate,
update: adminUsersUpdate,
confirmEmail: adminUsersConfirmEmail,
},
auth: {
completeLogin: adminAuthCompleteLogin,
},
};

View File

@@ -0,0 +1,32 @@
/**
* admin.auth.completeLogin - Complete pending login request (dev helper)
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const loginRequest = await context.db
.selectFrom("login_requests")
.where("email", "=", input.email.toLowerCase())
.where("completed_at", "is", null)
.where("expires_at", ">", new Date())
.orderBy("created_at", "desc")
.select(["id"])
.executeTakeFirst();
if (!loginRequest) {
throw new ORPCError("NOT_FOUND", {
message: "No pending login request found",
});
}
await context.db
.updateTable("login_requests")
.set({ completed_at: new Date() })
.where("id", "=", loginRequest.id)
.execute();
});

View File

@@ -0,0 +1,35 @@
/**
* Admin procedure helpers - shared transformation functions
*/
import type { OrgSites, Orgs, Users } from "@reviq/db-schema";
import type { Selectable } from "kysely";
/** Transform org record to API response format */
export const toOrgResponse = (org: Selectable<Orgs>) => ({
id: org.id,
slug: org.slug,
displayName: org.display_name,
logoUrl: org.logo_url,
createdAt: org.created_at,
});
/** Transform user record to API response format */
export const toUserResponse = (user: Selectable<Users>) => ({
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
});
/** Transform site record to API response format */
export const toSiteResponse = (site: Selectable<OrgSites>) => ({
id: site.id,
domain: site.domain,
createdAt: site.created_at,
});

View File

@@ -0,0 +1,58 @@
/**
* admin.orgs.create - Create organization with owner
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminOrgsCreate = os.admin.orgs.create
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug, displayName, ownerEmail } = input;
// Find owner user by email (outside transaction - read-only)
const owner = await context.db
.selectFrom("users")
.where("email", "=", ownerEmail.toLowerCase())
.select(["id"])
.executeTakeFirst();
if (!owner) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
// Create org and owner membership in transaction (with race condition protection)
await context.db.transaction().execute(async (trx) => {
// Check for existing org inside transaction to prevent race condition
const existingOrg = await trx
.selectFrom("orgs")
.where("slug", "=", slug)
.select(["id"])
.executeTakeFirst();
if (existingOrg) {
throw new ORPCError("CONFLICT", {
message: "Organization with this slug already exists",
});
}
const newOrg = await trx
.insertInto("orgs")
.values({
slug,
display_name: displayName,
})
.returning(["id"])
.executeTakeFirstOrThrow();
await trx
.insertInto("org_members")
.values({
org_id: newOrg.id,
user_id: owner.id,
role: "owner",
})
.execute();
});
return { slug };
});

View File

@@ -0,0 +1,36 @@
/**
* admin.orgs.delete - Delete organization and all related records
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminOrgsDelete = os.admin.orgs.delete
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug } = input;
// Delete org and related records in transaction
await context.db.transaction().execute(async (trx) => {
const org = await trx
.selectFrom("orgs")
.where("slug", "=", slug)
.select(["id"])
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
await trx
.deleteFrom("org_invites")
.where("org_id", "=", org.id)
.execute();
await trx.deleteFrom("org_sites").where("org_id", "=", org.id).execute();
await trx
.deleteFrom("org_members")
.where("org_id", "=", org.id)
.execute();
await trx.deleteFrom("orgs").where("id", "=", org.id).execute();
});
});

View File

@@ -0,0 +1,22 @@
/**
* admin.orgs.get - Get organization by slug
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { toOrgResponse } from "../helpers.js";
export const adminOrgsGet = os.admin.orgs.get
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const org = await context.db
.selectFrom("orgs")
.where("slug", "=", input.slug)
.selectAll()
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
return toOrgResponse(org);
});

View File

@@ -0,0 +1,14 @@
/**
* admin.orgs.list - List all organizations
*/
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { toOrgResponse } from "../helpers.js";
export const adminOrgsList = os.admin.orgs.list
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ context }) => {
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
return orgs.map(toOrgResponse);
});

View File

@@ -0,0 +1,97 @@
/**
* admin.orgs.listSites, admin.orgs.addSite, admin.orgs.removeSite
* Site management for organizations
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { toSiteResponse } from "../helpers.js";
export const adminOrgsListSites = os.admin.orgs.listSites
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug } = input;
const org = await context.db
.selectFrom("orgs")
.where("slug", "=", slug)
.select(["id"])
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
const sites = await context.db
.selectFrom("org_sites")
.where("org_id", "=", org.id)
.selectAll()
.execute();
return sites.map(toSiteResponse);
});
export const adminOrgsAddSite = os.admin.orgs.addSite
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug, domain } = input;
// Use transaction to prevent race condition on site creation
await context.db.transaction().execute(async (trx) => {
const org = await trx
.selectFrom("orgs")
.where("slug", "=", slug)
.select(["id"])
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
// Check if site already exists (inside transaction)
const existingSite = await trx
.selectFrom("org_sites")
.where("domain", "=", domain)
.select(["id"])
.executeTakeFirst();
if (existingSite) {
throw new ORPCError("CONFLICT", {
message: "Site with this domain already exists",
});
}
await trx
.insertInto("org_sites")
.values({
org_id: org.id,
domain,
})
.execute();
});
});
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug, domain } = input;
const org = await context.db
.selectFrom("orgs")
.where("slug", "=", slug)
.select(["id"])
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
const result = await context.db
.deleteFrom("org_sites")
.where("org_id", "=", org.id)
.where("domain", "=", domain)
.executeTakeFirst();
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
}
});

View File

@@ -0,0 +1,50 @@
/**
* admin.orgs.update - Update organization
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminOrgsUpdate = os.admin.orgs.update
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { slug, displayName, logoUrl } = input;
// Check if there are actual updates to make
if (displayName === undefined && logoUrl === undefined) {
// Verify org exists even for no-op
const org = await context.db
.selectFrom("orgs")
.where("slug", "=", slug)
.select(["id"])
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
return;
}
const updates: Partial<{
display_name: string;
logo_url: string | null;
updated_at: Date;
}> = { updated_at: new Date() };
if (displayName !== undefined) {
updates.display_name = displayName;
}
if (logoUrl !== undefined) {
updates.logo_url = logoUrl || null;
}
const result = await context.db
.updateTable("orgs")
.set(updates)
.where("slug", "=", slug)
.executeTakeFirst();
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
});

View File

@@ -0,0 +1,24 @@
/**
* admin.users.confirmEmail - Confirm a user's email (used by CLI)
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const result = await context.db
.updateTable("users")
.set({
email_verified_at: new Date(),
updated_at: new Date(),
})
.where("email", "=", input.email.toLowerCase())
.executeTakeFirst();
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
});

View File

@@ -0,0 +1,63 @@
/**
* admin.users.create - Create passwordless user, optionally add to org
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminUsersCreate = os.admin.users.create
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { email, name, orgSlug, orgRole } = input;
const normalizedEmail = email.toLowerCase();
// If orgSlug provided, verify org exists (outside transaction - read-only)
let orgId: number | undefined;
if (orgSlug) {
const org = await context.db
.selectFrom("orgs")
.where("slug", "=", orgSlug)
.select(["id"])
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
orgId = org.id;
}
// Create user in transaction (with race condition protection)
await context.db.transaction().execute(async (trx) => {
// Check for existing user inside transaction to prevent race condition
const existingUser = await trx
.selectFrom("users")
.where("email", "=", normalizedEmail)
.select(["id"])
.executeTakeFirst();
if (existingUser) {
throw new ORPCError("CONFLICT", {
message: "User with this email already exists",
});
}
const newUser = await trx
.insertInto("users")
.values({
email: normalizedEmail,
display_name: name ?? null,
})
.returning(["id"])
.executeTakeFirstOrThrow();
if (orgId !== undefined) {
await trx
.insertInto("org_members")
.values({
org_id: orgId,
user_id: newUser.id,
role: orgRole ?? "member",
})
.execute();
}
});
});

View File

@@ -0,0 +1,22 @@
/**
* admin.users.get - Get user by email
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { toUserResponse } from "../helpers.js";
export const adminUsersGet = os.admin.users.get
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const user = await context.db
.selectFrom("users")
.where("email", "=", input.email.toLowerCase())
.selectAll()
.executeTakeFirst();
if (!user) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
return toUserResponse(user);
});

View File

@@ -0,0 +1,14 @@
/**
* admin.users.list - List all users
*/
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
import { toUserResponse } from "../helpers.js";
export const adminUsersList = os.admin.users.list
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ context }) => {
const users = await context.db.selectFrom("users").selectAll().execute();
return users.map(toUserResponse);
});

View File

@@ -0,0 +1,48 @@
/**
* admin.users.update - Update user properties (e.g., isSuperuser)
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
export const adminUsersUpdate = os.admin.users.update
.use(authMiddleware)
.use(superuserMiddleware)
.handler(async ({ input, context }) => {
const { email, isSuperuser } = input;
const normalizedEmail = email.toLowerCase();
// Check if there are actual updates to make
if (isSuperuser === undefined) {
// Verify user exists even for no-op
const user = await context.db
.selectFrom("users")
.where("email", "=", normalizedEmail)
.select(["id"])
.executeTakeFirst();
if (!user) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
return;
}
// Prevent superuser from demoting themselves
if (!isSuperuser && normalizedEmail === context.user.email.toLowerCase()) {
throw new ORPCError("BAD_REQUEST", {
message: "Cannot remove your own superuser status",
});
}
const result = await context.db
.updateTable("users")
.set({
is_superuser: isSuperuser,
updated_at: new Date(),
})
.where("email", "=", normalizedEmail)
.executeTakeFirst();
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
});

View File

@@ -110,35 +110,43 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
isSuperuser: user.is_superuser,
};
const sessionInfo: Session = session
? {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
}
: {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken?.created_at ?? new Date(),
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
// Build auth info based on authentication method
const authInfo: AuthInfo = session
? {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
}
: {
method: "api_token",
tokenId: apiToken?.id,
tokenName: apiToken?.name,
expiresAt: apiToken?.expires_at,
lastUsedAt: apiToken?.last_used_at,
createdAt: apiToken?.created_at,
};
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {

View File

@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { adminRoutes } from "./procedures/admin/_routes.js";
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js";
@@ -241,91 +242,6 @@ const setupProfile = os.me.setupProfile
// - invitesList, invitesCreate, invitesCancel, invitesAccept
// - sitesList
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
const adminOrgsList = os.admin.orgs.list
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsGet = os.admin.orgs.get.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsCreate = os.admin.orgs.create
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsUpdate = os.admin.orgs.update
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsDelete = os.admin.orgs.delete
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsListSites = os.admin.orgs.listSites
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsAddSite = os.admin.orgs.addSite
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminOrgsRemoveSite = os.admin.orgs.removeSite
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Admin users procedures
const adminUsersList = os.admin.users.list
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminUsersGet = os.admin.users.get
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminUsersCreate = os.admin.users.create
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminUsersUpdate = os.admin.users.update
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const adminUsersConfirmEmail = os.admin.users.confirmEmail
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Admin auth procedures
const adminAuthCompleteLogin = os.admin.auth.completeLogin
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Build the router
export const router = os.router({
auth: {
@@ -389,26 +305,5 @@ export const router = os.router({
list: sitesList,
},
},
admin: {
orgs: {
list: adminOrgsList,
get: adminOrgsGet,
create: adminOrgsCreate,
update: adminOrgsUpdate,
delete: adminOrgsDelete,
listSites: adminOrgsListSites,
addSite: adminOrgsAddSite,
removeSite: adminOrgsRemoveSite,
},
users: {
list: adminUsersList,
get: adminUsersGet,
create: adminUsersCreate,
update: adminUsersUpdate,
confirmEmail: adminUsersConfirmEmail,
},
auth: {
completeLogin: adminAuthCompleteLogin,
},
},
admin: adminRoutes,
});

View File

@@ -4,11 +4,11 @@
"private": true,
"type": "module",
"bin": {
"reviq": "./dist/index.js",
"__reviq_bash_complete": "./dist/bash-complete.js"
"reviq": "./dist/reviq",
"__reviq_bash_complete": "./dist/bash-complete"
},
"scripts": {
"build": "bun build src/bin/reviq.ts --outfile dist/index.js --target bun && bun build src/bin/bash-complete.ts --outfile dist/bash-complete.js --target bun",
"build": "bun build src/bin/reviq.ts --compile --outfile dist/reviq && bun build src/bin/bash-complete.ts --compile --outfile dist/bash-complete",
"cli": "bun run src/bin/reviq.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",

View File

@@ -12,7 +12,7 @@
},
},
"apps/api-server": {
"name": "api-server",
"name": "@reviq/api-server",
"version": "0.0.0",
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
@@ -372,6 +372,8 @@
"@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"],
"@reviq/api-server": ["@reviq/api-server@workspace:apps/api-server"],
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
"@reviq/db": ["@reviq/db@workspace:packages/db"],
@@ -538,8 +540,6 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"api-server": ["api-server@workspace:apps/api-server"],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],

View File

@@ -76,6 +76,15 @@ publisher-dashboard/
│ │ ├── index.ts # Server entry point (Bun.serve)
│ │ ├── router.ts
│ │ ├── procedures/
│ │ │ ├── base.ts # Middleware (auth, superuser, loginRequest)
│ │ │ ├── auth/ # Auth procedures
│ │ │ ├── me/ # User self-management procedures
│ │ │ └── admin/ # Superuser-only procedures
│ │ │ ├── _routes.ts # Consolidated admin route exports
│ │ │ ├── helpers.ts # Shared transform functions
│ │ │ ├── auth/
│ │ │ ├── orgs/
│ │ │ └── users/
│ │ └── middleware/
│ ├── publisher-dashboard/ # SvelteKit frontend
│ │ ├── package.json
@@ -2306,12 +2315,18 @@ _Can run parallel to H after F1 is done_
_Depends on: D1 (auth middleware)_
- [ ] **J1**: Implement org middleware (slug lookup, membership check)
- [ ] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
- [ ] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
- [ ] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
- [ ] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
- [ ] **J6**: Implement `orgs.sites.list`
- [x] **J1**: Implement org middleware (slug lookup, membership check)
- [x] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
- [x] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
- [x] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
- [x] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
- [x] **J6**: Implement `orgs.sites.list`
_Implementation notes:_
- Files in `procedures/orgs/` with `index.ts` for consolidated exports
- Helper functions in `helpers.ts`: `lookupOrgBySlug`, `getMembership`, `requireRole`, `countOwners`
- Race conditions prevented via Kysely transactions for owner count checks
- Privilege escalation prevented: only owners can invite new owners
#### Workstream K: Admin Procedures (Backend)
@@ -2324,6 +2339,12 @@ _Can run parallel to J2-J6_
- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
- [x] **K5**: Implement `admin.auth.completeLogin` (dev helper)
_Implementation notes:_
- Files in `procedures/admin/` with `_routes.ts` for consolidated exports
- Helper functions in `helpers.ts`: `toOrgResponse`, `toUserResponse`, `toSiteResponse`
- Race conditions prevented via transaction-scoped existence checks
- Self-demotion guard in `adminUsersUpdate` prevents superusers from removing their own status
#### Workstream L: Org Pages (Frontend)
_Depends on: J1-J6, C3_