@tatara/cli 0.1.0 → 0.1.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/dist/bin.mjs +172 -81
- package/package.json +1 -1
- package/templates/features/authz/files/src/core/authz/constants.ts +4 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +5 -5
- package/templates/features/authz/files/src/core/authz/services/members.ts +17 -1
- package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
- package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
- package/templates/features/authz/files/src/core/authz/types.ts +2 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +13 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +7 -0
- package/templates/features/base/files/AGENTS.md +2 -0
- package/templates/features/base/files/Dockerfile +1 -0
- package/templates/features/base/files/_github/workflows/ci.yml +10 -0
- package/templates/features/base/files/src/core/commands/define.ts +9 -1
- package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
- package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
- package/templates/features/base/files/src/core/http/app.ts +6 -1
- package/templates/features/base/files/src/core/http/errors.ts +40 -12
- package/templates/features/base/files/src/core/http/middleware/validation.ts +1 -1
- package/templates/features/base/files/src/core/http/response.ts +5 -0
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +18 -1
- package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
- package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
- package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
- package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +2 -2
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +2 -2
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +3 -2
package/dist/bin.mjs
CHANGED
|
@@ -90,6 +90,36 @@ function loadProject(from = process.cwd()) {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
//#endregion
|
|
93
|
+
//#region src/commands/route-mounts.ts
|
|
94
|
+
const ROUTE_FILE = /\/routes\/[^/]+\.routes\.ts$/;
|
|
95
|
+
const DEFINE_ROUTES = /defineRoutes\(\s*\{([^}]*)\}/;
|
|
96
|
+
function lineOf$1(text, index) {
|
|
97
|
+
return text.slice(0, index).split("\n").length;
|
|
98
|
+
}
|
|
99
|
+
/** Route files that mount on an app + basePath already taken by an earlier file (sorted by path). */
|
|
100
|
+
function routeMountCollisions(sources) {
|
|
101
|
+
const owners = /* @__PURE__ */ new Map();
|
|
102
|
+
const violations = [];
|
|
103
|
+
for (const file of [...sources.keys()].sort()) {
|
|
104
|
+
if (!ROUTE_FILE.test(file)) continue;
|
|
105
|
+
const text = sources.get(file) ?? "";
|
|
106
|
+
const call = DEFINE_ROUTES.exec(text);
|
|
107
|
+
const app = call && /\bapp:\s*["']([^"']+)["']/.exec(call[1] ?? "")?.[1];
|
|
108
|
+
const basePath = call && /\bbasePath:\s*["']([^"']+)["']/.exec(call[1] ?? "")?.[1];
|
|
109
|
+
if (!call || !app || !basePath) continue;
|
|
110
|
+
const key = `${app} "${basePath}"`;
|
|
111
|
+
const owner = owners.get(key);
|
|
112
|
+
if (owner) violations.push({
|
|
113
|
+
file,
|
|
114
|
+
line: lineOf$1(text, call.index),
|
|
115
|
+
rule: "duplicate-base-path",
|
|
116
|
+
message: `${key} is already mounted by ${owner}; routers on one base path shadow each other, so give this one its own basePath`
|
|
117
|
+
});
|
|
118
|
+
else owners.set(key, file);
|
|
119
|
+
}
|
|
120
|
+
return violations;
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
93
123
|
//#region src/commands/check.ts
|
|
94
124
|
const IMPORT = /(?:import|export)\s[^"']*?from\s*["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
|
|
95
125
|
function lineOf(text, index) {
|
|
@@ -150,6 +180,7 @@ async function check(project = loadProject()) {
|
|
|
150
180
|
});
|
|
151
181
|
}
|
|
152
182
|
}
|
|
183
|
+
violations.push(...routeMountCollisions(sources));
|
|
153
184
|
return violations.filter((v) => !ignored(v, sources.get(v.file) ?? ""));
|
|
154
185
|
}
|
|
155
186
|
/** `// tatara-check-ignore <rule>: <reason>` on the line above a violation silences it. */
|
|
@@ -835,7 +866,7 @@ const storage = {
|
|
|
835
866
|
env: [
|
|
836
867
|
{
|
|
837
868
|
key: "STORAGE_DRIVER",
|
|
838
|
-
schema: "z.enum([\"local\", \"s3\"])
|
|
869
|
+
schema: "localDefault(z.enum([\"local\", \"s3\"]), \"local\")",
|
|
839
870
|
example: "s3",
|
|
840
871
|
test: "local"
|
|
841
872
|
},
|
|
@@ -905,20 +936,29 @@ const storage = {
|
|
|
905
936
|
}
|
|
906
937
|
} },
|
|
907
938
|
composeVolumes: ["storage-data"],
|
|
908
|
-
slots: [
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
939
|
+
slots: [
|
|
940
|
+
{
|
|
941
|
+
file: "Dockerfile",
|
|
942
|
+
slot: "docker-runtime",
|
|
943
|
+
order: 50,
|
|
944
|
+
content: "RUN mkdir -p .storage && chown node:node .storage"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
file: ".gitignore",
|
|
948
|
+
slot: "ignore",
|
|
949
|
+
content: ".storage/"
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
file: "AGENTS.md",
|
|
953
|
+
slot: "sections",
|
|
954
|
+
content: `## Storage
|
|
916
955
|
|
|
917
956
|
- Use \`storage\` from \`#core/storage/index\` (\`put\`, \`get\`, \`delete\`, \`url\`). \`STORAGE_DRIVER=s3\` works with
|
|
918
957
|
any S3-compatible store (RustFS locally, AWS S3, R2); \`local\` writes to \`STORAGE_LOCAL_DIR\` (tests).
|
|
919
958
|
- \`tatara run storage:check\` creates the bucket and does a round-trip.
|
|
920
959
|
`
|
|
921
|
-
|
|
960
|
+
}
|
|
961
|
+
]
|
|
922
962
|
};
|
|
923
963
|
const email = {
|
|
924
964
|
id: "email",
|
|
@@ -929,13 +969,13 @@ const email = {
|
|
|
929
969
|
env: [
|
|
930
970
|
{
|
|
931
971
|
key: "EMAIL_DRIVER",
|
|
932
|
-
schema: "z.enum([\"smtp\", \"console\"])
|
|
972
|
+
schema: "localDefault(z.enum([\"smtp\", \"console\"]), \"console\")",
|
|
933
973
|
example: "smtp",
|
|
934
974
|
test: "console"
|
|
935
975
|
},
|
|
936
976
|
{
|
|
937
977
|
key: "SMTP_URL",
|
|
938
|
-
schema: "z.string()
|
|
978
|
+
schema: "localDefault(z.string(), \"smtp://localhost:1025\")",
|
|
939
979
|
example: "smtp://localhost:1025"
|
|
940
980
|
},
|
|
941
981
|
{
|
|
@@ -1009,7 +1049,7 @@ const i18n = {
|
|
|
1009
1049
|
{
|
|
1010
1050
|
file: "src/core/http/middleware/error-handler.ts",
|
|
1011
1051
|
slot: "error-message",
|
|
1012
|
-
content: "message = t(c.get(\"language\"), `errors.${err.code}`, message);"
|
|
1052
|
+
content: "if (err.translatable) message = t(c.get(\"language\"), `errors.${err.code}`, message);"
|
|
1013
1053
|
},
|
|
1014
1054
|
{
|
|
1015
1055
|
file: "AGENTS.md",
|
|
@@ -1067,7 +1107,8 @@ const auth = {
|
|
|
1067
1107
|
};
|
|
1068
1108
|
const BETTER_AUTH_AGENTS = `### better-auth
|
|
1069
1109
|
|
|
1070
|
-
- Endpoints are served at \`/api/auth
|
|
1110
|
+
- Endpoints are served at \`/api/auth/*\` and listed in \`/openapi.json\` and \`/docs\` under the Auth tag
|
|
1111
|
+
(schemas prefixed \`Auth\`). better-auth's own reference is also at \`/api/auth/reference\`.
|
|
1071
1112
|
- Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
|
|
1072
1113
|
- Outside production, every email and SMS is kept in an in-memory outbox:
|
|
1073
1114
|
\`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
|
|
@@ -1113,6 +1154,16 @@ const betterAuth = {
|
|
|
1113
1154
|
slot: "imports",
|
|
1114
1155
|
content: "import { outboxEnabled, readOutbox } from \"#core/auth/delivery\";"
|
|
1115
1156
|
},
|
|
1157
|
+
{
|
|
1158
|
+
file: "src/core/http/app.ts",
|
|
1159
|
+
slot: "imports",
|
|
1160
|
+
content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
file: "src/core/http/app.ts",
|
|
1164
|
+
slot: "openapi",
|
|
1165
|
+
content: "if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);"
|
|
1166
|
+
},
|
|
1116
1167
|
{
|
|
1117
1168
|
file: "src/core/http/app.ts",
|
|
1118
1169
|
slot: "routes",
|
|
@@ -1288,6 +1339,64 @@ const authPassword = {
|
|
|
1288
1339
|
}
|
|
1289
1340
|
]
|
|
1290
1341
|
};
|
|
1342
|
+
const adminFields = [
|
|
1343
|
+
{
|
|
1344
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1345
|
+
slot: "user-fields",
|
|
1346
|
+
when: drizzlePg,
|
|
1347
|
+
content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
|
|
1348
|
+
},
|
|
1349
|
+
{
|
|
1350
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1351
|
+
slot: "user-fields",
|
|
1352
|
+
when: drizzleMy,
|
|
1353
|
+
content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1357
|
+
slot: "session-fields",
|
|
1358
|
+
when: drizzlePg,
|
|
1359
|
+
content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1363
|
+
slot: "session-fields",
|
|
1364
|
+
when: drizzleMy,
|
|
1365
|
+
content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
file: "prisma/models/auth.prisma",
|
|
1369
|
+
slot: "user-fields",
|
|
1370
|
+
content: [
|
|
1371
|
+
"role String? @db.VarChar(255)",
|
|
1372
|
+
"banned Boolean? @default(false)",
|
|
1373
|
+
"banReason String? @map(\"ban_reason\") @db.Text",
|
|
1374
|
+
"banExpires DateTime? @map(\"ban_expires\")"
|
|
1375
|
+
].join("\n")
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
file: "prisma/models/auth.prisma",
|
|
1379
|
+
slot: "user-relations",
|
|
1380
|
+
content: "members Member[]\ninvitations Invitation[]"
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
file: "prisma/models/auth.prisma",
|
|
1384
|
+
slot: "session-fields",
|
|
1385
|
+
content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
file: "src/core/auth/adapter.ts",
|
|
1389
|
+
slot: "imports",
|
|
1390
|
+
when: ["drizzle"],
|
|
1391
|
+
content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
file: "src/core/auth/adapter.ts",
|
|
1395
|
+
slot: "adapter-schema",
|
|
1396
|
+
when: ["drizzle"],
|
|
1397
|
+
content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
|
|
1398
|
+
}
|
|
1399
|
+
];
|
|
1291
1400
|
const authOrgs = {
|
|
1292
1401
|
id: "auth-orgs",
|
|
1293
1402
|
kind: "auth-method",
|
|
@@ -1301,9 +1410,33 @@ const authOrgs = {
|
|
|
1301
1410
|
{
|
|
1302
1411
|
file: "src/core/auth/auth.ts",
|
|
1303
1412
|
slot: "plugins",
|
|
1413
|
+
unless: ["authz"],
|
|
1304
1414
|
content: "organization(),\nadmin(),",
|
|
1305
1415
|
order: 900
|
|
1306
1416
|
},
|
|
1417
|
+
{
|
|
1418
|
+
file: "src/core/auth/auth.ts",
|
|
1419
|
+
slot: "plugins",
|
|
1420
|
+
when: ["authz"],
|
|
1421
|
+
content: [
|
|
1422
|
+
"organization({",
|
|
1423
|
+
" organizationHooks: {",
|
|
1424
|
+
" // authz rows outlive better-auth's member row; without this, re-adding restores old roles",
|
|
1425
|
+
" afterRemoveMember: async ({ member }) => {",
|
|
1426
|
+
" await removeMemberAccess(member.organizationId, member.userId);",
|
|
1427
|
+
" },",
|
|
1428
|
+
" },",
|
|
1429
|
+
"}),",
|
|
1430
|
+
"admin(),"
|
|
1431
|
+
].join("\n"),
|
|
1432
|
+
order: 900
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
file: "src/core/auth/auth.ts",
|
|
1436
|
+
slot: "imports",
|
|
1437
|
+
when: ["authz"],
|
|
1438
|
+
content: "import { removeMemberAccess } from \"#core/authz/services/members\";"
|
|
1439
|
+
},
|
|
1307
1440
|
{
|
|
1308
1441
|
file: "tests/helpers/auth.ts",
|
|
1309
1442
|
slot: "user-fields",
|
|
@@ -1314,64 +1447,7 @@ const authOrgs = {
|
|
|
1314
1447
|
slot: "seed-user-fields",
|
|
1315
1448
|
content: "role: \"admin\","
|
|
1316
1449
|
},
|
|
1317
|
-
...
|
|
1318
|
-
{
|
|
1319
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1320
|
-
slot: "user-fields",
|
|
1321
|
-
when: drizzlePg,
|
|
1322
|
-
content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
|
|
1323
|
-
},
|
|
1324
|
-
{
|
|
1325
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1326
|
-
slot: "user-fields",
|
|
1327
|
-
when: drizzleMy,
|
|
1328
|
-
content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
|
|
1329
|
-
},
|
|
1330
|
-
{
|
|
1331
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1332
|
-
slot: "session-fields",
|
|
1333
|
-
when: drizzlePg,
|
|
1334
|
-
content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
|
|
1335
|
-
},
|
|
1336
|
-
{
|
|
1337
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1338
|
-
slot: "session-fields",
|
|
1339
|
-
when: drizzleMy,
|
|
1340
|
-
content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
|
|
1341
|
-
},
|
|
1342
|
-
{
|
|
1343
|
-
file: "prisma/models/auth.prisma",
|
|
1344
|
-
slot: "user-fields",
|
|
1345
|
-
content: [
|
|
1346
|
-
"role String? @db.VarChar(255)",
|
|
1347
|
-
"banned Boolean? @default(false)",
|
|
1348
|
-
"banReason String? @map(\"ban_reason\") @db.Text",
|
|
1349
|
-
"banExpires DateTime? @map(\"ban_expires\")"
|
|
1350
|
-
].join("\n")
|
|
1351
|
-
},
|
|
1352
|
-
{
|
|
1353
|
-
file: "prisma/models/auth.prisma",
|
|
1354
|
-
slot: "user-relations",
|
|
1355
|
-
content: "members Member[]\ninvitations Invitation[]"
|
|
1356
|
-
},
|
|
1357
|
-
{
|
|
1358
|
-
file: "prisma/models/auth.prisma",
|
|
1359
|
-
slot: "session-fields",
|
|
1360
|
-
content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
|
|
1361
|
-
},
|
|
1362
|
-
{
|
|
1363
|
-
file: "src/core/auth/adapter.ts",
|
|
1364
|
-
slot: "imports",
|
|
1365
|
-
when: ["drizzle"],
|
|
1366
|
-
content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
|
|
1367
|
-
},
|
|
1368
|
-
{
|
|
1369
|
-
file: "src/core/auth/adapter.ts",
|
|
1370
|
-
slot: "adapter-schema",
|
|
1371
|
-
when: ["drizzle"],
|
|
1372
|
-
content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
|
|
1373
|
-
}
|
|
1374
|
-
]
|
|
1450
|
+
...adminFields
|
|
1375
1451
|
]
|
|
1376
1452
|
};
|
|
1377
1453
|
const keycloak = {
|
|
@@ -1389,7 +1465,7 @@ const keycloak = {
|
|
|
1389
1465
|
},
|
|
1390
1466
|
{
|
|
1391
1467
|
key: "KEYCLOAK_URL",
|
|
1392
|
-
schema: "z.url()
|
|
1468
|
+
schema: "localDefault(z.url(), \"http://localhost:8080\")",
|
|
1393
1469
|
example: "http://localhost:8080"
|
|
1394
1470
|
},
|
|
1395
1471
|
{
|
|
@@ -1509,13 +1585,13 @@ const base = {
|
|
|
1509
1585
|
},
|
|
1510
1586
|
{
|
|
1511
1587
|
key: "APP_URL",
|
|
1512
|
-
schema: "z.url()
|
|
1588
|
+
schema: "localDefault(z.url(), \"http://localhost:3000\")",
|
|
1513
1589
|
example: "http://localhost:3000",
|
|
1514
1590
|
comment: "Public URL of the API"
|
|
1515
1591
|
},
|
|
1516
1592
|
{
|
|
1517
1593
|
key: "CORS_ORIGINS",
|
|
1518
|
-
schema: "z.string()
|
|
1594
|
+
schema: "localDefault(z.string(), \"http://localhost:5173\").transform((v) => v.split(\",\").map((o) => o.trim()).filter(Boolean))",
|
|
1519
1595
|
example: "http://localhost:5173",
|
|
1520
1596
|
comment: "Comma-separated browser origins allowed to call the API"
|
|
1521
1597
|
},
|
|
@@ -1550,7 +1626,7 @@ const pnpm = {
|
|
|
1550
1626
|
slot: "docker-build",
|
|
1551
1627
|
content: [
|
|
1552
1628
|
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
|
|
1553
|
-
"RUN pnpm install --frozen-lockfile",
|
|
1629
|
+
"RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-cache,target=/root/.cache/pnpm pnpm install --frozen-lockfile",
|
|
1554
1630
|
"COPY . .",
|
|
1555
1631
|
"RUN pnpm run build"
|
|
1556
1632
|
].join("\n")
|
|
@@ -1561,7 +1637,8 @@ const pnpm = {
|
|
|
1561
1637
|
order: 0,
|
|
1562
1638
|
content: [
|
|
1563
1639
|
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
|
|
1564
|
-
"
|
|
1640
|
+
"# cache mounts keep the package store out of the image",
|
|
1641
|
+
"RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-cache,target=/root/.cache/pnpm pnpm install --frozen-lockfile --prod",
|
|
1565
1642
|
"COPY --from=build /app/dist ./dist"
|
|
1566
1643
|
].join("\n")
|
|
1567
1644
|
},
|
|
@@ -1589,7 +1666,7 @@ const npm = {
|
|
|
1589
1666
|
slot: "docker-build",
|
|
1590
1667
|
content: [
|
|
1591
1668
|
"COPY package.json package-lock.json ./",
|
|
1592
|
-
"RUN npm ci",
|
|
1669
|
+
"RUN --mount=type=cache,target=/root/.npm npm ci",
|
|
1593
1670
|
"COPY . .",
|
|
1594
1671
|
"RUN npm run build"
|
|
1595
1672
|
].join("\n")
|
|
@@ -1600,7 +1677,7 @@ const npm = {
|
|
|
1600
1677
|
order: 0,
|
|
1601
1678
|
content: [
|
|
1602
1679
|
"COPY package.json package-lock.json ./",
|
|
1603
|
-
"RUN npm ci --omit=dev",
|
|
1680
|
+
"RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev",
|
|
1604
1681
|
"COPY --from=build /app/dist ./dist"
|
|
1605
1682
|
].join("\n")
|
|
1606
1683
|
},
|
|
@@ -1632,7 +1709,7 @@ const bun = {
|
|
|
1632
1709
|
slot: "docker-build",
|
|
1633
1710
|
content: [
|
|
1634
1711
|
"COPY package.json bun.lock ./",
|
|
1635
|
-
"RUN bun install --frozen-lockfile",
|
|
1712
|
+
"RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile",
|
|
1636
1713
|
"COPY . .",
|
|
1637
1714
|
"RUN bun run build"
|
|
1638
1715
|
].join("\n")
|
|
@@ -1643,7 +1720,7 @@ const bun = {
|
|
|
1643
1720
|
order: 0,
|
|
1644
1721
|
content: [
|
|
1645
1722
|
"COPY package.json bun.lock ./",
|
|
1646
|
-
"RUN bun install --frozen-lockfile --production",
|
|
1723
|
+
"RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --production",
|
|
1647
1724
|
"COPY --from=build /app/dist ./dist"
|
|
1648
1725
|
].join("\n")
|
|
1649
1726
|
},
|
|
@@ -2002,6 +2079,13 @@ function envModule(vars) {
|
|
|
2002
2079
|
return `import { createEnv } from "@t3-oss/env-core";
|
|
2003
2080
|
import { z } from "zod";
|
|
2004
2081
|
|
|
2082
|
+
const production = process.env.NODE_ENV === "production";
|
|
2083
|
+
|
|
2084
|
+
/** A default for local development; production must set the value explicitly. */
|
|
2085
|
+
function localDefault<T extends z.ZodType>(schema: T, value: z.input<T>): T {
|
|
2086
|
+
return production ? schema : (schema.default(value as never) as unknown as T);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2005
2089
|
/** The only place that reads process.env. Add new variables here and to .env.example. */
|
|
2006
2090
|
export const env = createEnv({
|
|
2007
2091
|
server: {
|
|
@@ -2370,6 +2454,13 @@ async function generate(project, request) {
|
|
|
2370
2454
|
}
|
|
2371
2455
|
const existing = [...rendered.keys()].filter((rel) => existsSync(join(project.root, rel)));
|
|
2372
2456
|
if (existing.length > 0) throw new GeneratorError(`Refusing to overwrite existing file(s):\n ${existing.join("\n ")}`);
|
|
2457
|
+
const routeSources = /* @__PURE__ */ new Map();
|
|
2458
|
+
for (const rel of await glob("src/**/routes/*.routes.ts", { cwd: project.root })) routeSources.set(rel, await readFile(join(project.root, rel), "utf8"));
|
|
2459
|
+
const before = routeMountCollisions(routeSources).length;
|
|
2460
|
+
for (const [rel, text] of rendered) routeSources.set(rel, text);
|
|
2461
|
+
const after = routeMountCollisions(routeSources);
|
|
2462
|
+
const collision = after.length > before ? after.at(-1) : void 0;
|
|
2463
|
+
if (collision) throw new GeneratorError(`${collision.file}: ${collision.message}. Pick another --name, or create it and change basePath.`);
|
|
2373
2464
|
const files = [...rendered.keys()].sort();
|
|
2374
2465
|
if (request.dryRun) return {
|
|
2375
2466
|
kind: request.kind,
|
package/package.json
CHANGED
|
@@ -15,9 +15,13 @@ export const AUDIT_CHANGE_TYPES = [
|
|
|
15
15
|
"member_role_revoked",
|
|
16
16
|
"overlay_granted",
|
|
17
17
|
"overlay_revoked",
|
|
18
|
+
"member_removed",
|
|
18
19
|
] as const;
|
|
19
20
|
export type AuditChangeType = (typeof AUDIT_CHANGE_TYPES)[number];
|
|
20
21
|
|
|
22
|
+
/** Audit actor for changes made by the system rather than a user (e.g. org membership hooks). */
|
|
23
|
+
export const SYSTEM_ACTOR = "system";
|
|
24
|
+
|
|
21
25
|
/** Org used when the principal has none (single-tenant apps, users without an active org). */
|
|
22
26
|
export const DEFAULT_ORG_ID = "default";
|
|
23
27
|
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
userParams,
|
|
20
20
|
userRoleParams,
|
|
21
21
|
} from "#core/authz/validators";
|
|
22
|
-
import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
|
|
22
|
+
import { errorResponses, jsonBody, jsonContent, success, successSchema } from "#core/http/response";
|
|
23
23
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
24
24
|
import { catalog } from "#generated/permissions";
|
|
25
25
|
|
|
@@ -100,7 +100,7 @@ router.openapi(
|
|
|
100
100
|
summary: "Create a custom role",
|
|
101
101
|
middleware: [requirePermission("role:create")] as const,
|
|
102
102
|
security: [{ bearerAuth: ["role:create"] }],
|
|
103
|
-
request: { body:
|
|
103
|
+
request: { body: jsonBody(createRoleSchema, "The role") },
|
|
104
104
|
responses: {
|
|
105
105
|
201: jsonContent(successSchema(roleSchema), "Created"),
|
|
106
106
|
...errorResponses(400, 409),
|
|
@@ -122,7 +122,7 @@ router.openapi(
|
|
|
122
122
|
summary: "Update a custom role",
|
|
123
123
|
middleware: [requirePermission("role:update")] as const,
|
|
124
124
|
security: [{ bearerAuth: ["role:update"] }],
|
|
125
|
-
request: { params: idParams, body:
|
|
125
|
+
request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
|
|
126
126
|
responses: {
|
|
127
127
|
200: jsonContent(successSchema(roleSchema), "Updated"),
|
|
128
128
|
...errorResponses(400, 404),
|
|
@@ -179,7 +179,7 @@ router.openapi(
|
|
|
179
179
|
summary: "Assign a role to a member",
|
|
180
180
|
middleware: [requirePermission("member:update")] as const,
|
|
181
181
|
security: [{ bearerAuth: ["member:update"] }],
|
|
182
|
-
request: { params: userParams, body:
|
|
182
|
+
request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
|
|
183
183
|
responses: {
|
|
184
184
|
200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
|
|
185
185
|
...errorResponses(404),
|
|
@@ -225,7 +225,7 @@ router.openapi(
|
|
|
225
225
|
summary: "Grant or deny one permission to a member",
|
|
226
226
|
middleware: [requirePermission("member:update")] as const,
|
|
227
227
|
security: [{ bearerAuth: ["member:update"] }],
|
|
228
|
-
request: { params: userParams, body:
|
|
228
|
+
request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
|
|
229
229
|
responses: {
|
|
230
230
|
201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
|
|
231
231
|
...errorResponses(400),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { invalidateMember } from "#core/authz/cache";
|
|
2
|
-
import type
|
|
2
|
+
import { type OverlayEffect, type Scope, SYSTEM_ACTOR } from "#core/authz/constants";
|
|
3
3
|
import { store } from "#core/authz/db/store";
|
|
4
4
|
import { audit } from "#core/authz/services/audit";
|
|
5
5
|
import { validateGrants } from "#core/authz/services/grants";
|
|
@@ -31,6 +31,22 @@ export async function revokeRole(actorId: string, orgId: string, userId: string,
|
|
|
31
31
|
await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_revoked", before: { roleId } });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Removes all of a user's access in an org; called when they stop being a member. */
|
|
35
|
+
export async function removeMemberAccess(orgId: string, userId: string, actorId = SYSTEM_ACTOR) {
|
|
36
|
+
const removed = await store.removeMember(orgId, userId);
|
|
37
|
+
invalidateMember(userId, orgId);
|
|
38
|
+
if (removed.roles + removed.overlays > 0) {
|
|
39
|
+
await audit({
|
|
40
|
+
orgId,
|
|
41
|
+
actorId,
|
|
42
|
+
targetUserId: userId,
|
|
43
|
+
changeType: "member_removed",
|
|
44
|
+
before: removed,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return removed;
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
export interface OverlayInput {
|
|
35
51
|
resource: string;
|
|
36
52
|
action: string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { api } from "#tests/helpers/api";
|
|
3
|
+
import { asUser, authenticate } from "#tests/helpers/auth";
|
|
4
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
5
|
+
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
await resetDatabase();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("error messages", () => {
|
|
11
|
+
it("keeps a specific message instead of the generic one for its code", async () => {
|
|
12
|
+
const headers = await asUser({ systemRoles: ["admin"] });
|
|
13
|
+
const role = await api.post<{ data: { id: string } }>(
|
|
14
|
+
"/api/authorization/roles",
|
|
15
|
+
{ key: "auditor", name: "Auditor", grants: [] },
|
|
16
|
+
{ headers },
|
|
17
|
+
);
|
|
18
|
+
const member = await authenticate();
|
|
19
|
+
await api.post(
|
|
20
|
+
`/api/authorization/members/${member.userId}/roles`,
|
|
21
|
+
{ roleId: role.body.data.id },
|
|
22
|
+
{ headers },
|
|
23
|
+
);
|
|
24
|
+
const res = await api.delete<{ code: string; error: string }>(
|
|
25
|
+
`/api/authorization/roles/${role.body.data.id}`,
|
|
26
|
+
{ headers },
|
|
27
|
+
);
|
|
28
|
+
expect(res.status).toBe(409);
|
|
29
|
+
expect(res.body).toMatchObject({
|
|
30
|
+
code: "CONFLICT",
|
|
31
|
+
error: 'Role "auditor" is assigned to 1 member(s)',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { app } from "#apps/api/app";
|
|
3
|
+
import { asUser } from "#tests/helpers/auth";
|
|
4
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
5
|
+
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
await resetDatabase();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// A body sent without Content-Type used to skip validation and reach services as {}
|
|
11
|
+
describe("JSON request bodies", () => {
|
|
12
|
+
it("rejects a missing body with 400", async () => {
|
|
13
|
+
const headers = await asUser({ systemRoles: ["admin"] });
|
|
14
|
+
const res = await app.request("/api/authorization/roles", { method: "POST", headers });
|
|
15
|
+
expect(res.status).toBe(400);
|
|
16
|
+
expect(await res.json()).toMatchObject({ code: "VALIDATION_ERROR" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("does not assign a role from a missing body", async () => {
|
|
20
|
+
const headers = await asUser({ systemRoles: ["admin"] });
|
|
21
|
+
const res = await app.request("/api/authorization/members/someone/roles", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers,
|
|
24
|
+
});
|
|
25
|
+
expect(res.status).toBe(400);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -77,6 +77,8 @@ export interface AuthzStore {
|
|
|
77
77
|
memberRoles(orgId: string, userId: string): Promise<RoleRow[]>;
|
|
78
78
|
assignRole(orgId: string, userId: string, roleId: string, by: string | null): Promise<boolean>;
|
|
79
79
|
revokeRole(orgId: string, userId: string, roleId: string): Promise<boolean>;
|
|
80
|
+
/** Deletes every role assignment and overlay the user has in the org. */
|
|
81
|
+
removeMember(orgId: string, userId: string): Promise<{ roles: number; overlays: number }>;
|
|
80
82
|
listOverlays(orgId: string, userId: string): Promise<OverlayRow[]>;
|
|
81
83
|
addOverlay(input: Omit<OverlayRow, "id" | "createdAt">): Promise<string>;
|
|
82
84
|
removeOverlay(orgId: string, userId: string, overlayId: string): Promise<OverlayRow | null>;
|
|
@@ -194,6 +194,19 @@ export const store: AuthzStore = {
|
|
|
194
194
|
return true;
|
|
195
195
|
},
|
|
196
196
|
|
|
197
|
+
async removeMember(orgId, userId) {
|
|
198
|
+
const roleWhere = and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId));
|
|
199
|
+
const overlayWhere = and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId));
|
|
200
|
+
// count first: MySQL has no RETURNING
|
|
201
|
+
return db.transaction(async (tx) => {
|
|
202
|
+
const [roles] = await tx.select({ n: count() }).from(authzMemberRoles).where(roleWhere);
|
|
203
|
+
const [overlays] = await tx.select({ n: count() }).from(authzOverlays).where(overlayWhere);
|
|
204
|
+
await tx.delete(authzMemberRoles).where(roleWhere);
|
|
205
|
+
await tx.delete(authzOverlays).where(overlayWhere);
|
|
206
|
+
return { roles: roles?.n ?? 0, overlays: overlays?.n ?? 0 };
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
|
|
197
210
|
async listOverlays(orgId, userId) {
|
|
198
211
|
const rows = await db
|
|
199
212
|
.select()
|
|
@@ -168,6 +168,13 @@ export const store: AuthzStore = {
|
|
|
168
168
|
return count > 0;
|
|
169
169
|
},
|
|
170
170
|
|
|
171
|
+
async removeMember(orgId, userId) {
|
|
172
|
+
const [roles, overlays] = await db.$transaction([
|
|
173
|
+
db.authzMemberRole.deleteMany({ where: { orgId, userId } }),
|
|
174
|
+
db.authzOverlay.deleteMany({ where: { orgId, userId } }),
|
|
175
|
+
]);
|
|
176
|
+
return { roles: roles.count, overlays: overlays.count };
|
|
177
|
+
},
|
|
171
178
|
async listOverlays(orgId, userId) {
|
|
172
179
|
const rows = await db.authzOverlay.findMany({
|
|
173
180
|
where: { orgId, userId },
|
|
@@ -45,6 +45,8 @@ __PM_EXEC__ tatara routes # every route and what protects it
|
|
|
45
45
|
## HTTP
|
|
46
46
|
|
|
47
47
|
- Every route uses `createRoute` from `@hono/zod-openapi`, with request and response schemas.
|
|
48
|
+
Declare request bodies with `jsonBody(schema, description)` (required; a missing body is a 400),
|
|
49
|
+
responses with `jsonContent(...)`.
|
|
48
50
|
The spec is served at `/openapi.json`, the reference UI at `/docs`.
|
|
49
51
|
- Responses use the envelope: `success(data)`, `paginated(items, page, pageSize, total)`.
|
|
50
52
|
Errors are thrown as `AppError` subclasses (`NotFoundError`, `ConflictError`,
|
|
@@ -5,6 +5,9 @@ on:
|
|
|
5
5
|
branches: [main]
|
|
6
6
|
pull_request:
|
|
7
7
|
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
8
11
|
jobs:
|
|
9
12
|
verify:
|
|
10
13
|
runs-on: ubuntu-latest
|
|
@@ -14,3 +17,10 @@ jobs:
|
|
|
14
17
|
- run: cp .env.example .env
|
|
15
18
|
# tatara:slot ci-services
|
|
16
19
|
- run: __PM_RUN__ verify
|
|
20
|
+
|
|
21
|
+
image:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v5
|
|
25
|
+
# catch a broken Dockerfile on the PR, not at deploy
|
|
26
|
+
- run: docker build --tag app:ci .
|
|
@@ -9,7 +9,7 @@ export interface CommandDefinition<S extends z.ZodType = z.ZodType> {
|
|
|
9
9
|
/** `module:action`, e.g. "billing:recalculate" */
|
|
10
10
|
name: string;
|
|
11
11
|
description: string;
|
|
12
|
-
/** flags parsed from `--key value`; use z.coerce for numbers and booleans */
|
|
12
|
+
/** flags parsed from `--key value`; use z.coerce for numbers and `flag()` for booleans */
|
|
13
13
|
args?: S;
|
|
14
14
|
run(args: z.infer<S>, ctx: CommandContext): Promise<void> | void;
|
|
15
15
|
}
|
|
@@ -23,3 +23,11 @@ export function defineCommand<S extends z.ZodType = typeof noArgs>(
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export const noArgs = z.object({});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A boolean flag: bare `--force` is true, `--force false|0|no|off` is false.
|
|
29
|
+
* (`z.coerce.boolean()` would read the string "false" as true.)
|
|
30
|
+
*/
|
|
31
|
+
export function flag() {
|
|
32
|
+
return z.union([z.boolean(), z.stringbool()]).default(false);
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { flag } from "#core/commands/define";
|
|
4
|
+
import { parseFlags } from "#core/commands/runner";
|
|
5
|
+
|
|
6
|
+
const args = z.object({ force: flag() });
|
|
7
|
+
const parse = (argv: string[]) => args.parse(parseFlags(argv)).force;
|
|
8
|
+
|
|
9
|
+
describe("flag()", () => {
|
|
10
|
+
it("is true for a bare flag", () => expect(parse(["--force"])).toBe(true));
|
|
11
|
+
it("reads --flag false as false", () => expect(parse(["--force", "false"])).toBe(false));
|
|
12
|
+
it("reads --flag=0 as false", () => expect(parse(["--force=0"])).toBe(false));
|
|
13
|
+
it("reads --flag true as true", () => expect(parse(["--force", "true"])).toBe(true));
|
|
14
|
+
it("defaults to false", () => expect(parse([])).toBe(false));
|
|
15
|
+
it("rejects anything else", () => expect(() => parse(["--force", "maybe"])).toThrow());
|
|
16
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// .env.test is loaded into process.env, so it holds every variable this app requires
|
|
4
|
+
const saved = { ...process.env };
|
|
5
|
+
|
|
6
|
+
const LOCAL_DEFAULTS = [
|
|
7
|
+
"APP_URL",
|
|
8
|
+
"CORS_ORIGINS",
|
|
9
|
+
"EMAIL_DRIVER",
|
|
10
|
+
"STORAGE_DRIVER",
|
|
11
|
+
"SMTP_URL",
|
|
12
|
+
"KEYCLOAK_URL",
|
|
13
|
+
].filter((key) => saved[key] !== undefined);
|
|
14
|
+
|
|
15
|
+
async function loadEnv(vars: Record<string, string | undefined>) {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
process.env = { ...vars } as NodeJS.ProcessEnv;
|
|
18
|
+
return (await import("#core/config/env")).env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.env = { ...saved };
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("env in production", () => {
|
|
27
|
+
it("loads when every setting is given", async () => {
|
|
28
|
+
const env = await loadEnv({ ...saved, NODE_ENV: "production" });
|
|
29
|
+
expect(env.NODE_ENV).toBe("production");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each(LOCAL_DEFAULTS)("refuses to fall back to the local default for %s", async (key) => {
|
|
33
|
+
await expect(loadEnv({ ...saved, NODE_ENV: "production", [key]: undefined })).rejects.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps local defaults outside production", async () => {
|
|
37
|
+
const env = await loadEnv({ ...saved, NODE_ENV: "development", APP_URL: undefined });
|
|
38
|
+
expect(env.APP_URL).toBe("http://localhost:3000");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -43,7 +43,12 @@ export function createHttpApp(name: AppName, options: { title: string }) {
|
|
|
43
43
|
if (definition.app === name) app.route(`${API_PREFIX}${definition.basePath}`, definition.router);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const openApiConfig = { openapi: "3.1.0", info: { title: options.title, version: "1.0.0" } } as const;
|
|
47
|
+
app.get("/openapi.json", async (c) => {
|
|
48
|
+
const document = app.getOpenAPI31Document(openApiConfig);
|
|
49
|
+
// tatara:slot openapi
|
|
50
|
+
return c.json(document);
|
|
51
|
+
});
|
|
47
52
|
app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
|
|
48
53
|
|
|
49
54
|
app.onError(errorHandler);
|
|
@@ -4,48 +4,76 @@ export class AppError extends Error {
|
|
|
4
4
|
readonly status: ContentfulStatusCode;
|
|
5
5
|
readonly code: string;
|
|
6
6
|
readonly details?: unknown;
|
|
7
|
+
/** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
|
|
8
|
+
readonly translatable: boolean;
|
|
7
9
|
|
|
8
|
-
constructor(
|
|
10
|
+
constructor(
|
|
11
|
+
status: ContentfulStatusCode,
|
|
12
|
+
code: string,
|
|
13
|
+
message: string,
|
|
14
|
+
details?: unknown,
|
|
15
|
+
translatable = false,
|
|
16
|
+
) {
|
|
9
17
|
super(message);
|
|
10
18
|
this.name = new.target.name;
|
|
11
19
|
this.status = status;
|
|
12
20
|
this.code = code;
|
|
13
21
|
this.details = details;
|
|
22
|
+
this.translatable = translatable;
|
|
14
23
|
}
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export class ValidationError extends AppError {
|
|
18
|
-
constructor(message
|
|
19
|
-
super(400, "VALIDATION_ERROR", message, details);
|
|
27
|
+
constructor(message?: string, details?: unknown) {
|
|
28
|
+
super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
|
|
20
29
|
}
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export class BusinessRuleError extends AppError {
|
|
24
33
|
constructor(code: string, message: string, details?: unknown) {
|
|
25
|
-
|
|
34
|
+
// codes are specific to the rule, so a translation for the code is never less precise
|
|
35
|
+
super(400, code, message, details, true);
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
export class UnauthorizedError extends AppError {
|
|
30
|
-
constructor(message
|
|
31
|
-
super(
|
|
40
|
+
constructor(message?: string) {
|
|
41
|
+
super(
|
|
42
|
+
401,
|
|
43
|
+
"UNAUTHORIZED",
|
|
44
|
+
message ?? "Authentication required",
|
|
45
|
+
undefined,
|
|
46
|
+
message === undefined,
|
|
47
|
+
);
|
|
32
48
|
}
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
export class ForbiddenError extends AppError {
|
|
36
|
-
constructor(message
|
|
37
|
-
super(
|
|
52
|
+
constructor(message?: string) {
|
|
53
|
+
super(
|
|
54
|
+
403,
|
|
55
|
+
"FORBIDDEN",
|
|
56
|
+
message ?? "You do not have permission to do this",
|
|
57
|
+
undefined,
|
|
58
|
+
message === undefined,
|
|
59
|
+
);
|
|
38
60
|
}
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
export class NotFoundError extends AppError {
|
|
42
|
-
constructor(resource
|
|
43
|
-
super(
|
|
64
|
+
constructor(resource?: string) {
|
|
65
|
+
super(
|
|
66
|
+
404,
|
|
67
|
+
"NOT_FOUND",
|
|
68
|
+
`${resource ?? "Resource"} not found`,
|
|
69
|
+
undefined,
|
|
70
|
+
resource === undefined,
|
|
71
|
+
);
|
|
44
72
|
}
|
|
45
73
|
}
|
|
46
74
|
|
|
47
75
|
export class ConflictError extends AppError {
|
|
48
|
-
constructor(message
|
|
49
|
-
super(409, "CONFLICT", message, details);
|
|
76
|
+
constructor(message?: string, details?: unknown) {
|
|
77
|
+
super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
|
|
50
78
|
}
|
|
51
79
|
}
|
|
@@ -6,7 +6,7 @@ import { ValidationError } from "#core/http/errors";
|
|
|
6
6
|
export const validationHook: Hook<any, AppEnv, any, any> = (result) => {
|
|
7
7
|
if (!result.success) {
|
|
8
8
|
throw new ValidationError(
|
|
9
|
-
|
|
9
|
+
undefined,
|
|
10
10
|
result.error.issues.map((issue) => ({
|
|
11
11
|
path: issue.path.join("."),
|
|
12
12
|
message: issue.message,
|
|
@@ -49,6 +49,11 @@ export function jsonContent<T extends z.ZodType>(schema: T, description: string)
|
|
|
49
49
|
return { content: { "application/json": { schema } }, description };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** A required JSON request body. Without `required`, a request with no body skips validation. */
|
|
53
|
+
export function jsonBody<T extends z.ZodType>(schema: T, description: string) {
|
|
54
|
+
return { ...jsonContent(schema, description), required: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
const ERROR_DESCRIPTIONS = {
|
|
53
58
|
400: "Invalid request",
|
|
54
59
|
401: "Authentication required",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import { auth } from "#core/auth/auth";
|
|
3
|
+
|
|
4
|
+
type JsonObject = Record<string, unknown>;
|
|
5
|
+
type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
|
|
6
|
+
type Paths = NonNullable<OpenApiDocument["paths"]>;
|
|
7
|
+
type Components = NonNullable<OpenApiDocument["components"]>;
|
|
8
|
+
|
|
9
|
+
interface AuthDocument {
|
|
10
|
+
paths?: Record<string, JsonObject>;
|
|
11
|
+
components?: { schemas?: JsonObject; securitySchemes?: JsonObject };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SCHEMA_REF = "#/components/schemas/";
|
|
15
|
+
|
|
16
|
+
// better-auth names schemas like the app (User, Member, …); prefix them so neither side is overwritten
|
|
17
|
+
function renameRefs(value: unknown, rename: (name: string) => string): AuthDocument {
|
|
18
|
+
return JSON.parse(
|
|
19
|
+
JSON.stringify(value).replaceAll(
|
|
20
|
+
new RegExp(`"\\$ref":"${SCHEMA_REF}([^"]+)"`, "g"),
|
|
21
|
+
(_match, name: string) => `"$ref":"${SCHEMA_REF}${rename(name)}"`,
|
|
22
|
+
),
|
|
23
|
+
) as AuthDocument;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function retag(paths: Record<string, JsonObject>, tag: string): Record<string, JsonObject> {
|
|
27
|
+
for (const operations of Object.values(paths)) {
|
|
28
|
+
for (const operation of Object.values(operations as Record<string, JsonObject>)) {
|
|
29
|
+
if (operation && typeof operation === "object") operation.tags = [tag];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return paths;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Adds better-auth's endpoints (served under `basePath`) to the app's OpenAPI document. */
|
|
36
|
+
export async function mergeAuthOpenApi(document: OpenApiDocument, basePath: string): Promise<void> {
|
|
37
|
+
const authDoc = renameRefs(await auth.api.generateOpenAPISchema(), (name) => `Auth${name}`);
|
|
38
|
+
|
|
39
|
+
document.paths ??= {};
|
|
40
|
+
for (const [path, operations] of Object.entries(retag(authDoc.paths ?? {}, "Auth"))) {
|
|
41
|
+
document.paths[`${basePath}${path}`] = operations as Paths[string];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
document.components ??= {};
|
|
45
|
+
document.components.schemas ??= {};
|
|
46
|
+
for (const [name, schema] of Object.entries(authDoc.components?.schemas ?? {})) {
|
|
47
|
+
document.components.schemas[`Auth${name}`] = schema as NonNullable<
|
|
48
|
+
Components["schemas"]
|
|
49
|
+
>[string];
|
|
50
|
+
}
|
|
51
|
+
document.components.securitySchemes = {
|
|
52
|
+
...(authDoc.components?.securitySchemes as Components["securitySchemes"]),
|
|
53
|
+
...document.components.securitySchemes,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
document.tags = [
|
|
57
|
+
...(document.tags ?? []),
|
|
58
|
+
{ name: "Auth", description: "Sign-up, sign-in, sessions, organizations (better-auth)" },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
@@ -2,6 +2,23 @@ import { createMiddleware } from "hono/factory";
|
|
|
2
2
|
import { auth } from "#core/auth/auth";
|
|
3
3
|
import type { AppEnv } from "#core/http/context";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The session's active organization, or null once the user stops being a member of it.
|
|
7
|
+
* better-auth leaves `activeOrganizationId` on the removed member's session.
|
|
8
|
+
*/
|
|
9
|
+
async function activeMembership(userId: string, orgId: string | null | undefined) {
|
|
10
|
+
if (!orgId) return null;
|
|
11
|
+
const { adapter } = await auth.$context;
|
|
12
|
+
const member = await adapter.findOne({
|
|
13
|
+
model: "member",
|
|
14
|
+
where: [
|
|
15
|
+
{ field: "organizationId", value: orgId },
|
|
16
|
+
{ field: "userId", value: userId },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
return member ? orgId : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/** Resolves the session (cookie or bearer token) into `c.var.principal`. */
|
|
6
23
|
export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
|
|
7
24
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
@@ -10,7 +27,7 @@ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
|
|
|
10
27
|
const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
|
|
11
28
|
c.set("principal", {
|
|
12
29
|
userId: user.id,
|
|
13
|
-
orgId: s.activeOrganizationId
|
|
30
|
+
orgId: await activeMembership(user.id, s.activeOrganizationId),
|
|
14
31
|
roles: (user.role ?? "user")
|
|
15
32
|
.split(",")
|
|
16
33
|
.map((role) => role.trim())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { api } from "#tests/helpers/api";
|
|
3
|
+
|
|
4
|
+
interface Document {
|
|
5
|
+
paths: Record<string, Record<string, { tags?: string[] }>>;
|
|
6
|
+
components: { schemas: Record<string, unknown>; securitySchemes: Record<string, unknown> };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function apiDocument(): Promise<Document> {
|
|
10
|
+
const res = await api.get<Document>("/openapi.json");
|
|
11
|
+
expect(res.status).toBe(200);
|
|
12
|
+
return res.body;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function refs(value: unknown): string[] {
|
|
16
|
+
return [...JSON.stringify(value).matchAll(/"\$ref":"#\/components\/schemas\/([^"]+)"/g)].map(
|
|
17
|
+
(match) => match[1] ?? "",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("OpenAPI document", () => {
|
|
22
|
+
it("documents better-auth endpoints next to the app's own", async () => {
|
|
23
|
+
const doc = await apiDocument();
|
|
24
|
+
expect(doc.paths["/api/auth/get-session"]?.get).toBeDefined();
|
|
25
|
+
expect(doc.paths["/api/me"]?.get).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("tags better-auth operations as Auth", async () => {
|
|
29
|
+
const doc = await apiDocument();
|
|
30
|
+
expect(doc.paths["/api/auth/get-session"]?.get?.tags).toEqual(["Auth"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("prefixes better-auth schemas so app schemas keep their names", async () => {
|
|
34
|
+
const doc = await apiDocument();
|
|
35
|
+
expect(doc.components.schemas.AuthUser).toBeDefined();
|
|
36
|
+
expect(doc.components.schemas.Principal).toBeDefined();
|
|
37
|
+
expect(doc.components.securitySchemes.bearerAuth).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("resolves every schema reference", async () => {
|
|
41
|
+
const doc = await apiDocument();
|
|
42
|
+
const missing = refs(doc).filter((name) => !(name in doc.components.schemas));
|
|
43
|
+
expect(missing).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { adminApi } from "#tests/helpers/api";
|
|
3
|
+
|
|
4
|
+
it("leaves auth out of the admin OpenAPI document", async () => {
|
|
5
|
+
const res = await adminApi.get<{ paths: Record<string, unknown> }>("/openapi.json");
|
|
6
|
+
expect(res.status).toBe(200);
|
|
7
|
+
expect(Object.keys(res.body.paths).some((path) => path.startsWith("/api/auth/"))).toBe(false);
|
|
8
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { auth } from "#core/auth/auth";
|
|
3
|
+
import { api } from "#tests/helpers/api";
|
|
4
|
+
import { authenticate } from "#tests/helpers/auth";
|
|
5
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
await resetDatabase();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("principal", () => {
|
|
12
|
+
it("drops the active organization once the user is no longer a member", async () => {
|
|
13
|
+
const owner = await authenticate();
|
|
14
|
+
const member = await authenticate({ email: "member@example.com" });
|
|
15
|
+
const org = await api.post<{ id: string }>(
|
|
16
|
+
"/api/auth/organization/create",
|
|
17
|
+
{ name: "School", slug: "school" },
|
|
18
|
+
{ headers: owner.headers },
|
|
19
|
+
);
|
|
20
|
+
await auth.api.addMember({
|
|
21
|
+
body: { userId: member.userId, role: "member", organizationId: org.body.id },
|
|
22
|
+
});
|
|
23
|
+
await api.post(
|
|
24
|
+
"/api/auth/organization/set-active",
|
|
25
|
+
{ organizationId: org.body.id },
|
|
26
|
+
{ headers: member.headers },
|
|
27
|
+
);
|
|
28
|
+
const before = await api.get<{ data: { orgId: string | null } }>("/api/me", {
|
|
29
|
+
headers: member.headers,
|
|
30
|
+
});
|
|
31
|
+
expect(before.body.data.orgId).toBe(org.body.id);
|
|
32
|
+
|
|
33
|
+
const removed = await api.post(
|
|
34
|
+
"/api/auth/organization/remove-member",
|
|
35
|
+
{ memberIdOrEmail: "member@example.com", organizationId: org.body.id },
|
|
36
|
+
{ headers: owner.headers },
|
|
37
|
+
);
|
|
38
|
+
expect(removed.status).toBe(200);
|
|
39
|
+
const after = await api.get<{ data: { orgId: string | null } }>("/api/me", {
|
|
40
|
+
headers: member.headers,
|
|
41
|
+
});
|
|
42
|
+
expect(after.body.data.orgId).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { beforeEach, expect, it } from "vitest";
|
|
2
|
+
import { auth } from "#core/auth/auth";
|
|
3
|
+
import { api } from "#tests/helpers/api";
|
|
4
|
+
import { authenticate } from "#tests/helpers/auth";
|
|
5
|
+
import { applyTestAccess } from "#tests/helpers/authz";
|
|
6
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
await resetDatabase();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("does not restore old org roles when a removed member is added back", async () => {
|
|
13
|
+
const owner = await authenticate();
|
|
14
|
+
const member = await authenticate({ email: "member@example.com" });
|
|
15
|
+
const org = await api.post<{ id: string }>(
|
|
16
|
+
"/api/auth/organization/create",
|
|
17
|
+
{ name: "School", slug: "school" },
|
|
18
|
+
{ headers: owner.headers },
|
|
19
|
+
);
|
|
20
|
+
await auth.api.addMember({
|
|
21
|
+
body: { userId: member.userId, role: "member", organizationId: org.body.id },
|
|
22
|
+
});
|
|
23
|
+
await applyTestAccess(member.userId, { systemRoles: ["admin"], orgId: org.body.id });
|
|
24
|
+
await api.post(
|
|
25
|
+
"/api/auth/organization/remove-member",
|
|
26
|
+
{ memberIdOrEmail: "member@example.com", organizationId: org.body.id },
|
|
27
|
+
{ headers: owner.headers },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
await auth.api.addMember({
|
|
31
|
+
body: { userId: member.userId, role: "member", organizationId: org.body.id },
|
|
32
|
+
});
|
|
33
|
+
await api.post(
|
|
34
|
+
"/api/auth/organization/set-active",
|
|
35
|
+
{ organizationId: org.body.id },
|
|
36
|
+
{ headers: member.headers },
|
|
37
|
+
);
|
|
38
|
+
const roles = await api.get("/api/authorization/roles", { headers: member.headers });
|
|
39
|
+
expect(roles.status).toBe(403);
|
|
40
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { defineCommand } from "#core/commands/define";
|
|
2
|
+
import { defineCommand, flag } from "#core/commands/define";
|
|
3
3
|
import { env } from "#core/config/env";
|
|
4
4
|
import { dropAllTables } from "#core/db/admin";
|
|
5
5
|
import { migrateDatabase } from "#core/db/migrate";
|
|
@@ -7,7 +7,7 @@ import { migrateDatabase } from "#core/db/migrate";
|
|
|
7
7
|
export default defineCommand({
|
|
8
8
|
name: "db:reset",
|
|
9
9
|
description: "Drop every table and re-apply migrations (refuses to run in production)",
|
|
10
|
-
args: z.object({ force:
|
|
10
|
+
args: z.object({ force: flag() }),
|
|
11
11
|
async run({ force }, { logger }) {
|
|
12
12
|
if (env.NODE_ENV === "production" && !force) {
|
|
13
13
|
throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { parseFlags } from "#core/commands/runner";
|
|
3
|
+
import reset from "#core/db/commands/reset";
|
|
4
|
+
|
|
5
|
+
it("does not treat --force false as forcing db:reset", () => {
|
|
6
|
+
expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
|
|
7
|
+
});
|
package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { defineCommand } from "#core/commands/define";
|
|
2
|
+
import { defineCommand, flag } from "#core/commands/define";
|
|
3
3
|
|
|
4
4
|
export default defineCommand({
|
|
5
5
|
name: "__moduleKebab__:__nameKebab__",
|
|
6
6
|
description: "__nameTitle__",
|
|
7
|
-
args: z.object({ dryRun:
|
|
7
|
+
args: z.object({ dryRun: flag() }),
|
|
8
8
|
async run({ dryRun }, { logger }) {
|
|
9
9
|
logger.info({ dryRun }, "__moduleKebab__:__nameKebab__ ran");
|
|
10
10
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
2
|
import {
|
|
3
3
|
errorResponses,
|
|
4
|
+
jsonBody,
|
|
4
5
|
jsonContent,
|
|
5
6
|
paginated,
|
|
6
7
|
paginatedSchema,
|
|
@@ -72,7 +73,7 @@ router.openapi(
|
|
|
72
73
|
tags,
|
|
73
74
|
summary: "Create a __nameKebab__",
|
|
74
75
|
// tatara:slot guard-create
|
|
75
|
-
request: { body:
|
|
76
|
+
request: { body: jsonBody(create__namePascal__Schema, "The new __nameKebab__") },
|
|
76
77
|
responses: {
|
|
77
78
|
201: jsonContent(successSchema(__nameCamel__Schema), "Created"),
|
|
78
79
|
...errorResponses(400),
|
|
@@ -91,7 +92,7 @@ router.openapi(
|
|
|
91
92
|
// tatara:slot guard-update
|
|
92
93
|
request: {
|
|
93
94
|
params: __nameCamel__ParamsSchema,
|
|
94
|
-
body:
|
|
95
|
+
body: jsonBody(update__namePascal__Schema, "Fields to change"),
|
|
95
96
|
},
|
|
96
97
|
responses: {
|
|
97
98
|
200: jsonContent(successSchema(__nameCamel__Schema), "Updated"),
|