Adding a feature
The todo feature is the reference. Add a new resource the same way, one layer at a time. Names below keep the @taman/ scope; substitute yours.
Permissions (packages/shared/rbac)
Add the resource to sharedStatements in rbac.shared.ts. Tuples must stay as const so better-auth infers action names:
export const sharedStatements = {
todo: ['create', 'read', 'update', 'delete'],
invoice: ['create', 'read', 'update', 'delete'],
} as const;
Grant those actions on the roles that should have them:
- Platform:
rbac.admin.ts(adminRolealready grantstodo: […];userRoledoes not). - Organization:
rbac.organizations.ts(ownerandmemberboth get full todo CRUD). The default organizationadminrole is dropped —rolesreplaces better-auth defaults.
Ownership ("this row is yours") is not expressed here. Check that in the service.
Database schema (packages/server/db-pg)
Add a table next to src/schemas/todo.schema.ts. Export it from src/schemas/index.ts.
Todos are org-scoped:
organizationId: uuid('organization_id')
.notNull()
.references(() => organizationTable.id, { onDelete: 'cascade' }),
Then pnpm db-pg:migrate:dev (drizzle-kit push in local dev).
Contract (packages/shared/api-contract)
Add src/<feature>/<feature>.schema.ts (Zod) and <feature>.contract.ts (oc.input / oc.output). Mount it on the root contract in src/index.ts:
export const contract = {
menu: menuContract,
todo: todoContract,
};
Typed errors belong on the contract (oc.errors({ NOT_FOUND: { message: '…' } })). Streams use eventIterator.
Repo and service (apps/api/server/domains/<feature>/)
- Repo — drizzle queries only (
todo.repo.ts). Fake repo + unit tests exist as the pattern (todo.repo.fake.ts). - Service — extends
CoreServiceand defines its own privateauthorize(action)that requires a membership, checksthis.can({ <feature>: [action] }), and returnsmember.organizationId(copy it fromTodoService;CoreServicedoes not provide it). Scope every query by that ID and map rows to contract types. Missing rows returnnull; do not throwNOT_FOUNDhere.
Publishing realtime events stays in the service (publisher.publish(todoChannel(orgId), event)).
RPC procedure (apps/api/server/rpc/procedures/)
const todo = os.todo.use(authed);
export const todoRouter = {
get: todo.get.handler(async ({ context, input, errors }) => {
const found = await (await todoService(context)).get(input.id);
if (!found) {
throw errors.NOT_FOUND();
}
return found;
}),
// list, create, update, remove, live
};
Register the router in server/rpc/router.ts. authed enforces a session, and an active organization for non-admins. Platform admins pass authed without one, so the service's authorize is what keeps their queries scoped.
Web route and view
apps/web/src/router/routes/modules/<feature>.ts— default-export aRouteRecordRaw[]. Files inmodules/*.ts(not nested) are eager-loaded. Setmeta.icon,meta.order(ROUTE_ORDERinroutes.constants.ts),meta.titlevia$t(...).- Views under
apps/web/src/views/<feature>/.generateAccessmapsimport.meta.glob('../views/**/*.vue'). - Call the API with
orpc/clientfromsrc/api/orpc.ts.
import { useQuery } from '@tanstack/vue-query';
import { orpc } from '#/api/orpc';
const query = useQuery(orpc.todo.list.queryOptions({
input: { page: 1, pageSize: 20 },
}));
i18n
Add JSON under apps/web/src/locales/langs/en-US/<feature>.json and id-ID/. Keys are loaded by loadLocalesMapFromDir. Use $t('todo.title') in route meta and views.
Checklist
-
sharedStatements+ role grants - drizzle table, exported, pushed
- contract schemas + procedures on the root contract
- repo → service →
os.<feature>.use(authed)handlers - router module + views
- locale files
- unit tests beside repo/service/procedure (todo has them)
What generate strips
The example gallery (apps/web/src/views/examples/** and routes/modules/dev/examples.ts) is not in a generated project. Copy patterns from todo, not from those demos, unless you are working in the source repo.