@tatara/cli 0.4.0 → 0.6.0
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 +377 -94
- package/package.json +1 -1
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +2 -2
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
- package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
- package/templates/features/authz/files/src/core/authz/index.ts +9 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
- package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
- package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
- package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
- package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
- package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/versions.json +4 -1
- package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
- package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
- package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
- package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
- package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
- package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
- package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
- package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
- package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
- package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
- package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
- package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
- package/templates/features/authz/files/src/core/authz/types.ts +0 -87
- package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
package/dist/bin.mjs
CHANGED
|
@@ -273,13 +273,12 @@ async function sync(project = loadProject()) {
|
|
|
273
273
|
const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
|
|
274
274
|
const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
|
|
275
275
|
const names = permissions.map((f) => identifier(f, "permissions"));
|
|
276
|
-
await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { mergeCatalogs, type PermissionKeysOf } from "
|
|
277
|
-
import corePermissions from "#core/authz/permissions";
|
|
276
|
+
await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
|
|
278
277
|
${imports.join("\n")}${imports.length ? "\n" : ""}
|
|
279
|
-
export const catalog = mergeCatalogs(
|
|
278
|
+
export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
|
|
280
279
|
|
|
281
280
|
export type PermissionKey =
|
|
282
|
-
| PermissionKeysOf<typeof
|
|
281
|
+
| PermissionKeysOf<typeof authzPermissions>${names.map((n) => `\n\t| PermissionKeysOf<typeof ${n}>`).join("")};
|
|
283
282
|
`, written);
|
|
284
283
|
}
|
|
285
284
|
if (project.config.database !== "none") {
|
|
@@ -682,6 +681,7 @@ const authz = {
|
|
|
682
681
|
id: "authz",
|
|
683
682
|
kind: "addon",
|
|
684
683
|
label: "Permission system",
|
|
684
|
+
dependencies: ["@tatara/authz"],
|
|
685
685
|
slots: [
|
|
686
686
|
{
|
|
687
687
|
file: "tests/helpers/auth.ts",
|
|
@@ -711,9 +711,9 @@ const authz = {
|
|
|
711
711
|
|
|
712
712
|
- Permissions are \`resource:action\` (optionally scoped \`own | team | org\`, where a wider scope
|
|
713
713
|
satisfies a narrower one). Each module declares its resources and the grants built-in roles get
|
|
714
|
-
in \`permissions/<name>.permissions.ts\` (\`definePermissions\`); \`tatara sync\` merges them into a
|
|
714
|
+
in \`permissions/<name>.permissions.ts\` (\`definePermissions\` from \`@tatara/authz\`); \`tatara sync\` merges them into a
|
|
715
715
|
typed catalog.
|
|
716
|
-
- Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/
|
|
716
|
+
- Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/index\`
|
|
717
717
|
and declare \`security: [{ bearerAuth: ["project:read"] }]\`. For scoped actions, call
|
|
718
718
|
\`resolveScope(principal, "doc:read")\` in the service and filter rows by the returned scope.
|
|
719
719
|
- Effective permissions = role grants (assigned roles + built-in roles whose key matches an
|
|
@@ -837,7 +837,7 @@ const admin = {
|
|
|
837
837
|
{
|
|
838
838
|
file: "src/core/http/app.ts",
|
|
839
839
|
slot: "imports",
|
|
840
|
-
content: "import { requireRole } from \"
|
|
840
|
+
content: "import { requireRole } from \"@tatara/auth\";"
|
|
841
841
|
},
|
|
842
842
|
{
|
|
843
843
|
file: "src/core/http/app.ts",
|
|
@@ -1077,6 +1077,7 @@ const auth = {
|
|
|
1077
1077
|
id: "auth",
|
|
1078
1078
|
kind: "auth",
|
|
1079
1079
|
label: "Auth (shared)",
|
|
1080
|
+
dependencies: ["@tatara/auth"],
|
|
1080
1081
|
slots: [
|
|
1081
1082
|
{
|
|
1082
1083
|
file: "src/core/http/app.ts",
|
|
@@ -1107,7 +1108,7 @@ const auth = {
|
|
|
1107
1108
|
|
|
1108
1109
|
- \`c.var.principal\` is set for every request with valid credentials: \`{ userId, orgId, roles, kind }\`.
|
|
1109
1110
|
- Protect routes with \`middleware: [requireAuth()]\` or \`requireRole("admin")\` from
|
|
1110
|
-
|
|
1111
|
+
\`@tatara/auth\`, and declare \`security: [{ bearerAuth: [] }]\` so the route is
|
|
1111
1112
|
documented as protected. Read the caller with \`currentPrincipal(c)\`.
|
|
1112
1113
|
- In tests, \`asUser()\` from \`#tests/helpers/auth\` returns headers for a fresh authenticated user.
|
|
1113
1114
|
`
|
|
@@ -1121,7 +1122,7 @@ const BETTER_AUTH_AGENTS = `### better-auth
|
|
|
1121
1122
|
- Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
|
|
1122
1123
|
- Outside production, every email and SMS is kept in an in-memory outbox:
|
|
1123
1124
|
\`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
|
|
1124
|
-
- To send real SMS, implement \`SmsSender\` in \`src/core/auth/sms.ts\`.
|
|
1125
|
+
- To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/core/auth/sms.ts\`.
|
|
1125
1126
|
`;
|
|
1126
1127
|
const drizzlePg = ["drizzle", "postgres"];
|
|
1127
1128
|
const drizzleMy = ["drizzle", "mysql"];
|
|
@@ -1161,12 +1162,12 @@ const betterAuth = {
|
|
|
1161
1162
|
{
|
|
1162
1163
|
file: "src/core/http/app.ts",
|
|
1163
1164
|
slot: "imports",
|
|
1164
|
-
content: "import {
|
|
1165
|
+
content: "import { outbox } from \"#core/auth/delivery\";"
|
|
1165
1166
|
},
|
|
1166
1167
|
{
|
|
1167
1168
|
file: "src/core/http/app.ts",
|
|
1168
1169
|
slot: "imports",
|
|
1169
|
-
content: "import { mergeAuthOpenApi } from \"
|
|
1170
|
+
content: "import { mergeAuthOpenApi } from \"@tatara/auth\";"
|
|
1170
1171
|
},
|
|
1171
1172
|
{
|
|
1172
1173
|
file: "src/core/http/app.ts",
|
|
@@ -1178,7 +1179,7 @@ const betterAuth = {
|
|
|
1178
1179
|
slot: "openapi-options",
|
|
1179
1180
|
content: [
|
|
1180
1181
|
"async extend(document) {",
|
|
1181
|
-
" if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);",
|
|
1182
|
+
" if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
|
|
1182
1183
|
"},"
|
|
1183
1184
|
].join("\n")
|
|
1184
1185
|
},
|
|
@@ -1189,8 +1190,8 @@ const betterAuth = {
|
|
|
1189
1190
|
content: [
|
|
1190
1191
|
"if (name === \"api\") {",
|
|
1191
1192
|
" app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => auth.handler(c.req.raw));",
|
|
1192
|
-
" if (
|
|
1193
|
-
" app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages:
|
|
1193
|
+
" if (outbox.enabled) {",
|
|
1194
|
+
" app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: outbox.read(c.req.query(\"to\")) }));",
|
|
1194
1195
|
" }",
|
|
1195
1196
|
"}"
|
|
1196
1197
|
].join("\n")
|
|
@@ -1453,7 +1454,7 @@ const authOrgs = {
|
|
|
1453
1454
|
file: "src/core/auth/auth.ts",
|
|
1454
1455
|
slot: "imports",
|
|
1455
1456
|
when: ["authz"],
|
|
1456
|
-
content: "import { removeMemberAccess } from \"#core/authz/
|
|
1457
|
+
content: "import { removeMemberAccess } from \"#core/authz/index\";"
|
|
1457
1458
|
},
|
|
1458
1459
|
{
|
|
1459
1460
|
file: "tests/helpers/auth.ts",
|
|
@@ -1468,64 +1469,11 @@ const authOrgs = {
|
|
|
1468
1469
|
...adminFields
|
|
1469
1470
|
]
|
|
1470
1471
|
};
|
|
1471
|
-
const KEYCLOAK_AGENTS = `### Keycloak
|
|
1472
|
-
|
|
1473
|
-
- The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
|
|
1474
|
-
\`keycloakTokens()\` better-auth plugin (\`src/core/auth/keycloak-plugin.ts\`) verifies signature,
|
|
1475
|
-
issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
|
|
1476
|
-
against the \`__APP_NAME__-web\` client.
|
|
1477
|
-
- The token's subject is the user id. The user (and a \`keycloak\` account) is created in
|
|
1478
|
-
better-auth's tables on first use; a token whose email already belongs to another account is
|
|
1479
|
-
rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
|
|
1480
|
-
- Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
|
|
1481
|
-
a built-in authz role grants it. The organization comes from the \`organization\` claim.
|
|
1482
|
-
- Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
|
|
1483
|
-
\`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
|
|
1484
|
-
- Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
|
|
1485
|
-
`;
|
|
1486
|
-
const KEYCLOAK_TEST_HELPER = [
|
|
1487
|
-
"const { publicKey, privateKey } = await generateKeyPair(\"RS256\");",
|
|
1488
|
-
"setKeySource(async (header) => {",
|
|
1489
|
-
" if (header.kid !== \"test\") throw new Error(\"unknown key\");",
|
|
1490
|
-
" return publicKey;",
|
|
1491
|
-
"});",
|
|
1492
|
-
"",
|
|
1493
|
-
"export interface KeycloakTokenOptions {",
|
|
1494
|
-
" sub?: string;",
|
|
1495
|
-
" email?: string;",
|
|
1496
|
-
" emailVerified?: boolean;",
|
|
1497
|
-
" roles?: string[];",
|
|
1498
|
-
" organization?: string[] | Record<string, unknown>;",
|
|
1499
|
-
" username?: string;",
|
|
1500
|
-
" issuer?: string;",
|
|
1501
|
-
" audience?: string;",
|
|
1502
|
-
" expiresIn?: string | number;",
|
|
1503
|
-
"}",
|
|
1504
|
-
"",
|
|
1505
|
-
"/** Signs an access token the API accepts, as if Keycloak issued it. */",
|
|
1506
|
-
"export async function keycloakToken(options: KeycloakTokenOptions = {}): Promise<string> {",
|
|
1507
|
-
" const sub = options.sub ?? newId();",
|
|
1508
|
-
" return new SignJWT({",
|
|
1509
|
-
" email: options.email ?? `user-${sub}@example.com`,",
|
|
1510
|
-
" email_verified: options.emailVerified ?? true,",
|
|
1511
|
-
" preferred_username: options.username ?? `user-${sub.slice(0, 8)}`,",
|
|
1512
|
-
" roles: options.roles ?? [\"user\"],",
|
|
1513
|
-
" ...(options.organization ? { organization: options.organization } : {}),",
|
|
1514
|
-
" })",
|
|
1515
|
-
" .setProtectedHeader({ alg: \"RS256\", kid: \"test\" })",
|
|
1516
|
-
" .setSubject(sub)",
|
|
1517
|
-
" .setIssuer(options.issuer ?? issuer)",
|
|
1518
|
-
" .setAudience(options.audience ?? env.KEYCLOAK_AUDIENCE)",
|
|
1519
|
-
" .setIssuedAt()",
|
|
1520
|
-
" .setExpirationTime(options.expiresIn ?? \"5m\")",
|
|
1521
|
-
" .sign(privateKey);",
|
|
1522
|
-
"}"
|
|
1523
|
-
].join("\n");
|
|
1524
1472
|
const authKeycloak = {
|
|
1525
1473
|
id: "auth-keycloak",
|
|
1526
1474
|
kind: "auth-method",
|
|
1527
1475
|
label: "Keycloak tokens",
|
|
1528
|
-
dependencies: ["jose"],
|
|
1476
|
+
dependencies: ["@tatara/keycloak", "jose"],
|
|
1529
1477
|
env: [
|
|
1530
1478
|
{
|
|
1531
1479
|
key: "KEYCLOAK_PORT",
|
|
@@ -1574,53 +1522,56 @@ const authKeycloak = {
|
|
|
1574
1522
|
{
|
|
1575
1523
|
file: "src/core/auth/auth.ts",
|
|
1576
1524
|
slot: "imports",
|
|
1577
|
-
content: "import { keycloakTokens } from \"
|
|
1525
|
+
content: "import { keycloakTokens } from \"@tatara/keycloak\";"
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
file: "src/core/auth/auth.ts",
|
|
1529
|
+
slot: "imports",
|
|
1530
|
+
content: "import { keycloak } from \"#core/auth/keycloak\";"
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
file: "src/core/auth/auth.ts",
|
|
1534
|
+
slot: "imports",
|
|
1535
|
+
content: "import { logger } from \"#core/logging/logger\";"
|
|
1578
1536
|
},
|
|
1579
1537
|
{
|
|
1580
1538
|
file: "src/core/auth/auth.ts",
|
|
1581
1539
|
slot: "plugins",
|
|
1582
1540
|
unless: ["auth-orgs"],
|
|
1583
|
-
content: "keycloakTokens(),",
|
|
1541
|
+
content: "keycloakTokens({ verifier: keycloak, logger }),",
|
|
1584
1542
|
order: 800
|
|
1585
1543
|
},
|
|
1586
1544
|
{
|
|
1587
1545
|
file: "src/core/auth/auth.ts",
|
|
1588
1546
|
slot: "plugins",
|
|
1589
1547
|
when: ["auth-orgs"],
|
|
1590
|
-
content: "keycloakTokens({ organizations: true }),",
|
|
1548
|
+
content: "keycloakTokens({ verifier: keycloak, logger, organizations: true }),",
|
|
1591
1549
|
order: 800
|
|
1592
1550
|
},
|
|
1593
1551
|
{
|
|
1594
1552
|
file: "src/core/auth/principal.ts",
|
|
1595
|
-
slot: "
|
|
1596
|
-
content:
|
|
1597
|
-
"if (!session) {",
|
|
1598
|
-
" const header = c.req.header(\"authorization\");",
|
|
1599
|
-
" const token = header?.startsWith(\"Bearer \") ? header.slice(7) : undefined;",
|
|
1600
|
-
" const principal = token ? await auth.api.verifyKeycloakToken({ body: { token } }) : null;",
|
|
1601
|
-
" if (principal) c.set(\"principal\", principal);",
|
|
1602
|
-
"}"
|
|
1603
|
-
].join("\n")
|
|
1553
|
+
slot: "resolvers",
|
|
1554
|
+
content: "(c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
|
|
1604
1555
|
},
|
|
1605
1556
|
{
|
|
1606
|
-
file: "
|
|
1557
|
+
file: "src/core/auth/principal.ts",
|
|
1607
1558
|
slot: "imports",
|
|
1608
|
-
content: "import {
|
|
1559
|
+
content: "import { keycloakPrincipal } from \"@tatara/keycloak\";"
|
|
1609
1560
|
},
|
|
1610
1561
|
{
|
|
1611
1562
|
file: "tests/helpers/auth.ts",
|
|
1612
1563
|
slot: "imports",
|
|
1613
|
-
content: "import {
|
|
1564
|
+
content: "import { useTestKeys } from \"@tatara/keycloak/testing\";"
|
|
1614
1565
|
},
|
|
1615
1566
|
{
|
|
1616
1567
|
file: "tests/helpers/auth.ts",
|
|
1617
1568
|
slot: "imports",
|
|
1618
|
-
content: "import {
|
|
1569
|
+
content: "import { keycloak } from \"#core/auth/keycloak\";"
|
|
1619
1570
|
},
|
|
1620
1571
|
{
|
|
1621
1572
|
file: "tests/helpers/auth.ts",
|
|
1622
1573
|
slot: "helpers",
|
|
1623
|
-
content:
|
|
1574
|
+
content: ["/** Signs an access token the API accepts, as if Keycloak issued it. */", "export const keycloakToken = await useTestKeys(keycloak);"].join("\n")
|
|
1624
1575
|
},
|
|
1625
1576
|
{
|
|
1626
1577
|
file: "tests/helpers/auth.ts",
|
|
@@ -1628,7 +1579,7 @@ const authKeycloak = {
|
|
|
1628
1579
|
unless: ["auth-orgs"],
|
|
1629
1580
|
content: [
|
|
1630
1581
|
"if (options.roles) {",
|
|
1631
|
-
" const sub =
|
|
1582
|
+
" const sub = crypto.randomUUID();",
|
|
1632
1583
|
" const token = await keycloakToken({ sub, roles: options.roles, email: options.email });",
|
|
1633
1584
|
" return { userId: sub, headers: { authorization: `Bearer ${token}` } };",
|
|
1634
1585
|
"}"
|
|
@@ -1637,7 +1588,21 @@ const authKeycloak = {
|
|
|
1637
1588
|
{
|
|
1638
1589
|
file: "AGENTS.md",
|
|
1639
1590
|
slot: "sections",
|
|
1640
|
-
content:
|
|
1591
|
+
content: `### Keycloak
|
|
1592
|
+
|
|
1593
|
+
- The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
|
|
1594
|
+
\`keycloakTokens()\` better-auth plugin (\`@tatara/keycloak\`) verifies signature,
|
|
1595
|
+
issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
|
|
1596
|
+
against the \`__APP_NAME__-web\` client.
|
|
1597
|
+
- The token's subject is the user id. The user (and a \`keycloak\` account) is created in
|
|
1598
|
+
better-auth's tables on first use; a token whose email already belongs to another account is
|
|
1599
|
+
rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
|
|
1600
|
+
- Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
|
|
1601
|
+
a built-in authz role grants it. The organization comes from the \`organization\` claim.
|
|
1602
|
+
- Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
|
|
1603
|
+
\`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
|
|
1604
|
+
- Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
|
|
1605
|
+
`
|
|
1641
1606
|
}
|
|
1642
1607
|
]
|
|
1643
1608
|
};
|
|
@@ -2487,11 +2452,11 @@ function guardSlots(project, n) {
|
|
|
2487
2452
|
order: fills.length
|
|
2488
2453
|
});
|
|
2489
2454
|
if (project.has("authz")) {
|
|
2490
|
-
add("imports", "import { requirePermission } from \"#core/authz/
|
|
2455
|
+
add("imports", "import { requirePermission } from \"#core/authz/index\";");
|
|
2491
2456
|
for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
|
|
2492
2457
|
add("auth-errors", "...errorResponses(401, 403),");
|
|
2493
2458
|
} else if (project.has("auth")) {
|
|
2494
|
-
add("imports", "import { requireAuth } from \"
|
|
2459
|
+
add("imports", "import { requireAuth } from \"@tatara/auth\";");
|
|
2495
2460
|
for (const route of Object.keys(ACTIONS)) add(`guard-${route}`, "middleware: [requireAuth()] as const,\nsecurity: [{ bearerAuth: [] }],");
|
|
2496
2461
|
add("auth-errors", "...errorResponses(401),");
|
|
2497
2462
|
}
|
|
@@ -3221,6 +3186,316 @@ function createBaseline(project, deps) {
|
|
|
3221
3186
|
} };
|
|
3222
3187
|
}
|
|
3223
3188
|
//#endregion
|
|
3189
|
+
//#region src/upgrade/codemods/imports.ts
|
|
3190
|
+
const escapeRegExp$1 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3191
|
+
/**
|
|
3192
|
+
* The names `text` imports (or re-exports) from `specifier`, or null when it doesn't use it.
|
|
3193
|
+
* A namespace, default, side-effect or dynamic import gives ["*"]: it could reach any name.
|
|
3194
|
+
*/
|
|
3195
|
+
function importsOf(text, specifier) {
|
|
3196
|
+
const spec = `["']${escapeRegExp$1(specifier)}["']`;
|
|
3197
|
+
const names = [];
|
|
3198
|
+
let found = false;
|
|
3199
|
+
const fromClause = new RegExp(`(?:import|export)\\s+(?:type\\s+)?([^;]*?)\\s+from\\s+${spec}`, "g");
|
|
3200
|
+
for (const match of text.matchAll(fromClause)) {
|
|
3201
|
+
found = true;
|
|
3202
|
+
const braces = (match[1] ?? "").match(/^\{([^}]*)\}$/);
|
|
3203
|
+
if (!braces) {
|
|
3204
|
+
names.push("*");
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
for (const part of (braces[1] ?? "").split(",")) {
|
|
3208
|
+
const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
3209
|
+
if (name) names.push(name);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
if (new RegExp(`import\\s*\\(\\s*${spec}\\s*\\)|import\\s+${spec}`).test(text)) {
|
|
3213
|
+
found = true;
|
|
3214
|
+
names.push("*");
|
|
3215
|
+
}
|
|
3216
|
+
return found ? names : null;
|
|
3217
|
+
}
|
|
3218
|
+
//#endregion
|
|
3219
|
+
//#region src/upgrade/codemods/auth-package.ts
|
|
3220
|
+
const AUTH = "@tatara/auth";
|
|
3221
|
+
const KEYCLOAK = "@tatara/keycloak";
|
|
3222
|
+
const APP$1 = "src/core/http/app.ts";
|
|
3223
|
+
const HELPERS = "tests/helpers/auth.ts";
|
|
3224
|
+
function groups(keycloak) {
|
|
3225
|
+
const all = [
|
|
3226
|
+
{
|
|
3227
|
+
name: "OpenAPI merge and dev outbox",
|
|
3228
|
+
regenerate: [APP$1, "src/core/auth/delivery.ts"],
|
|
3229
|
+
remove: ["src/core/auth/openapi.ts"],
|
|
3230
|
+
exports: [{ specifier: "#core/auth/openapi" }, {
|
|
3231
|
+
specifier: "#core/auth/delivery",
|
|
3232
|
+
names: [
|
|
3233
|
+
"outboxEnabled",
|
|
3234
|
+
"readOutbox",
|
|
3235
|
+
"OutboxMessage"
|
|
3236
|
+
]
|
|
3237
|
+
}]
|
|
3238
|
+
},
|
|
3239
|
+
{
|
|
3240
|
+
name: "SMS sender",
|
|
3241
|
+
regenerate: ["src/core/auth/sms.ts"],
|
|
3242
|
+
remove: [],
|
|
3243
|
+
exports: [{
|
|
3244
|
+
specifier: "#core/auth/sms",
|
|
3245
|
+
names: ["SmsSender"]
|
|
3246
|
+
}]
|
|
3247
|
+
},
|
|
3248
|
+
{
|
|
3249
|
+
name: "principal middleware",
|
|
3250
|
+
regenerate: ["src/core/auth/principal.ts"],
|
|
3251
|
+
remove: [],
|
|
3252
|
+
exports: []
|
|
3253
|
+
}
|
|
3254
|
+
];
|
|
3255
|
+
if (!keycloak) {
|
|
3256
|
+
all.push({
|
|
3257
|
+
name: "test helpers",
|
|
3258
|
+
regenerate: [HELPERS],
|
|
3259
|
+
remove: [],
|
|
3260
|
+
exports: []
|
|
3261
|
+
});
|
|
3262
|
+
return all;
|
|
3263
|
+
}
|
|
3264
|
+
all.push({
|
|
3265
|
+
name: "Keycloak",
|
|
3266
|
+
regenerate: [
|
|
3267
|
+
"src/core/auth/auth.ts",
|
|
3268
|
+
"src/core/auth/keycloak.ts",
|
|
3269
|
+
"src/core/auth/commands/keycloak-token.ts",
|
|
3270
|
+
"src/core/auth/tests/keycloak-tokens.test.ts",
|
|
3271
|
+
HELPERS
|
|
3272
|
+
],
|
|
3273
|
+
remove: ["src/core/auth/keycloak-plugin.ts"],
|
|
3274
|
+
exports: [
|
|
3275
|
+
{ specifier: "#core/auth/keycloak-plugin" },
|
|
3276
|
+
{
|
|
3277
|
+
specifier: "#core/auth/keycloak",
|
|
3278
|
+
names: [
|
|
3279
|
+
"issuer",
|
|
3280
|
+
"setKeySource",
|
|
3281
|
+
"verifyAccessToken",
|
|
3282
|
+
"rolesOf",
|
|
3283
|
+
"organizationOf",
|
|
3284
|
+
"KeycloakClaims"
|
|
3285
|
+
]
|
|
3286
|
+
},
|
|
3287
|
+
{
|
|
3288
|
+
specifier: "#tests/helpers/auth",
|
|
3289
|
+
names: ["KeycloakTokenOptions"]
|
|
3290
|
+
}
|
|
3291
|
+
]
|
|
3292
|
+
});
|
|
3293
|
+
return all;
|
|
3294
|
+
}
|
|
3295
|
+
async function read$3(ctx, path) {
|
|
3296
|
+
return ctx.read(path).catch(() => void 0);
|
|
3297
|
+
}
|
|
3298
|
+
/** True when `text` imports `entry` (or one of its gone names). */
|
|
3299
|
+
function usesExport(text, entry) {
|
|
3300
|
+
const names = importsOf(text, entry.specifier);
|
|
3301
|
+
if (!names) return false;
|
|
3302
|
+
const gone = entry.names;
|
|
3303
|
+
return !gone || names.some((name) => name === "*" || gone.includes(name));
|
|
3304
|
+
}
|
|
3305
|
+
/** Why the group can't move, or [] when it can. */
|
|
3306
|
+
async function blockersOf(ctx, group, sources) {
|
|
3307
|
+
const blockers = [];
|
|
3308
|
+
for (const path of [...group.regenerate, ...group.remove]) {
|
|
3309
|
+
const text = await read$3(ctx, path);
|
|
3310
|
+
if (text === void 0) continue;
|
|
3311
|
+
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3312
|
+
}
|
|
3313
|
+
const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
|
|
3314
|
+
for (const path of sources) {
|
|
3315
|
+
if (own.has(path)) continue;
|
|
3316
|
+
const text = await ctx.read(path);
|
|
3317
|
+
if (group.exports.some((entry) => usesExport(text, entry))) blockers.push(`${path} imports what moves`);
|
|
3318
|
+
}
|
|
3319
|
+
return blockers;
|
|
3320
|
+
}
|
|
3321
|
+
const authPackage = {
|
|
3322
|
+
version: "0.5.0",
|
|
3323
|
+
id: "auth-package",
|
|
3324
|
+
description: "Use @tatara/auth (guards, principal, OpenAPI merge, outbox) and @tatara/keycloak",
|
|
3325
|
+
async run(ctx) {
|
|
3326
|
+
const { config } = ctx.project;
|
|
3327
|
+
if (config.auth === "none") return;
|
|
3328
|
+
const keycloak = config.authMethods.includes("keycloak");
|
|
3329
|
+
await ctx.addDependency(AUTH, versionOf(AUTH));
|
|
3330
|
+
if (keycloak) await ctx.addDependency(KEYCLOAK, versionOf(KEYCLOAK));
|
|
3331
|
+
const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3332
|
+
cwd: ctx.project.root,
|
|
3333
|
+
ignore: ["src/generated/**"]
|
|
3334
|
+
});
|
|
3335
|
+
const ready = [];
|
|
3336
|
+
for (const group of groups(keycloak)) {
|
|
3337
|
+
const blockers = await blockersOf(ctx, group, sources);
|
|
3338
|
+
if (blockers.length === 0) ready.push(group);
|
|
3339
|
+
else ctx.report.notes.push(`${group.name} was not moved onto ${group.name === "Keycloak" ? KEYCLOAK : AUTH}: ${blockers.join("; ")}. Compare these files with a newly generated app and move them by hand.`);
|
|
3340
|
+
}
|
|
3341
|
+
for (const group of ready) {
|
|
3342
|
+
for (const path of group.regenerate) {
|
|
3343
|
+
if (await read$3(ctx, path) === void 0) continue;
|
|
3344
|
+
const next = await ctx.renderCurrent(path);
|
|
3345
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3346
|
+
}
|
|
3347
|
+
for (const path of group.remove) await ctx.removeIfUnchanged(path);
|
|
3348
|
+
}
|
|
3349
|
+
if (await ctx.removeIfUnchanged("src/core/auth/middleware.ts") === "kept") ctx.report.notes.push("src/core/auth/middleware.ts is customised; its imports still point at it.");
|
|
3350
|
+
else await ctx.rewriteImports("#core/auth/middleware", AUTH);
|
|
3351
|
+
}
|
|
3352
|
+
};
|
|
3353
|
+
//#endregion
|
|
3354
|
+
//#region src/upgrade/codemods/authz-package.ts
|
|
3355
|
+
const AUTHZ = "@tatara/authz";
|
|
3356
|
+
const INDEX = "#core/authz/index";
|
|
3357
|
+
const DIR = "src/core/authz";
|
|
3358
|
+
/** Copies the package replaces. */
|
|
3359
|
+
const REMOVED = [
|
|
3360
|
+
"cache.ts",
|
|
3361
|
+
"catalog.ts",
|
|
3362
|
+
"constants.ts",
|
|
3363
|
+
"middleware.ts",
|
|
3364
|
+
"permissions.ts",
|
|
3365
|
+
"resolver.ts",
|
|
3366
|
+
"types.ts",
|
|
3367
|
+
"validators.ts",
|
|
3368
|
+
"services/audit.ts",
|
|
3369
|
+
"services/grants.ts",
|
|
3370
|
+
"services/members.ts",
|
|
3371
|
+
"services/roles.ts",
|
|
3372
|
+
"services/sync.ts"
|
|
3373
|
+
].map((f) => `${DIR}/${f}`);
|
|
3374
|
+
/** App files that stay (or are new), and become what this CLI generates. system-roles.ts is the app's config and stays as it is. */
|
|
3375
|
+
const REGENERATED$1 = [
|
|
3376
|
+
`${DIR}/index.ts`,
|
|
3377
|
+
`${DIR}/db/seed.ts`,
|
|
3378
|
+
`${DIR}/commands/grant-role.ts`,
|
|
3379
|
+
`${DIR}/commands/sync-permissions.ts`,
|
|
3380
|
+
`${DIR}/routes/authorization.routes.ts`,
|
|
3381
|
+
`${DIR}/tests/authz.test.ts`,
|
|
3382
|
+
"src/core/auth/tests/keycloak-authz.test.ts",
|
|
3383
|
+
"tests/helpers/authz.ts"
|
|
3384
|
+
];
|
|
3385
|
+
/** Where other files' imports go, and the names that exist there. undefined: every name. */
|
|
3386
|
+
const MOVES = [
|
|
3387
|
+
{
|
|
3388
|
+
from: "#core/authz/constants",
|
|
3389
|
+
to: AUTHZ
|
|
3390
|
+
},
|
|
3391
|
+
{
|
|
3392
|
+
from: "#core/authz/types",
|
|
3393
|
+
to: AUTHZ
|
|
3394
|
+
},
|
|
3395
|
+
{
|
|
3396
|
+
from: "#core/authz/catalog",
|
|
3397
|
+
to: AUTHZ
|
|
3398
|
+
},
|
|
3399
|
+
{
|
|
3400
|
+
from: "#core/authz/validators",
|
|
3401
|
+
to: AUTHZ
|
|
3402
|
+
},
|
|
3403
|
+
{
|
|
3404
|
+
from: "#core/authz/middleware",
|
|
3405
|
+
to: INDEX,
|
|
3406
|
+
names: ["requirePermission"]
|
|
3407
|
+
},
|
|
3408
|
+
{
|
|
3409
|
+
from: "#core/authz/resolver",
|
|
3410
|
+
to: INDEX,
|
|
3411
|
+
names: ["checkPermission", "resolveScope"]
|
|
3412
|
+
},
|
|
3413
|
+
{
|
|
3414
|
+
from: "#core/authz/services/members",
|
|
3415
|
+
to: INDEX,
|
|
3416
|
+
names: ["removeMemberAccess"]
|
|
3417
|
+
},
|
|
3418
|
+
{
|
|
3419
|
+
from: "#core/authz/cache",
|
|
3420
|
+
to: INDEX,
|
|
3421
|
+
names: []
|
|
3422
|
+
},
|
|
3423
|
+
{
|
|
3424
|
+
from: "#core/authz/permissions",
|
|
3425
|
+
to: INDEX,
|
|
3426
|
+
names: []
|
|
3427
|
+
},
|
|
3428
|
+
...[
|
|
3429
|
+
"audit",
|
|
3430
|
+
"grants",
|
|
3431
|
+
"roles",
|
|
3432
|
+
"sync"
|
|
3433
|
+
].map((s) => ({
|
|
3434
|
+
from: `#core/authz/services/${s}`,
|
|
3435
|
+
to: INDEX,
|
|
3436
|
+
names: []
|
|
3437
|
+
}))
|
|
3438
|
+
];
|
|
3439
|
+
/** Names the 0.5 constants file had that the package dropped. */
|
|
3440
|
+
const DROPPED_CONSTANTS = ["CACHE_TTL_MS"];
|
|
3441
|
+
async function read$2(ctx, path) {
|
|
3442
|
+
return ctx.read(path).catch(() => void 0);
|
|
3443
|
+
}
|
|
3444
|
+
const authzPackage = {
|
|
3445
|
+
version: "0.6.0",
|
|
3446
|
+
id: "authz-package",
|
|
3447
|
+
description: "Use @tatara/authz (catalog, resolver, guards, services, routes)",
|
|
3448
|
+
async run(ctx) {
|
|
3449
|
+
if (!ctx.project.config.addons.includes("authz")) return;
|
|
3450
|
+
await ctx.addDependency(AUTHZ, versionOf(AUTHZ));
|
|
3451
|
+
const blockers = [];
|
|
3452
|
+
const regenerate = [];
|
|
3453
|
+
for (const path of REGENERATED$1) {
|
|
3454
|
+
const text = await read$2(ctx, path);
|
|
3455
|
+
if (text === void 0) {
|
|
3456
|
+
if (path === `${DIR}/index.ts`) regenerate.push(path);
|
|
3457
|
+
continue;
|
|
3458
|
+
}
|
|
3459
|
+
if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
|
|
3460
|
+
else blockers.push(`${path} is customised`);
|
|
3461
|
+
}
|
|
3462
|
+
const removed = [];
|
|
3463
|
+
for (const path of REMOVED) {
|
|
3464
|
+
const text = await read$2(ctx, path);
|
|
3465
|
+
if (text === void 0) continue;
|
|
3466
|
+
removed.push(path);
|
|
3467
|
+
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3468
|
+
}
|
|
3469
|
+
const own = /* @__PURE__ */ new Set([...REGENERATED$1, ...REMOVED]);
|
|
3470
|
+
const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3471
|
+
cwd: ctx.project.root,
|
|
3472
|
+
ignore: ["src/generated/**"]
|
|
3473
|
+
});
|
|
3474
|
+
for (const path of sources) {
|
|
3475
|
+
if (own.has(path)) continue;
|
|
3476
|
+
const text = await ctx.read(path);
|
|
3477
|
+
for (const move of MOVES) {
|
|
3478
|
+
const names = importsOf(text, move.from);
|
|
3479
|
+
if (!names) continue;
|
|
3480
|
+
const allowed = move.names;
|
|
3481
|
+
const missing = names.filter((n) => n === "*" || (allowed ? !allowed.includes(n) : move.from.endsWith("/constants") && DROPPED_CONSTANTS.includes(n)));
|
|
3482
|
+
if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${move.from}`);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
if (blockers.length > 0) {
|
|
3486
|
+
ctx.report.notes.push(`Nothing in ${DIR} was changed: ${blockers.join("; ")}. Compare with a newly generated app and move onto @tatara/authz (createAuthz in ${DIR}/index.ts) by hand.`);
|
|
3487
|
+
ctx.report.blocked.push("authz-package");
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
for (const path of regenerate) {
|
|
3491
|
+
const next = await ctx.renderCurrent(path);
|
|
3492
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3493
|
+
}
|
|
3494
|
+
for (const path of removed) await ctx.removeIfUnchanged(path);
|
|
3495
|
+
for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
//#endregion
|
|
3224
3499
|
//#region src/upgrade/codemods/core-package.ts
|
|
3225
3500
|
const CORE = "@tatara/core";
|
|
3226
3501
|
/** Files @tatara/core replaces, with the import specifier apps used for each. */
|
|
@@ -3480,6 +3755,7 @@ const CODEMODS = [
|
|
|
3480
3755
|
for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
|
|
3481
3756
|
if (blockers.length > 0) {
|
|
3482
3757
|
ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
|
|
3758
|
+
ctx.report.blocked.push("http-package");
|
|
3483
3759
|
return;
|
|
3484
3760
|
}
|
|
3485
3761
|
for (const path of replace) {
|
|
@@ -3494,7 +3770,9 @@ const CODEMODS = [
|
|
|
3494
3770
|
]) await ctx.rewriteImports(specifier, HTTP);
|
|
3495
3771
|
}
|
|
3496
3772
|
},
|
|
3497
|
-
dbTesting
|
|
3773
|
+
dbTesting,
|
|
3774
|
+
authPackage,
|
|
3775
|
+
authzPackage
|
|
3498
3776
|
];
|
|
3499
3777
|
//#endregion
|
|
3500
3778
|
//#region src/upgrade/context.ts
|
|
@@ -3506,7 +3784,8 @@ function createContext(project, baseline, opts = {}) {
|
|
|
3506
3784
|
regenerated: [],
|
|
3507
3785
|
removed: [],
|
|
3508
3786
|
kept: [],
|
|
3509
|
-
notes: []
|
|
3787
|
+
notes: [],
|
|
3788
|
+
blocked: []
|
|
3510
3789
|
};
|
|
3511
3790
|
const abs = (path) => join(project.root, path);
|
|
3512
3791
|
let rendered;
|
|
@@ -3618,13 +3897,16 @@ async function upgrade(project, opts = {}) {
|
|
|
3618
3897
|
const from = project.config.version;
|
|
3619
3898
|
const ctx = createContext(project, opts.baseline ?? createBaseline(project), { dryRun: opts.dryRun });
|
|
3620
3899
|
const due = [...opts.codemods ?? CODEMODS].filter((m) => compareVersions(m.version, from) > 0 && compareVersions(m.version, target) <= 0).sort((a, b) => compareVersions(a.version, b.version));
|
|
3900
|
+
let reached = target;
|
|
3621
3901
|
for (const mod of due) {
|
|
3902
|
+
const blockedBefore = ctx.report.blocked.length;
|
|
3622
3903
|
try {
|
|
3623
3904
|
await mod.run(ctx);
|
|
3624
3905
|
} catch (error) {
|
|
3625
3906
|
throw new Error(`codemod ${mod.id} (${mod.version}) failed: ${error.message}`, { cause: error });
|
|
3626
3907
|
}
|
|
3627
3908
|
ctx.report.ran.push(mod.id);
|
|
3909
|
+
if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
|
|
3628
3910
|
}
|
|
3629
3911
|
if (opts.dryRun) return ctx.report;
|
|
3630
3912
|
const format = opts.formatFile ?? biomeFormat;
|
|
@@ -3634,9 +3916,9 @@ async function upgrade(project, opts = {}) {
|
|
|
3634
3916
|
const config = JSON.parse(await readFile(path, "utf8"));
|
|
3635
3917
|
const next = {
|
|
3636
3918
|
...config,
|
|
3637
|
-
version:
|
|
3919
|
+
version: reached,
|
|
3638
3920
|
generated: {
|
|
3639
|
-
cli:
|
|
3921
|
+
cli: reached,
|
|
3640
3922
|
files: await nextRecord(project, ctx.report)
|
|
3641
3923
|
}
|
|
3642
3924
|
};
|
|
@@ -3656,6 +3938,7 @@ function printUpgrade(report, from, to) {
|
|
|
3656
3938
|
for (const path of report.removed) console.log(pc.dim(` removed ${path} (now provided by a package)`));
|
|
3657
3939
|
for (const { path } of report.kept) console.log(pc.yellow(` kept ${path}: changed in this app; move the change into options or eject`));
|
|
3658
3940
|
for (const note of report.notes) console.log(` ${note}`);
|
|
3941
|
+
if (report.blocked.length > 0) console.log(pc.yellow(` ${report.blocked.join(", ")} did not run: make the changes above, then run tatara upgrade again`));
|
|
3659
3942
|
}
|
|
3660
3943
|
//#endregion
|
|
3661
3944
|
//#region src/bin.ts
|