@drhomet/mithaq 0.0.0-stage → 0.2.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/LICENSE +22 -0
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/README.md +160 -2
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +239 -0
- package/dist/asas/build.js +834 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +177 -0
- package/dist/asas/cli/upgrade.d.ts +33 -0
- package/dist/asas/cli/upgrade.js +256 -0
- package/dist/asas/http/fetch.d.ts +41 -0
- package/dist/asas/http/fetch.js +178 -0
- package/dist/asas/http/orpc.d.ts +45 -0
- package/dist/asas/http/orpc.js +153 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +196 -0
- package/dist/asas/runner/errors.d.ts +63 -0
- package/dist/asas/runner/errors.js +75 -0
- package/dist/asas/runner/read.d.ts +72 -0
- package/dist/asas/runner/read.js +601 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +418 -0
- package/dist/asas/runner/steps.js +277 -0
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +275 -0
- package/dist/asas/runner/write.d.ts +606 -0
- package/dist/asas/runner/write.js +1019 -0
- package/dist/auth/better-auth/access.d.ts +29 -0
- package/dist/auth/better-auth/access.js +103 -0
- package/dist/auth/better-auth/generate.d.ts +31 -0
- package/dist/auth/better-auth/generate.js +328 -0
- package/dist/auth/better-auth/index.d.ts +94 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +26 -0
- package/dist/auth/better-auth/tenant.js +34 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +738 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +124 -0
- package/dist/base/fields.js +52 -0
- package/dist/base/index.d.ts +47 -0
- package/dist/base/index.js +5 -0
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +75 -0
- package/dist/base/kinds.js +123 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +81 -0
- package/dist/drizzle/index.d.ts +308 -0
- package/dist/drizzle/index.js +951 -0
- package/dist/drizzle/kinds.d.ts +28 -0
- package/dist/drizzle/kinds.js +43 -0
- package/dist/drizzle/migrate.d.ts +159 -0
- package/dist/drizzle/migrate.js +1328 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +26 -0
- package/dist/mithaq/define.d.ts +335 -0
- package/dist/mithaq/define.js +57 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +235 -0
- package/dist/mithaq/language.d.ts +374 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +111 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +1014 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +386 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +28 -0
- package/dist/rich-text/index.js +59 -0
- package/dist/rich-text/rich-text.d.ts +106 -0
- package/dist/rich-text/rich-text.js +249 -0
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +156 -0
- package/dist/screen/client.js +71 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +268 -0
- package/dist/screen/index.d.ts +496 -0
- package/dist/screen/index.js +663 -0
- package/dist/screen/react.d.ts +97 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +42 -0
- package/dist/storage/index.js +43 -0
- package/dist/storage/storage.d.ts +386 -0
- package/dist/storage/storage.js +412 -0
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +193 -0
- package/dist/zod/index.js +122 -0
- package/dist/zod/kinds.d.ts +36 -0
- package/dist/zod/kinds.js +58 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/docs/packages/mithaq/README.md +962 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1056 -0
- package/package.json +92 -4
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RowTable } from '../../mithaq/rows.ts';
|
|
2
|
+
import { type AccessOptions, type AccessTarget, type SystemRoles } from '../../rbac/index.ts';
|
|
3
|
+
export type BetterAuthAccessOptions<Roles extends SystemRoles, Table extends RowTable, Actor> = AccessOptions<Roles, Table, Actor> & {
|
|
4
|
+
/** The provider's own compile-time roles, each an `ac.newRole(...)` result. */
|
|
5
|
+
statements?: Readonly<Record<string, {
|
|
6
|
+
statements: Readonly<Record<string, readonly string[]>>;
|
|
7
|
+
}>>;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The access target over Better Auth's own `member.role` and nothing else: a member holds the system
|
|
11
|
+
* roles its membership row names in that organization, comma-separated, and there is no custom role
|
|
12
|
+
* and no assignment table. It suits a project whose roles are the provider's owner, admin and member.
|
|
13
|
+
* The row is read from the store for the organization asked about, never off the session, so a
|
|
14
|
+
* membership elsewhere grants nothing here.
|
|
15
|
+
*
|
|
16
|
+
* A `member.role` slug is one a tenant's admin can mint, since the provider's dynamic access control
|
|
17
|
+
* is on, so `roles` holds only slugs a tenant may hold, or `rolesOf` drops the rest: a backoffice role
|
|
18
|
+
* named in it would otherwise grant its keys inside the tenant.
|
|
19
|
+
*/
|
|
20
|
+
export declare const memberRoleTarget: <const Roles extends SystemRoles, const Table extends RowTable, Actor>({ roles, rolesOf, access, }: Omit<AccessOptions<Roles, Table, Actor>, 'kayan'>) => AccessTarget<Roles, Table, Actor> & {
|
|
21
|
+
readonly type: 'memberRole';
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The access target over Better Auth's store: a custom role is an `organizationRole` row.
|
|
25
|
+
* Read directly, not through `hasPermission`, whose cache never invalidates a narrowed role.
|
|
26
|
+
*/
|
|
27
|
+
export declare const betterAuthTarget: <const Roles extends SystemRoles, const Table extends RowTable, Actor>({ roles, rolesOf, access, kayan, statements, }: BetterAuthAccessOptions<Roles, Table, Actor>) => AccessTarget<Roles, Table, Actor> & {
|
|
28
|
+
readonly type: 'betterAuth';
|
|
29
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { allRows, assignedSlugs, heldSlugs, rbacTarget, scoped, systemKeys } from '../../rbac/index.js';
|
|
2
|
+
// An action list that is not a list of strings contributes nothing: a string
|
|
3
|
+
// would flatten character by character.
|
|
4
|
+
const flatten = (statements, into) => {
|
|
5
|
+
for (const [resource, actions] of Object.entries(statements)) {
|
|
6
|
+
if (!Array.isArray(actions))
|
|
7
|
+
continue;
|
|
8
|
+
for (const action of actions)
|
|
9
|
+
if (typeof action === 'string')
|
|
10
|
+
into.add(`${resource}.${action}`);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
// Better Auth stores `permission` as a JSON string, not the record its zod object describes.
|
|
14
|
+
const readPermission = (value) => {
|
|
15
|
+
if (typeof value !== 'string')
|
|
16
|
+
return undefined;
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(value);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
25
|
+
return undefined;
|
|
26
|
+
for (const actions of Object.values(parsed)) {
|
|
27
|
+
if (!Array.isArray(actions) || actions.some((action) => typeof action !== 'string'))
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The access target over Better Auth's own `member.role` and nothing else: a member holds the system
|
|
34
|
+
* roles its membership row names in that organization, comma-separated, and there is no custom role
|
|
35
|
+
* and no assignment table. It suits a project whose roles are the provider's owner, admin and member.
|
|
36
|
+
* The row is read from the store for the organization asked about, never off the session, so a
|
|
37
|
+
* membership elsewhere grants nothing here.
|
|
38
|
+
*
|
|
39
|
+
* A `member.role` slug is one a tenant's admin can mint, since the provider's dynamic access control
|
|
40
|
+
* is on, so `roles` holds only slugs a tenant may hold, or `rolesOf` drops the rest: a backoffice role
|
|
41
|
+
* named in it would otherwise grant its keys inside the tenant.
|
|
42
|
+
*/
|
|
43
|
+
export const memberRoleTarget = ({ roles, rolesOf, access, }) => ({
|
|
44
|
+
capability: 'access',
|
|
45
|
+
type: 'memberRole',
|
|
46
|
+
roles,
|
|
47
|
+
...(rolesOf ? { rolesOf } : {}),
|
|
48
|
+
access: (access ?? {}),
|
|
49
|
+
// Better Auth's own model and columns.
|
|
50
|
+
reads: ['member'],
|
|
51
|
+
resolve: async ({ store, tenant, member, actor }) => {
|
|
52
|
+
// A read without a tenant would be every tenant's; the actor still goes through `rolesOf`, which
|
|
53
|
+
// answers backoffice staff, who have no active organization, their backoffice roles.
|
|
54
|
+
const found = scoped(tenant) && scoped(member)
|
|
55
|
+
? await allRows({ store, kayan: 'member', where: { and: [{ organizationId: { equals: tenant } }, { userId: { equals: member } }] } })
|
|
56
|
+
: { docs: [] };
|
|
57
|
+
// Split the way the provider's own owner checks split it: exact, untrimmed, so ` owner` is no owner.
|
|
58
|
+
const slugs = [...new Set(found.docs.flatMap((row) => (typeof row.role === 'string' ? row.role.split(',').filter(Boolean) : [])))];
|
|
59
|
+
const held = heldSlugs({ roles, rolesOf, slugs, actor });
|
|
60
|
+
const { everything, keys } = systemKeys(roles, held);
|
|
61
|
+
return { permissions: everything ? '*' : [...keys], roles: held };
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The access target over Better Auth's store: a custom role is an `organizationRole` row.
|
|
66
|
+
* Read directly, not through `hasPermission`, whose cache never invalidates a narrowed role.
|
|
67
|
+
*/
|
|
68
|
+
export const betterAuthTarget = ({ roles, rolesOf, access, kayan, statements = {}, }) => {
|
|
69
|
+
const base = rbacTarget({ access, kayan, roles, rolesOf });
|
|
70
|
+
const { memberRoles, roles: roleKayan } = kayan;
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
type: 'betterAuth',
|
|
74
|
+
reads: [roleKayan, memberRoles],
|
|
75
|
+
resolve: async ({ store, tenant, member, actor, ownership }) => {
|
|
76
|
+
const slugs = await assignedSlugs({ store, kayan: memberRoles, field: ownership(memberRoles), tenant, member });
|
|
77
|
+
const held = heldSlugs({ roles, rolesOf, slugs, actor });
|
|
78
|
+
const { custom, everything, keys } = systemKeys(roles, held);
|
|
79
|
+
if (everything)
|
|
80
|
+
return { permissions: '*', roles: slugs };
|
|
81
|
+
for (const slug of held) {
|
|
82
|
+
const role = statements[slug];
|
|
83
|
+
if (role?.statements)
|
|
84
|
+
flatten(role.statements, keys);
|
|
85
|
+
}
|
|
86
|
+
if (custom.length && scoped(tenant)) {
|
|
87
|
+
// Caught, so a role table that is down loses the keys and not the assignment.
|
|
88
|
+
const found = await allRows({ store, kayan: roleKayan, where: {
|
|
89
|
+
and: [{ organizationId: { equals: tenant } }, { role: { in: custom } }],
|
|
90
|
+
} }).catch(() => undefined);
|
|
91
|
+
if (!found)
|
|
92
|
+
return { permissions: [], roles: slugs };
|
|
93
|
+
const stored = found.docs.map((row) => readPermission(row.permission));
|
|
94
|
+
// A malformed column voids the whole answer.
|
|
95
|
+
if (stored.some((one) => one === undefined))
|
|
96
|
+
return { permissions: [], roles: slugs };
|
|
97
|
+
for (const one of stored)
|
|
98
|
+
flatten(one, keys);
|
|
99
|
+
}
|
|
100
|
+
return { permissions: [...keys], roles: slugs };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getAuthTables } from 'better-auth/db';
|
|
2
|
+
/**
|
|
3
|
+
* The first line of every file this generator writes. A file without it is the
|
|
4
|
+
* developer's, and is left exactly as it is.
|
|
5
|
+
*/
|
|
6
|
+
export declare const header = "// Written by `mithaq generate auth` from the Better Auth options generate.providers names. Do not edit.";
|
|
7
|
+
/**
|
|
8
|
+
* Writes one kayan file per Better Auth table, and one barrel beside them. With `check`, writes
|
|
9
|
+
* nothing and returns the files a run would change or delete. A file without the header is never touched.
|
|
10
|
+
*/
|
|
11
|
+
export declare const generateAuth: ({ options, directory, language, check, provider, providers, screens, schemaName, }: {
|
|
12
|
+
options: Parameters<typeof getAuthTables>[0];
|
|
13
|
+
directory: URL;
|
|
14
|
+
/** The module each generated kayan imports `defineKayan` from: `generate.language`, as given. */
|
|
15
|
+
language: string;
|
|
16
|
+
check?: boolean;
|
|
17
|
+
/** The Postgres schema every kayan of this instance is built in, from its `providers` entry. */
|
|
18
|
+
schemaName?: string;
|
|
19
|
+
/** Each screen of the language by name, and the kayan it lists, which is where a title is written. */
|
|
20
|
+
screens?: Readonly<Record<string, readonly string[]>>;
|
|
21
|
+
} & ({
|
|
22
|
+
provider?: undefined;
|
|
23
|
+
providers?: undefined;
|
|
24
|
+
} | {
|
|
25
|
+
/** The kayan this instance authenticates, whose file carries the `auth` key. */
|
|
26
|
+
provider: string;
|
|
27
|
+
/** The module that file imports `providers` from: `generate.providers`, as given. */
|
|
28
|
+
providers: string;
|
|
29
|
+
})) => Promise<string[]>;
|
|
30
|
+
/** The auth generator as the CLI runs it. */
|
|
31
|
+
export declare const run: (project: URL, check: boolean) => Promise<number>;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { isIdentifierName, isKeyword, isStrictBindReservedWord } from '@babel/helper-validator-identifier';
|
|
13
|
+
import { getAuthTables } from 'better-auth/db';
|
|
14
|
+
import { snakeCase } from '../../mithaq/resolve.js';
|
|
15
|
+
import { isScreen } from '../../mithaq/language.js';
|
|
16
|
+
import { Refusal } from '../../mithaq/refusal.js';
|
|
17
|
+
const kinds = {
|
|
18
|
+
boolean: 'checkbox',
|
|
19
|
+
date: 'datetime',
|
|
20
|
+
number: 'number',
|
|
21
|
+
string: 'text',
|
|
22
|
+
};
|
|
23
|
+
const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
24
|
+
const sourceString = (value) => `'${JSON.stringify(value).slice(1, -1).replaceAll("'", "\\'")}'`;
|
|
25
|
+
const sourceKey = (name) => (identifier.test(name) ? name : sourceString(name));
|
|
26
|
+
const databaseName = (name) => name.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase();
|
|
27
|
+
const exportName = (name) => {
|
|
28
|
+
if (!identifier.test(name) || !isIdentifierName(name) || isKeyword(name) || isStrictBindReservedWord(name, true)) {
|
|
29
|
+
throw new Refusal(`Better Auth model "${name}" cannot be a generated file or export name.`);
|
|
30
|
+
}
|
|
31
|
+
return name;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The first line of every file this generator writes. A file without it is the
|
|
35
|
+
* developer's, and is left exactly as it is.
|
|
36
|
+
*/
|
|
37
|
+
export const header = '// Written by `mithaq generate auth` from the Better Auth options generate.providers names. Do not edit.';
|
|
38
|
+
const fieldSource = (name, field) => {
|
|
39
|
+
const fieldName = field.fieldName ?? name;
|
|
40
|
+
const dbName = databaseName(fieldName);
|
|
41
|
+
// Better Auth computes every `onUpdate` value; only a date is ours to bump.
|
|
42
|
+
const keys = [
|
|
43
|
+
field.required ? 'required: true' : '',
|
|
44
|
+
field.unique ? 'unique: true' : '',
|
|
45
|
+
// Better Auth's own migrations build it, so leaving it out would have the next migrate drop it.
|
|
46
|
+
// A unique column's constraint is already its index.
|
|
47
|
+
field.index && !field.unique ? 'index: true' : '',
|
|
48
|
+
field.onUpdate && field.type === 'date' ? 'nowOnUpdate: true' : '',
|
|
49
|
+
field.onUpdate ? "providedBy: { update: 'application' }" : '',
|
|
50
|
+
];
|
|
51
|
+
const rules = keys.filter(Boolean).join(', ');
|
|
52
|
+
if (field.references) {
|
|
53
|
+
// An explicit dbName keeps the relationship from becoming `user_id_id`. A required
|
|
54
|
+
// foreign key is ownership: a user's sessions and accounts go when the user goes.
|
|
55
|
+
const onDelete = field.required ? `onDelete: 'cascade'` : '';
|
|
56
|
+
const bindings = [rules, onDelete].filter(Boolean).join(', ');
|
|
57
|
+
return ` ${sourceKey(fieldName)}: { kind: 'relationship', relationTo: () => ${exportName(field.references.model)}, dbName: ${sourceString(dbName)}${bindings ? `, rules: { ${bindings} }` : ''} },`;
|
|
58
|
+
}
|
|
59
|
+
const kind = kinds[field.type];
|
|
60
|
+
if (!kind) {
|
|
61
|
+
throw new Refusal(`Better Auth field "${name}" has type "${field.type}", which mithaq has no kind for. Add one to the kinds map in auth/better-auth/generate.ts.`);
|
|
62
|
+
}
|
|
63
|
+
const named = dbName === snakeCase(fieldName) ? '' : `, dbName: ${sourceString(dbName)}`;
|
|
64
|
+
return ` ${sourceKey(fieldName)}: { kind: '${kind}'${named}${rules ? `, rules: { ${rules} }` : ''} },`;
|
|
65
|
+
};
|
|
66
|
+
const kayanSource = ({ name, table, language, provider, providers, screens = {}, access = false, operations = false, drawn, schemaName, idKind, }) => {
|
|
67
|
+
const exported = exportName(name);
|
|
68
|
+
// Written only under screens that list the kayan: a `targets.<screen>` on a kayan
|
|
69
|
+
// the screen does not list is refused at build.
|
|
70
|
+
const title = table.fields.name && (table.fields.name.fieldName ?? 'name');
|
|
71
|
+
const modelName = table.modelName ?? name;
|
|
72
|
+
const dbName = databaseName(modelName);
|
|
73
|
+
const titled = title
|
|
74
|
+
? Object.entries(screens)
|
|
75
|
+
.filter(([screen, kayan]) => kayan.includes(modelName) && !drawn?.includes(screen))
|
|
76
|
+
.map(([screen]) => `, ${sourceKey(screen)}: { useAsTitle: ${sourceString(title)} }`)
|
|
77
|
+
.join('')
|
|
78
|
+
: '';
|
|
79
|
+
const references = [
|
|
80
|
+
...new Set(Object.values(table.fields)
|
|
81
|
+
.map((field) => field.references?.model)
|
|
82
|
+
.filter((model) => model !== undefined && model !== name)),
|
|
83
|
+
].sort();
|
|
84
|
+
const carries = provider !== undefined && provider === modelName;
|
|
85
|
+
const key = identifier.test(provider ?? '') ? `.${provider}` : `[${sourceString(provider ?? '')}]`;
|
|
86
|
+
const imports = [
|
|
87
|
+
`import { betterAuth, defaultFields, defaultOperations } from 'mithaq'`,
|
|
88
|
+
`import { defineKayan } from ${sourceString(language)}`,
|
|
89
|
+
...(carries ? [`import { providers } from ${sourceString(providers ?? '')}`] : []),
|
|
90
|
+
...references.map((model) => `import { ${exportName(model)} } from './${exportName(model)}.ts'`),
|
|
91
|
+
...(access ? [`import { access } from './${exported}.access.ts'`] : []),
|
|
92
|
+
...(operations ? [`import { operations } from './${exported}.operations.ts'`] : []),
|
|
93
|
+
...(drawn ? [`import { screens } from './${exported}.screens.ts'`] : []),
|
|
94
|
+
];
|
|
95
|
+
if (!carries)
|
|
96
|
+
imports[0] = `import { defaultFields, defaultOperations } from 'mithaq'`;
|
|
97
|
+
const operationSource = operations ? '{ ...defaultOperations, ...operations }' : 'defaultOperations';
|
|
98
|
+
return `${header}
|
|
99
|
+
// This kayan's access and what each screen draws of it are the project's:
|
|
100
|
+
// \`${exported}.access.ts\` exporting \`access\` and \`${exported}.screens.ts\` exporting
|
|
101
|
+
// \`screens\`, beside this file, are imported here on the next run. A kayan
|
|
102
|
+
// built under this one's name elsewhere is refused by every relationship to it.
|
|
103
|
+
${imports.join('\n')}
|
|
104
|
+
|
|
105
|
+
export const ${exported} = defineKayan({
|
|
106
|
+
name: ${sourceString(modelName)}${dbName === modelName ? '' : `,
|
|
107
|
+
dbName: ${sourceString(dbName)}`},${schemaName === undefined ? '' : `
|
|
108
|
+
schemaName: ${sourceString(schemaName)},`}${carries ? `
|
|
109
|
+
auth: betterAuth({ server: providers${key}.server }),` : ''}
|
|
110
|
+
targets: { ${drawn ? `${titled.slice(2)}${titled ? ', ' : ''}...screens, service: { operations: ${operationSource} }` : `service: { operations: ${operationSource} }${titled}`} },${access ? `
|
|
111
|
+
access,` : ''}
|
|
112
|
+
fields: {
|
|
113
|
+
...defaultFields,
|
|
114
|
+
id: { kind: '${idKind}' },
|
|
115
|
+
${Object.entries(table.fields)
|
|
116
|
+
.map(([field, definition]) => ` ${fieldSource(field, definition)}`)
|
|
117
|
+
.join('\n')}
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
`;
|
|
121
|
+
};
|
|
122
|
+
const readFolder = (folder) => readdir(folder, { withFileTypes: true }).catch((error) => {
|
|
123
|
+
if (error.code === 'ENOENT')
|
|
124
|
+
return [];
|
|
125
|
+
throw error;
|
|
126
|
+
});
|
|
127
|
+
const barrelName = 'index';
|
|
128
|
+
// A plugin that only adds fields to `user` or `session` names those tables in its
|
|
129
|
+
// schema too, so the tables Better Auth ships with no plugin are subtracted.
|
|
130
|
+
const barrelSource = (options, tables) => {
|
|
131
|
+
const shipped = new Set(Object.keys(getAuthTables({})));
|
|
132
|
+
const claimed = new Map();
|
|
133
|
+
const plugins = options.plugins ?? [];
|
|
134
|
+
for (const plugin of plugins) {
|
|
135
|
+
if (!plugin?.id || !plugin.schema)
|
|
136
|
+
continue;
|
|
137
|
+
for (const table of Object.keys(plugin.schema)) {
|
|
138
|
+
if (shipped.has(table) || claimed.has(table))
|
|
139
|
+
continue;
|
|
140
|
+
claimed.set(table, plugin.id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const groups = new Map([['core', []]]);
|
|
144
|
+
for (const table of tables) {
|
|
145
|
+
const key = claimed.get(table) ?? 'core';
|
|
146
|
+
groups.set(key, [...(groups.get(key) ?? []), exportName(table)]);
|
|
147
|
+
}
|
|
148
|
+
const barrelImports = [...tables].map((table) => exportName(table)).sort();
|
|
149
|
+
return `${header}
|
|
150
|
+
// One key per plugin that brought tables of its own, and \`core\` for the tables
|
|
151
|
+
// Better Auth ships without one, so a project spreads these lists into
|
|
152
|
+
// \`buildAsas\` instead of naming every file. A plugin that leaves takes its key
|
|
153
|
+
// with it, and \`mithaq generate auth --check\` reports the drift.
|
|
154
|
+
${barrelImports.map((one) => `import { ${one} } from './${one}.ts'`).join('\n')}
|
|
155
|
+
|
|
156
|
+
export const betterAuth = {
|
|
157
|
+
${[...groups].map(([key, list]) => ` ${sourceKey(key)}: [${list.join(', ')}],`).join('\n')}
|
|
158
|
+
} as const
|
|
159
|
+
`;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Writes one kayan file per Better Auth table, and one barrel beside them. With `check`, writes
|
|
163
|
+
* nothing and returns the files a run would change or delete. A file without the header is never touched.
|
|
164
|
+
*/
|
|
165
|
+
export const generateAuth = async ({ options, directory, language, check = false, provider, providers, screens, schemaName, }) => {
|
|
166
|
+
const tables = getAuthTables(options);
|
|
167
|
+
const names = Object.keys(tables);
|
|
168
|
+
// With a `uuid` id, Better Auth's pg adapter leaves the id out of the insert and the
|
|
169
|
+
// column's `defaultRandom` fills it; a text kayan under a uuid instance fails every insert.
|
|
170
|
+
const idKind = options.advanced?.database?.generateId === 'uuid' ? 'uuid' : 'text';
|
|
171
|
+
if (names.includes(barrelName)) {
|
|
172
|
+
throw new Refusal(`Better Auth model "${barrelName}" would be written to the same file as the barrel this generator writes. Rename the model.`);
|
|
173
|
+
}
|
|
174
|
+
// The whole directory is read before anything is written or removed, so a file
|
|
175
|
+
// that stops the run stops it before the first deletion.
|
|
176
|
+
if (!check)
|
|
177
|
+
await mkdir(directory, { recursive: true });
|
|
178
|
+
const files = (await readFolder(directory)).map((entry) => entry.name);
|
|
179
|
+
// Sibling access, screens and operations files are the project's way in: the
|
|
180
|
+
// generated file is the generator's.
|
|
181
|
+
const siblings = new Set(files.filter((file) => file.endsWith('.access.ts') || file.endsWith('.screens.ts') || file.endsWith('.operations.ts')));
|
|
182
|
+
// Read by importing it: a title written beside a block the sibling also writes is
|
|
183
|
+
// one the compiler refuses as overwritten.
|
|
184
|
+
const drawnBy = new Map();
|
|
185
|
+
for (const file of siblings) {
|
|
186
|
+
if (!file.endsWith('.screens.ts'))
|
|
187
|
+
continue;
|
|
188
|
+
const exported = (await import(__rewriteRelativeImportExtension(new URL(file, directory).href))).screens;
|
|
189
|
+
drawnBy.set(file, exported && typeof exported === 'object' ? Object.keys(exported) : []);
|
|
190
|
+
}
|
|
191
|
+
const wanted = new Map(Object.entries(tables).map(([name, table]) => [
|
|
192
|
+
`${name}.ts`,
|
|
193
|
+
kayanSource({ name, table: table, language, provider, providers, screens, access: siblings.has(`${name}.access.ts`), operations: siblings.has(`${name}.operations.ts`), drawn: drawnBy.get(`${name}.screens.ts`), schemaName, idKind }),
|
|
194
|
+
]));
|
|
195
|
+
// Otherwise the `auth` key would be written onto nothing.
|
|
196
|
+
const named = Object.entries(tables).some(([name, table]) => (table.modelName ?? name) === provider);
|
|
197
|
+
if (provider !== undefined && !named) {
|
|
198
|
+
throw new Refusal(`No Better Auth table of the "${provider}" instance is named "${provider}", so there is no kayan for its \`auth\` to sit on. Name the instance after the table its sessions belong to, which is \`user\` unless its options rename it.`);
|
|
199
|
+
}
|
|
200
|
+
wanted.set(`${barrelName}.ts`, barrelSource(options, names));
|
|
201
|
+
const changed = [];
|
|
202
|
+
const stale = [];
|
|
203
|
+
const theirs = [];
|
|
204
|
+
for (const file of files) {
|
|
205
|
+
if (!file.endsWith('.ts'))
|
|
206
|
+
continue;
|
|
207
|
+
const current = await readFile(new URL(file, directory), 'utf8');
|
|
208
|
+
if (wanted.has(file)) {
|
|
209
|
+
if (!current.startsWith(header))
|
|
210
|
+
theirs.push(file);
|
|
211
|
+
else if (current === wanted.get(file))
|
|
212
|
+
wanted.delete(file);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (current.startsWith(header))
|
|
216
|
+
stale.push(file);
|
|
217
|
+
}
|
|
218
|
+
if (theirs.length) {
|
|
219
|
+
throw new Refusal(theirs.length > 1
|
|
220
|
+
? `${theirs.sort().join(', ')} were not written by \`mithaq generate auth\` and generating would overwrite them. Move them or delete them.`
|
|
221
|
+
: `${theirs[0]} was not written by \`mithaq generate auth\` and generating would overwrite it. Move it or delete it.`);
|
|
222
|
+
}
|
|
223
|
+
for (const file of stale) {
|
|
224
|
+
changed.push(file);
|
|
225
|
+
if (!check)
|
|
226
|
+
await rm(new URL(file, directory));
|
|
227
|
+
}
|
|
228
|
+
for (const [file, source] of wanted) {
|
|
229
|
+
changed.push(file);
|
|
230
|
+
if (!check)
|
|
231
|
+
await writeFile(new URL(file, directory), source);
|
|
232
|
+
}
|
|
233
|
+
return changed.sort();
|
|
234
|
+
};
|
|
235
|
+
/** The auth generator as the CLI runs it. */
|
|
236
|
+
export const run = async (project, check) => {
|
|
237
|
+
const { generate, targets } = (await import(__rewriteRelativeImportExtension(new URL('src/mithaq.ts', project).href)));
|
|
238
|
+
const screens = Object.fromEntries(Object.entries(targets ?? {}).flatMap(([name, target]) => (isScreen(target) ? [[name, target.kayan]] : [])));
|
|
239
|
+
// Where the files go is the project's choice, so nothing stands in for a folder it left out.
|
|
240
|
+
const folder = generate?.auth;
|
|
241
|
+
if (typeof folder !== 'string') {
|
|
242
|
+
throw new Refusal("src/mithaq.ts names no `generate.auth`, which is the folder this generator writes the Better Auth kayan into. Add `auth: '<folder>'` to `generate`, such as `auth: 'src/kayan/auth/'`.");
|
|
243
|
+
}
|
|
244
|
+
const root = new URL(folder, project);
|
|
245
|
+
// Resolved from where the generated files sit, since each imports it exactly as written.
|
|
246
|
+
const from = generate?.providers;
|
|
247
|
+
if (typeof from !== 'string') {
|
|
248
|
+
throw new Refusal(`src/mithaq.ts names no \`generate.providers\`, which is the module this generator reads \`providers\` from and writes into each generated kayan's import. Add \`providers: '<package name, or a path relative to a generated file>'\` to \`generate\`, beside \`auth: ${sourceString(folder)}\`.`);
|
|
249
|
+
}
|
|
250
|
+
// Resolved under `require`'s conditions, because `import.meta.resolve` takes no
|
|
251
|
+
// parent without a flag on Node 24.
|
|
252
|
+
let resolved;
|
|
253
|
+
try {
|
|
254
|
+
resolved = createRequire(new URL('instance/index.ts', root)).resolve(from);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (error.code !== 'MODULE_NOT_FOUND')
|
|
258
|
+
throw error;
|
|
259
|
+
throw new Refusal(`\`generate.providers\` names "${from}", which cannot be found from a generated file under ${sourceString(folder)}. Name a package this project depends on, or a path relative to a generated file.`);
|
|
260
|
+
}
|
|
261
|
+
// The language sits wherever the project's layout puts it, a package or a file, so nothing stands in for it either.
|
|
262
|
+
const language = generate?.language;
|
|
263
|
+
if (typeof language !== 'string') {
|
|
264
|
+
throw new Refusal(`src/mithaq.ts names no \`generate.language\`, which is the module each generated kayan imports \`defineKayan\` from. Add \`language: '<package name, or a path relative to a generated file>'\` to \`generate\`, beside \`auth: ${sourceString(folder)}\`.`);
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
createRequire(new URL('instance/index.ts', root)).resolve(language);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (error.code !== 'MODULE_NOT_FOUND')
|
|
271
|
+
throw error;
|
|
272
|
+
throw new Refusal(`\`generate.language\` names "${language}", which cannot be found from a generated file under ${sourceString(folder)}. Name the language's package, or a path relative to a generated file.`);
|
|
273
|
+
}
|
|
274
|
+
const { providers } = (await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href)));
|
|
275
|
+
if (!providers) {
|
|
276
|
+
throw new Refusal(`${from} exports no \`providers\`, which is the record this generator reads: one key per kayan a Better Auth instance authenticates, each \`{ options, server }\`.`);
|
|
277
|
+
}
|
|
278
|
+
// Checked for every entry before any instance's files are written.
|
|
279
|
+
for (const [provider, { schemaName }] of Object.entries(providers)) {
|
|
280
|
+
if (schemaName === undefined || typeof schemaName === 'string')
|
|
281
|
+
continue;
|
|
282
|
+
// `JSON.stringify` throws on a BigInt and answers `undefined` for a function.
|
|
283
|
+
let shown;
|
|
284
|
+
try {
|
|
285
|
+
shown = JSON.stringify(schemaName) ?? String(schemaName);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
shown = String(schemaName);
|
|
289
|
+
}
|
|
290
|
+
throw new Refusal(`${from} gives the "${provider}" instance the schemaName ${shown}, and a schemaName is the name of a Postgres schema, a string such as 'auth'.`);
|
|
291
|
+
}
|
|
292
|
+
const changed = [];
|
|
293
|
+
for (const [provider, one] of Object.entries(providers)) {
|
|
294
|
+
const schemaName = one.schemaName;
|
|
295
|
+
const files = await generateAuth({ options: one.options, directory: new URL(`${provider}/`, root), language, check, provider, providers: from, screens, schemaName });
|
|
296
|
+
changed.push(...files.map((file) => `${provider}/${file}`));
|
|
297
|
+
}
|
|
298
|
+
// A directory of an instance no longer declared is reported, never removed: the
|
|
299
|
+
// generator owns the files it wrote, not the directory holding them.
|
|
300
|
+
if (!check)
|
|
301
|
+
await mkdir(root, { recursive: true });
|
|
302
|
+
const stale = [];
|
|
303
|
+
for (const entry of await readFolder(root)) {
|
|
304
|
+
if (!entry.isDirectory() || Object.hasOwn(providers, entry.name))
|
|
305
|
+
continue;
|
|
306
|
+
const barrel = await readFile(new URL(`${entry.name}/${barrelName}.ts`, root), 'utf8').catch(() => '');
|
|
307
|
+
if (barrel.startsWith(header))
|
|
308
|
+
stale.push(`${entry.name}/`);
|
|
309
|
+
}
|
|
310
|
+
const report = () => {
|
|
311
|
+
if (!stale.length)
|
|
312
|
+
return;
|
|
313
|
+
const sentence = `${stale.join(', ')} ${stale.length > 1 ? 'are directories' : 'is a directory'} of an instance ${from} no longer declares. Delete ${stale.length > 1 ? 'them' : 'it'}: this command never removes a directory.`;
|
|
314
|
+
if (check)
|
|
315
|
+
console.error(sentence);
|
|
316
|
+
else
|
|
317
|
+
console.log(sentence);
|
|
318
|
+
};
|
|
319
|
+
if (check && (changed.length || stale.length)) {
|
|
320
|
+
if (changed.length)
|
|
321
|
+
console.error(`Stale auth kayan: ${changed.join(', ')}. Run \`mithaq generate auth\`.`);
|
|
322
|
+
report();
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
console.log(changed.length ? `Wrote ${changed.join(', ')}.` : 'Auth kayan are up to date.');
|
|
326
|
+
report();
|
|
327
|
+
return 0;
|
|
328
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type AssignedResolver } from '../../asas/actor.ts';
|
|
2
|
+
type BetterAuthUser = Record<string, unknown>;
|
|
3
|
+
type BetterAuthOrganization = {
|
|
4
|
+
id: string;
|
|
5
|
+
} & Record<string, unknown>;
|
|
6
|
+
type BetterAuth = {
|
|
7
|
+
api: {
|
|
8
|
+
getSession(args: {
|
|
9
|
+
headers: Headers;
|
|
10
|
+
query?: {
|
|
11
|
+
disableRefresh?: boolean;
|
|
12
|
+
};
|
|
13
|
+
}): Promise<null | {
|
|
14
|
+
session?: BetterAuthUser;
|
|
15
|
+
user: BetterAuthUser;
|
|
16
|
+
}>;
|
|
17
|
+
getActiveMemberRole?(args: {
|
|
18
|
+
headers: Headers;
|
|
19
|
+
}): Promise<null | {
|
|
20
|
+
role?: unknown;
|
|
21
|
+
}>;
|
|
22
|
+
listOrganizations?(args: {
|
|
23
|
+
headers: Headers;
|
|
24
|
+
}): Promise<BetterAuthOrganization[]>;
|
|
25
|
+
};
|
|
26
|
+
handler(request: Request): Promise<Response>;
|
|
27
|
+
};
|
|
28
|
+
type SessionUser<Server extends BetterAuth> = NonNullable<Awaited<ReturnType<Server['api']['getSession']>>>['user'];
|
|
29
|
+
export type BetterAuthActor<User extends BetterAuthUser = BetterAuthUser, Collection extends string = string> = User & {
|
|
30
|
+
activeOrganizationId?: unknown;
|
|
31
|
+
collection: Collection;
|
|
32
|
+
/** The real person behind an impersonated session, so the audit names who really made the write. */
|
|
33
|
+
impersonatedBy?: unknown;
|
|
34
|
+
/** The provider's own `member.role` in the active organization; several roles are comma-separated. */
|
|
35
|
+
memberRole?: string;
|
|
36
|
+
organizations?: BetterAuthOrganization[];
|
|
37
|
+
/** The ids of `organizations`, so a row can say `{ organization: { in: user.organizationIds } }`. */
|
|
38
|
+
organizationIds?: string[];
|
|
39
|
+
/** The keys the build's access target resolved for this actor, never a column of the provider's user row. */
|
|
40
|
+
permissions?: '*' | readonly string[];
|
|
41
|
+
/** The role slugs the access target resolved in the active tenant, never read from `member.role`. */
|
|
42
|
+
roles?: readonly string[];
|
|
43
|
+
strategy: 'better-auth';
|
|
44
|
+
user: User;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Whether the caller of the Better Auth request being handled holds `key`, for a project's
|
|
48
|
+
* `organizationHooks.before*`. Only a request through `asas.handler` is gated; any other call answers `true`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const holdsPermission: (key: string) => boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The organization whose keys `holdsPermission` answers with. A provider route may act on another
|
|
53
|
+
* organization than the session's active one, so a hook compares the two first.
|
|
54
|
+
*/
|
|
55
|
+
export declare const requestOrganization: () => string | null | undefined;
|
|
56
|
+
/** Better Auth as one strategy among siblings. The package never imports better-auth; the app owns it. */
|
|
57
|
+
export declare const betterAuthStrategy: <Server extends BetterAuth, Collection extends string = string>({ server, collection, assigned, onError, }: {
|
|
58
|
+
server: Server;
|
|
59
|
+
collection: Collection;
|
|
60
|
+
assigned?: AssignedResolver;
|
|
61
|
+
/** Handed an access target that failed to answer, and a session read that failed. */
|
|
62
|
+
onError: (error: unknown) => void;
|
|
63
|
+
}) => {
|
|
64
|
+
name: Collection;
|
|
65
|
+
authenticate: ({ headers }: {
|
|
66
|
+
headers: Headers;
|
|
67
|
+
}) => Promise<{
|
|
68
|
+
user: null;
|
|
69
|
+
} | {
|
|
70
|
+
user: BetterAuthActor<SessionUser<Server>, Collection>;
|
|
71
|
+
}>;
|
|
72
|
+
handler: (request: Request) => Promise<Response>;
|
|
73
|
+
/** The Better Auth instance, so the app can call `api.signUpEmail` and friends. */
|
|
74
|
+
server: Server;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The Better Auth provider as a kayan declares it. `server` is a factory, built from the
|
|
78
|
+
* targets and vocabulary the build derives; two kayan carrying this are two instances.
|
|
79
|
+
*/
|
|
80
|
+
export declare const betterAuth: <Server extends BetterAuth, Targets = unknown>(declaration: {
|
|
81
|
+
server: (targets: Targets, vocabulary: {
|
|
82
|
+
catalog: readonly string[];
|
|
83
|
+
statements: Readonly<Record<string, readonly string[]>>;
|
|
84
|
+
referenced: (id: string) => Promise<number>;
|
|
85
|
+
}) => Server;
|
|
86
|
+
}) => {
|
|
87
|
+
provider: 'better-auth';
|
|
88
|
+
server: (targets: Targets, vocabulary: {
|
|
89
|
+
catalog: readonly string[];
|
|
90
|
+
statements: Readonly<Record<string, readonly string[]>>;
|
|
91
|
+
referenced: (id: string) => Promise<number>;
|
|
92
|
+
}) => Server;
|
|
93
|
+
};
|
|
94
|
+
export {};
|