@drhomet/mithaq 0.0.0-stage → 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/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +231 -0
- package/dist/asas/build.js +787 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +145 -0
- package/dist/asas/cli/upgrade.d.ts +20 -0
- package/dist/asas/cli/upgrade.js +147 -0
- package/dist/asas/http/fetch.d.ts +23 -0
- package/dist/asas/http/fetch.js +115 -0
- package/dist/asas/http/orpc.d.ts +43 -0
- package/dist/asas/http/orpc.js +135 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +191 -0
- package/dist/asas/runner/errors.d.ts +60 -0
- package/dist/asas/runner/errors.js +59 -0
- package/dist/asas/runner/read.d.ts +68 -0
- package/dist/asas/runner/read.js +579 -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 +400 -0
- package/dist/asas/runner/steps.js +271 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +213 -0
- package/dist/asas/runner/write.d.ts +444 -0
- package/dist/asas/runner/write.js +791 -0
- package/dist/auth/better-auth/access.d.ts +15 -0
- package/dist/auth/better-auth/access.js +71 -0
- package/dist/auth/better-auth/generate.d.ts +29 -0
- package/dist/auth/better-auth/generate.js +310 -0
- package/dist/auth/better-auth/index.d.ts +92 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +18 -0
- package/dist/auth/better-auth/tenant.js +26 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +736 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +123 -0
- package/dist/base/fields.js +51 -0
- package/dist/base/index.d.ts +46 -0
- package/dist/base/index.js +5 -0
- package/dist/base/kinds.d.ts +54 -0
- package/dist/base/kinds.js +78 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +80 -0
- package/dist/drizzle/index.d.ts +438 -0
- package/dist/drizzle/index.js +832 -0
- package/dist/drizzle/kinds.d.ts +27 -0
- package/dist/drizzle/kinds.js +41 -0
- package/dist/drizzle/migrate.d.ts +133 -0
- package/dist/drizzle/migrate.js +1285 -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 +45 -0
- package/dist/index.js +36 -0
- package/dist/mithaq/define.d.ts +288 -0
- package/dist/mithaq/define.js +43 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +234 -0
- package/dist/mithaq/language.d.ts +335 -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/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +106 -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 +954 -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 +358 -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 +27 -0
- package/dist/rich-text/index.js +58 -0
- package/dist/rich-text/rich-text.d.ts +159 -0
- package/dist/rich-text/rich-text.js +372 -0
- package/dist/screen/client.d.ts +153 -0
- package/dist/screen/client.js +68 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +226 -0
- package/dist/screen/index.d.ts +479 -0
- package/dist/screen/index.js +645 -0
- package/dist/screen/react.d.ts +95 -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 +43 -0
- package/dist/storage/index.js +44 -0
- package/dist/storage/storage.d.ts +384 -0
- package/dist/storage/storage.js +405 -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/zod/index.d.ts +184 -0
- package/dist/zod/index.js +121 -0
- package/dist/zod/kinds.d.ts +34 -0
- package/dist/zod/kinds.js +49 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/package.json +63 -4
- package/r/mithaq-cells.json +20 -0
- package/r/mithaq-controls.json +37 -0
- package/r/mithaq-directional-icon.json +15 -0
- package/r/mithaq-expected-updated-at.json +15 -0
- package/r/mithaq-field-shell.json +18 -0
- package/r/mithaq-filter-operators.json +15 -0
- package/r/mithaq-form.json +33 -0
- package/r/mithaq-list-columns.json +23 -0
- package/r/mithaq-list-filters.json +24 -0
- package/r/mithaq-list-pagination.json +24 -0
- package/r/mithaq-list-query.json +18 -0
- package/r/mithaq-list-selection.json +19 -0
- package/r/mithaq-list.json +33 -0
- package/r/mithaq-nav.json +24 -0
- package/r/mithaq-nodes.json +34 -0
- package/r/mithaq-relationship-cell.json +20 -0
- package/r/mithaq-relationship-control.json +20 -0
- package/r/mithaq-rich-text.json +40 -0
- package/r/mithaq-screens-host.json +96 -0
- package/r/mithaq-storage.json +44 -0
- package/r/mithaq-upload.json +29 -0
- package/r/registry.json +550 -0
- package/README.md +0 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { rowsOf } from '../mithaq/rows.js';
|
|
2
|
+
import { writeSync } from 'node:fs';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { actorPermissions, markAuthActor } from '../asas/actor.js';
|
|
5
|
+
import { Refusal } from '../mithaq/refusal.js';
|
|
6
|
+
import { isService, isStore } from '../mithaq/language.js';
|
|
7
|
+
import { resolvedKayans } from '../mithaq/types.js';
|
|
8
|
+
/**
|
|
9
|
+
* The keys an actor holds for a kayan: what the build's access target resolved,
|
|
10
|
+
* or the kayan's own rows where no target answered. Precedence, never a union.
|
|
11
|
+
*/
|
|
12
|
+
export const heldPermissions = ({ access, user }) => actorPermissions(user) ?? rowsOf(access)?.permissions(user);
|
|
13
|
+
/** One page of every row a filter matches, which is how each table is read. */
|
|
14
|
+
export const allRows = async ({ store, kayan, where }) => store.find({ kayan, args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where } });
|
|
15
|
+
/** A tenant a row can be filtered by. Nothing else scopes a read, so nothing else reads. */
|
|
16
|
+
export const scoped = (value) => typeof value === 'string' || typeof value === 'number';
|
|
17
|
+
// A read that throws is caught at `resolveAssigned`, so a store that is down
|
|
18
|
+
// makes an actor that holds nothing rather than one that is not signed in.
|
|
19
|
+
export const assignedSlugs = async ({ store, kayan, field, tenant, member }) => {
|
|
20
|
+
// An assignment read without a tenant, or of a kayan no tenant owns, would be every tenant's.
|
|
21
|
+
if (!field || !scoped(tenant) || !scoped(member))
|
|
22
|
+
return [];
|
|
23
|
+
const found = await allRows({ store, kayan, where: {
|
|
24
|
+
and: [{ [field]: { equals: tenant } }, { user: { equals: member } }],
|
|
25
|
+
} });
|
|
26
|
+
const names = found.docs.map((row) => row.role);
|
|
27
|
+
return [...new Set(names.filter((one) => typeof one === 'string' && one !== ''))];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A role slug that is one of the project's own, whose keys are code. The rest
|
|
31
|
+
* are rows, and a target reads them from wherever it holds them.
|
|
32
|
+
*/
|
|
33
|
+
export const systemKeys = (roles, slugs) => {
|
|
34
|
+
const keys = new Set();
|
|
35
|
+
const custom = [];
|
|
36
|
+
let everything = false;
|
|
37
|
+
for (const slug of slugs) {
|
|
38
|
+
// An own key only: a slug is a column a caller wrote.
|
|
39
|
+
const role = Object.hasOwn(roles, slug) ? roles[slug] : undefined;
|
|
40
|
+
if (!role) {
|
|
41
|
+
custom.push(slug);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (role.permissions === '*')
|
|
45
|
+
everything = true;
|
|
46
|
+
else
|
|
47
|
+
for (const key of role.permissions)
|
|
48
|
+
keys.add(key);
|
|
49
|
+
}
|
|
50
|
+
return { custom, everything, keys };
|
|
51
|
+
};
|
|
52
|
+
// Never re-export this from a package entry: it marks a copy of any actor and
|
|
53
|
+
// slugs as trusted, so a caller outside core could mint the snapshot every reader trusts.
|
|
54
|
+
export const heldSlugs = ({ roles, rolesOf, slugs, actor }) => {
|
|
55
|
+
if (!rolesOf)
|
|
56
|
+
return slugs;
|
|
57
|
+
if (!actor)
|
|
58
|
+
return [];
|
|
59
|
+
const answered = rolesOf(markAuthActor({ ...actor, roles: slugs }));
|
|
60
|
+
if (!Array.isArray(answered))
|
|
61
|
+
return [];
|
|
62
|
+
const kept = answered.filter((one) => typeof one === 'string' && (slugs.includes(one) || Object.hasOwn(roles, one)));
|
|
63
|
+
return [...new Set(kept)];
|
|
64
|
+
};
|
|
65
|
+
/** The access target whose custom roles are rows of a kayan the project ships, beside its system roles in code. */
|
|
66
|
+
export const rbacTarget = ({ roles, rolesOf, access, kayan, }) => {
|
|
67
|
+
// Both targets come through here, so the one refusal covers both.
|
|
68
|
+
for (const key of ['roles', 'memberRoles']) {
|
|
69
|
+
if (typeof kayan?.[key] !== 'string') {
|
|
70
|
+
throw new Error(`The access target names no kayan.${key}. Pass kayan: { roles, memberRoles }, the names of the kayan holding the custom roles and the assignments.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const { roles: roleKayan, memberRoles } = kayan;
|
|
74
|
+
return {
|
|
75
|
+
capability: 'access',
|
|
76
|
+
type: 'rbac',
|
|
77
|
+
roles,
|
|
78
|
+
...(rolesOf ? { rolesOf } : {}),
|
|
79
|
+
access: (access ?? {}),
|
|
80
|
+
reads: [roleKayan, memberRoles],
|
|
81
|
+
commands: {
|
|
82
|
+
assign: async ({ asas, flags }) => {
|
|
83
|
+
// A sweep reaches this after `generate messages --check` destroyed the shared pool.
|
|
84
|
+
if (flags.includes('--check')) {
|
|
85
|
+
console.log('mithaq rbac assign --check writes nothing.');
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const values = {};
|
|
90
|
+
for (let index = 0; index < flags.length; index += 2) {
|
|
91
|
+
const flag = flags[index];
|
|
92
|
+
const value = flags[index + 1];
|
|
93
|
+
if (!flag || !['--tenant', '--user', '--role'].includes(flag) || !value || value.startsWith('--') || values[flag]) {
|
|
94
|
+
throw new Refusal('mithaq rbac assign requires one value each for --tenant, --user and --role.');
|
|
95
|
+
}
|
|
96
|
+
values[flag] = value;
|
|
97
|
+
}
|
|
98
|
+
for (const flag of ['--tenant', '--user', '--role']) {
|
|
99
|
+
if (!values[flag])
|
|
100
|
+
throw new Refusal(`Missing ${flag} for mithaq rbac assign.`);
|
|
101
|
+
}
|
|
102
|
+
const tenant = values['--tenant'];
|
|
103
|
+
const user = values['--user'];
|
|
104
|
+
const role = values['--role'];
|
|
105
|
+
if (!Object.hasOwn(roles, role))
|
|
106
|
+
throw new Refusal(`Role "${role}" is not declared on the access target.`);
|
|
107
|
+
if (role === 'super_admin')
|
|
108
|
+
throw new Refusal('Role "super_admin" is the platform role and cannot be assigned in a tenant.');
|
|
109
|
+
const project = asas;
|
|
110
|
+
const declarations = resolvedKayans.get(project) ?? {};
|
|
111
|
+
const assignment = declarations[memberRoles];
|
|
112
|
+
if (!assignment)
|
|
113
|
+
throw new Refusal(`mithaq rbac assign requires the "${memberRoles}" kayan in this build.`);
|
|
114
|
+
const owner = assignment.tenant;
|
|
115
|
+
if (!owner)
|
|
116
|
+
throw new Refusal(`mithaq rbac assign writes an assignment of one tenant, and kayan "${memberRoles}" places no field bound to the tenant target.`);
|
|
117
|
+
// This kayan may use a different store from the service target's default.
|
|
118
|
+
const store = project.targets[assignment.store];
|
|
119
|
+
if (!isStore(store))
|
|
120
|
+
throw new Refusal(`Kayan "${memberRoles}" names no store for mithaq rbac assign.`);
|
|
121
|
+
const membership = declarations.member;
|
|
122
|
+
if (!membership)
|
|
123
|
+
throw new Refusal('mithaq rbac assign requires the provider "member" kayan in this build.');
|
|
124
|
+
// The membership is read inside the assignment's transaction, so both kayan must live in that store.
|
|
125
|
+
if (membership.store !== assignment.store) {
|
|
126
|
+
throw new Refusal(`mithaq rbac assign reads both kayan in one transaction, but kayan "member" is in store "${membership.store}" and kayan "${memberRoles}" is in store "${assignment.store}".`);
|
|
127
|
+
}
|
|
128
|
+
const service = Object.values(project.targets).find(isService);
|
|
129
|
+
const actor = { command: 'mithaq rbac assign', osUser: userInfo().username };
|
|
130
|
+
await store.transaction(async (tx) => {
|
|
131
|
+
const member = await store.find({ kayan: 'member', args: {
|
|
132
|
+
limit: 1, page: 1, sort: { field: 'id', direction: 'asc' },
|
|
133
|
+
where: { and: [{ organizationId: { equals: tenant } }, { userId: { equals: user } }] },
|
|
134
|
+
}, tx });
|
|
135
|
+
if (!member.docs.length)
|
|
136
|
+
throw new Refusal(`User "${user}" is not a provider member of tenant "${tenant}".`);
|
|
137
|
+
const existing = await store.find({ kayan: memberRoles, args: {
|
|
138
|
+
limit: 1, page: 1, sort: { field: 'id', direction: 'asc' },
|
|
139
|
+
where: { and: [{ [owner]: { equals: tenant } }, { user: { equals: user } }, { role: { equals: role } }] },
|
|
140
|
+
}, tx });
|
|
141
|
+
if (existing.docs.length)
|
|
142
|
+
throw new Refusal(`Role "${role}" is already assigned to user "${user}" in tenant "${tenant}".`);
|
|
143
|
+
const written = new Date();
|
|
144
|
+
const row = await store.insert({ tx, kayan: memberRoles, data: { createdAt: written, role, [owner]: tenant, updatedAt: written, user } });
|
|
145
|
+
// The language checked the sink takes a `CommandActor`, which is what the cast names.
|
|
146
|
+
if (service?.audit)
|
|
147
|
+
await service.audit({ context: Object.freeze({ chain: [`${memberRoles}.assign`], depth: 1, source: 'cli' }), effect: 'create', kayan: assignment, operation: 'assign', dataAfter: row, store, tx, user: actor });
|
|
148
|
+
});
|
|
149
|
+
writeSync(1, `${JSON.stringify({ actor, tenant, user, role })}\n`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await asas.destroy();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
resolve: async ({ store, tenant, member, actor, ownership }) => {
|
|
158
|
+
const slugs = await assignedSlugs({ store, kayan: memberRoles, field: ownership(memberRoles), tenant, member });
|
|
159
|
+
const { custom, everything, keys } = systemKeys(roles, heldSlugs({ roles, rolesOf, slugs, actor }));
|
|
160
|
+
if (everything)
|
|
161
|
+
return { permissions: '*', roles: slugs };
|
|
162
|
+
const field = ownership(roleKayan);
|
|
163
|
+
// A role table no tenant owns holds no tenant's custom role, so it grants nothing.
|
|
164
|
+
if (custom.length && field && scoped(tenant)) {
|
|
165
|
+
// Caught here, so a role table that is down loses the keys and not the assignment.
|
|
166
|
+
const found = await allRows({ store, kayan: roleKayan, where: {
|
|
167
|
+
and: [{ [field]: { equals: tenant } }, { name: { in: custom } }],
|
|
168
|
+
} }).catch(() => undefined);
|
|
169
|
+
if (!found)
|
|
170
|
+
return { permissions: [], roles: slugs };
|
|
171
|
+
const held = found.docs.map((row) => row.permissions);
|
|
172
|
+
// A malformed column voids the whole answer: a subset of a list nothing vouched for is still such a grant.
|
|
173
|
+
if (!held.every((one) => Array.isArray(one) && one.every((key) => typeof key === 'string'))) {
|
|
174
|
+
return { permissions: [], roles: slugs };
|
|
175
|
+
}
|
|
176
|
+
for (const key of held.flat())
|
|
177
|
+
keys.add(key);
|
|
178
|
+
}
|
|
179
|
+
return { permissions: [...keys], roles: slugs };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Ctx, Tools } from '../asas/runner/steps.ts';
|
|
2
|
+
import { type SystemRoles } from './index.ts';
|
|
3
|
+
export declare const keepOperationKeys: (catalog: readonly string[], operationKeys: Readonly<Record<string, string>>) => void;
|
|
4
|
+
export declare const fieldlessOperation: (keys: readonly string[], operationKeys: Readonly<Record<string, string>>) => {
|
|
5
|
+
key: string;
|
|
6
|
+
kayan: string;
|
|
7
|
+
} | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* The validate step a role kayan puts after `validate`: a role row may hold only keys
|
|
10
|
+
* the catalog names and the granter itself holds.
|
|
11
|
+
*/
|
|
12
|
+
export declare const grantedPermissions: (ctx: Ctx, tools: Tools) => Ctx;
|
|
13
|
+
/**
|
|
14
|
+
* The validate step a `memberRoles` kayan puts before its store step: an actor may
|
|
15
|
+
* assign only a role whose keys it already holds.
|
|
16
|
+
*/
|
|
17
|
+
export declare const assignableRole: (roles: SystemRoles, roleKayan: string) => (ctx: Ctx, tools: Tools) => Promise<Ctx>;
|
|
18
|
+
/**
|
|
19
|
+
* The step after `grantedPermissions` in a role kayan's write lists: a tenant's role
|
|
20
|
+
* row may not take a system role's name.
|
|
21
|
+
*/
|
|
22
|
+
export declare const writableRoleName: (roles: SystemRoles) => (ctx: Ctx) => Ctx;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { denied, invalid } from '../asas/runner/errors.js';
|
|
2
|
+
import { heldPermissions } from './index.js';
|
|
3
|
+
const KEYS = 'permissions';
|
|
4
|
+
// Kept against each build's frozen catalog, which every operation is handed as `tools.catalog`.
|
|
5
|
+
const operationKeysOf = new WeakMap();
|
|
6
|
+
export const keepOperationKeys = (catalog, operationKeys) => {
|
|
7
|
+
operationKeysOf.set(catalog, operationKeys);
|
|
8
|
+
};
|
|
9
|
+
// A role holding an operation key of a kayan but no field key of it is refused
|
|
10
|
+
// every read of that kayan's fields, so its list draws nothing.
|
|
11
|
+
export const fieldlessOperation = (keys, operationKeys) => {
|
|
12
|
+
const key = keys.find((one) => Object.hasOwn(operationKeys, one) && !keys.some((other) => other.startsWith(`${operationKeys[one]}.fields.`)));
|
|
13
|
+
return key === undefined ? undefined : { key, kayan: operationKeys[key] };
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The validate step a role kayan puts after `validate`: a role row may hold only keys
|
|
17
|
+
* the catalog names and the granter itself holds.
|
|
18
|
+
*/
|
|
19
|
+
export const grantedPermissions = (ctx, tools) => {
|
|
20
|
+
// A write that names only the name is still a grant: a rename re-targets every key
|
|
21
|
+
// the row holds at a different set of members. `dataBefore` is what the store holds.
|
|
22
|
+
const data = ctx.data;
|
|
23
|
+
const written = data[KEYS] ?? ctx.dataBefore?.[KEYS];
|
|
24
|
+
// Checked here too, so a list a step wrote is checked as a caller's is, and a
|
|
25
|
+
// stored column no shape vouched for refuses rather than being read past.
|
|
26
|
+
if (written === undefined)
|
|
27
|
+
return ctx;
|
|
28
|
+
if (!Array.isArray(written) || written.some((key) => typeof key !== 'string')) {
|
|
29
|
+
throw invalid({
|
|
30
|
+
message: `A "${ctx.kayan.name}" row's "${KEYS}" must be a list of permission keys.`,
|
|
31
|
+
key: 'error.invalid.permissions',
|
|
32
|
+
params: { kayan: ctx.kayan.name, field: KEYS },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const keys = written;
|
|
36
|
+
const known = new Set(tools.catalog);
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
if (known.has(key))
|
|
39
|
+
continue;
|
|
40
|
+
throw invalid({
|
|
41
|
+
message: `Permission "${key}" is not a key of this project's catalog. A key is "<kayan>.<operation>", "<kayan>.fields.<field>" or "screen.<screen>".`,
|
|
42
|
+
key: 'error.invalid.permission',
|
|
43
|
+
params: { kayan: ctx.kayan.name, permission: key },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Asked only of a write that names the column, so an older row can still be renamed and deleted.
|
|
47
|
+
const fieldless = data[KEYS] !== undefined && fieldlessOperation(keys, operationKeysOf.get(tools.catalog) ?? {});
|
|
48
|
+
if (fieldless) {
|
|
49
|
+
throw invalid({
|
|
50
|
+
message: `A "${ctx.kayan.name}" row holds operation key "${fieldless.key}" of kayan "${fieldless.kayan}" and no "${fieldless.kayan}.fields.<field>" key, so every read of it is refused for its fields. Grant the fields it reads, or drop the operation.`,
|
|
51
|
+
key: 'error.invalid.permission_fields',
|
|
52
|
+
params: { kayan: ctx.kayan.name, permission: fieldless.key, resource: fieldless.kayan },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Read from the trusted snapshot, never the actor object, so a `permissions`
|
|
56
|
+
// property a caller wrote on itself buys nothing.
|
|
57
|
+
const held = heldPermissions({ access: ctx.kayan.access, user: ctx.actor });
|
|
58
|
+
if (held === true)
|
|
59
|
+
return ctx;
|
|
60
|
+
// `undefined` is a granter whose holdings nothing states, not one holding everything:
|
|
61
|
+
// a row written then would become a live grant the moment a store is turned on.
|
|
62
|
+
const missing = held === undefined ? keys : keys.filter((key) => !held.has(key));
|
|
63
|
+
if (!missing.length)
|
|
64
|
+
return ctx;
|
|
65
|
+
throw denied({
|
|
66
|
+
message: `Forbidden: a "${ctx.kayan.name}" row may not grant ${missing.map((key) => `"${key}"`).join(', ')}, which the granter does not hold.`,
|
|
67
|
+
key: 'error.forbidden.grant',
|
|
68
|
+
params: { kayan: ctx.kayan.name, permissions: missing },
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const NAME = 'name';
|
|
72
|
+
const ROLE = 'role';
|
|
73
|
+
// A `permissions` column that is not a list of strings, or a role kayan that was not
|
|
74
|
+
// read, reads as `'*'`: what may be handed over is answered with the largest it could be.
|
|
75
|
+
const roleKeys = async ({ roles, kayan, slug, tools }) => {
|
|
76
|
+
// An own key only: `constructor` or `__proto__` read through the prototype would be a 500 one line later.
|
|
77
|
+
const system = Object.hasOwn(roles, slug) ? roles[slug] : undefined;
|
|
78
|
+
if (system)
|
|
79
|
+
return system.permissions === '*' ? '*' : system.permissions;
|
|
80
|
+
// Through the bound registry, so the read runs under this actor, its transaction and its
|
|
81
|
+
// tenant scope, which keeps a role of another tenant out. Rows of one name in a table no
|
|
82
|
+
// tenant owns are unioned, which only asks the assigner for more.
|
|
83
|
+
const bound = tools.kayans?.[kayan];
|
|
84
|
+
const found = await bound?.find?.({ where: { [NAME]: { equals: slug } } });
|
|
85
|
+
// Not read is not a role holding nothing: it reads as `'*'`, so a guard that could not look refuses.
|
|
86
|
+
if (!found)
|
|
87
|
+
return '*';
|
|
88
|
+
if (!found.docs.length)
|
|
89
|
+
return undefined;
|
|
90
|
+
const keys = [];
|
|
91
|
+
for (const row of found.docs) {
|
|
92
|
+
const held = row[KEYS];
|
|
93
|
+
if (!Array.isArray(held) || held.some((key) => typeof key !== 'string'))
|
|
94
|
+
return '*';
|
|
95
|
+
keys.push(...held);
|
|
96
|
+
}
|
|
97
|
+
return keys;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* The validate step a `memberRoles` kayan puts before its store step: an actor may
|
|
101
|
+
* assign only a role whose keys it already holds.
|
|
102
|
+
*/
|
|
103
|
+
export const assignableRole = (roles, roleKayan) => {
|
|
104
|
+
if (typeof roleKayan !== 'string') {
|
|
105
|
+
throw new Error('assignableRole(roles, roleKayan) names no roleKayan. Pass the name of the kayan holding the custom roles, the access target\'s kayan.roles.');
|
|
106
|
+
}
|
|
107
|
+
return async (ctx, tools) => {
|
|
108
|
+
// A write that moves the row to another member is still an assignment of the role it names.
|
|
109
|
+
const data = ctx.data;
|
|
110
|
+
const slug = data[ROLE] ?? ctx.dataBefore?.[ROLE];
|
|
111
|
+
if (typeof slug !== 'string' || slug === '')
|
|
112
|
+
return ctx;
|
|
113
|
+
// Create and update refuse a missing slug first: even a root granter may not
|
|
114
|
+
// plant a row that a later role could arm.
|
|
115
|
+
const keys = await roleKeys({ roles, kayan: roleKayan, slug, tools });
|
|
116
|
+
if (keys === undefined) {
|
|
117
|
+
if (tools.kind === 'remove')
|
|
118
|
+
return ctx;
|
|
119
|
+
throw invalid({
|
|
120
|
+
message: `Role "${slug}" is not declared in the system roles or the "${roleKayan}" role table of this tenant.`,
|
|
121
|
+
key: 'error.invalid.role',
|
|
122
|
+
params: { kayan: ctx.kayan.name, role: slug },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// Read from the trusted snapshot, never the actor object.
|
|
126
|
+
const held = heldPermissions({ access: ctx.kayan.access, user: ctx.actor });
|
|
127
|
+
if (held === true)
|
|
128
|
+
return ctx;
|
|
129
|
+
if (keys === '*') {
|
|
130
|
+
throw denied({
|
|
131
|
+
message: `Forbidden: a "${ctx.kayan.name}" row may not assign role "${slug}", which holds every key of this project, and the assigner does not.`,
|
|
132
|
+
key: 'error.forbidden.assign',
|
|
133
|
+
params: { kayan: ctx.kayan.name, permissions: ['*'], role: slug },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Refused for the reason `grantedPermissions` refuses an `undefined` holding.
|
|
137
|
+
const missing = held === undefined ? keys : keys.filter((key) => !held.has(key));
|
|
138
|
+
if (!missing.length)
|
|
139
|
+
return ctx;
|
|
140
|
+
throw denied({
|
|
141
|
+
message: `Forbidden: a "${ctx.kayan.name}" row may not assign role "${slug}", which holds ${missing.map((key) => `"${key}"`).join(', ')}, which the assigner does not hold.`,
|
|
142
|
+
key: 'error.forbidden.assign',
|
|
143
|
+
params: { kayan: ctx.kayan.name, permissions: missing, role: slug },
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The step after `grantedPermissions` in a role kayan's write lists: a tenant's role
|
|
149
|
+
* row may not take a system role's name.
|
|
150
|
+
*/
|
|
151
|
+
export const writableRoleName = (roles) => (ctx) => {
|
|
152
|
+
const data = ctx.data;
|
|
153
|
+
const name = data[NAME] ?? ctx.dataBefore?.[NAME];
|
|
154
|
+
if (typeof name !== 'string' || !Object.hasOwn(roles, name))
|
|
155
|
+
return ctx;
|
|
156
|
+
throw invalid({
|
|
157
|
+
message: `A "${ctx.kayan.name}" row may not be named "${name}", which is a role this project ships: its keys are code, and a row of the same name would grant nothing and list beside it as though it did.`,
|
|
158
|
+
key: 'error.invalid.system_role_name',
|
|
159
|
+
params: { kayan: ctx.kayan.name, role: name },
|
|
160
|
+
});
|
|
161
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Klass, LexicalNode } from 'lexical';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { StandardSchemaV1 } from '../mithaq/standard-schema.ts';
|
|
4
|
+
import type { ResolvedField } from '../mithaq/types.ts';
|
|
5
|
+
import { type RichTextValue } from './rich-text.ts';
|
|
6
|
+
/**
|
|
7
|
+
* The rukn a project lays down for rich text, over its own node list:
|
|
8
|
+
* `arkan: [base, richText({ nodes: richTextNodes })]`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const richText: ({ nodes }: {
|
|
11
|
+
nodes: readonly Klass<LexicalNode>[];
|
|
12
|
+
}) => {
|
|
13
|
+
readonly name: "richText";
|
|
14
|
+
readonly kinds: {
|
|
15
|
+
richText: import("../base/kinds.ts").Kind<RichTextValue, "providedBy" | "required"> & {
|
|
16
|
+
targets: {
|
|
17
|
+
readonly drizzle: (field: ResolvedField) => import("drizzle-orm/pg-core").PgJsonbBuilder;
|
|
18
|
+
readonly zod: (field?: ResolvedField) => z.ZodCustom<RichTextValue, RichTextValue>;
|
|
19
|
+
readonly standardSchema: (field: ResolvedField) => StandardSchemaV1<unknown, RichTextValue>;
|
|
20
|
+
readonly screen: {
|
|
21
|
+
readonly control: "richText";
|
|
22
|
+
readonly cell: "richText";
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsonb } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { kind } from '../base/kinds.js';
|
|
4
|
+
import { defineRukn } from '../mithaq/rukn.js';
|
|
5
|
+
import { isRichText, richTextReader, storedRichText } from './rich-text.js';
|
|
6
|
+
// An emptied editor still sends a root and one empty paragraph, so a blank text
|
|
7
|
+
// with no decorator is missing for `required`.
|
|
8
|
+
const refusalOf = ({ field, read, value }) => {
|
|
9
|
+
const key = (rule) => (field?.kayan ? `${field.kayan}.${field.name}.${rule}` : undefined);
|
|
10
|
+
const required = { message: key('required') ?? 'Required' };
|
|
11
|
+
if (value === undefined && field?.rules?.required)
|
|
12
|
+
return required;
|
|
13
|
+
const got = read(value);
|
|
14
|
+
if ('refused' in got)
|
|
15
|
+
return { message: key('invalid') ?? got.refused, ...(got.type && { params: { type: got.type } }) };
|
|
16
|
+
if (field?.rules?.required && read !== storedRichText && got.text.trim() === '' && !got.holdsDecorator)
|
|
17
|
+
return required;
|
|
18
|
+
return undefined;
|
|
19
|
+
};
|
|
20
|
+
const readerOf = ({ field, nodes }) => field?.richText ?? richTextReader(nodes, field?.filesIn);
|
|
21
|
+
const richTextKind = ({ nodes }) => kind(isRichText)({
|
|
22
|
+
label: { en: 'Rich text', ar: 'نص منسق' },
|
|
23
|
+
message: { en: 'This is not rich text.', ar: 'هذا ليس نصا منسقا.' },
|
|
24
|
+
rules: ['providedBy', 'required'],
|
|
25
|
+
// The value is a tree, so the store searches its text and no other operator or order applies.
|
|
26
|
+
operators: ['like', 'contains', 'exists'],
|
|
27
|
+
sortable: false,
|
|
28
|
+
targets: {
|
|
29
|
+
drizzle: (field) => jsonb(field.column),
|
|
30
|
+
zod: (field) => {
|
|
31
|
+
const read = readerOf({ field, nodes });
|
|
32
|
+
return z.custom().superRefine((value, ctx) => {
|
|
33
|
+
const refused = refusalOf({ field, read, value });
|
|
34
|
+
if (refused)
|
|
35
|
+
ctx.addIssue({ code: 'custom', ...refused });
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
standardSchema: (field) => {
|
|
39
|
+
const read = readerOf({ field, nodes });
|
|
40
|
+
return {
|
|
41
|
+
'~standard': {
|
|
42
|
+
version: 1,
|
|
43
|
+
vendor: 'mithaq',
|
|
44
|
+
validate: (value) => {
|
|
45
|
+
const refused = refusalOf({ field, read, value });
|
|
46
|
+
return refused ? { issues: [refused] } : { value: value };
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
screen: { control: 'richText', cell: 'richText' },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* The rukn a project lays down for rich text, over its own node list:
|
|
56
|
+
* `arkan: [base, richText({ nodes: richTextNodes })]`.
|
|
57
|
+
*/
|
|
58
|
+
export const richText = ({ nodes }) => defineRukn({ name: 'richText', kinds: { richText: richTextKind({ nodes }) } });
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { DecoratorNode, type ElementFormatType, type Klass, type LexicalNode, type LexicalUpdateJSON, type SerializedLexicalNode } from 'lexical';
|
|
2
|
+
/** One node of Lexical's serialized editor state: a `type`, and `children` where it holds any. */
|
|
3
|
+
export type RichTextNode = {
|
|
4
|
+
type: string;
|
|
5
|
+
children?: RichTextNode[];
|
|
6
|
+
text?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
/** What a `richText` field holds: Lexical's `editorState.toJSON()`, a root of `type: 'root'`. */
|
|
10
|
+
export type RichTextValue = {
|
|
11
|
+
root: RichTextNode & {
|
|
12
|
+
type: 'root';
|
|
13
|
+
children: RichTextNode[];
|
|
14
|
+
};
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* How deep a rich text value may nest, how large it may be and how many nodes it may hold,
|
|
19
|
+
* all checked before any Lexical code reads it.
|
|
20
|
+
*/
|
|
21
|
+
export declare const richTextBounds: {
|
|
22
|
+
depth: number;
|
|
23
|
+
bytes: number;
|
|
24
|
+
nodes: number;
|
|
25
|
+
};
|
|
26
|
+
/** Whether a link may point here: `http`, `https`, `mailto`, `tel`, a fragment or a path. */
|
|
27
|
+
export declare const safeUrl: (url: unknown) => boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Why a value is no Lexical editor state a `richText` column may hold, or nothing. Runs before
|
|
30
|
+
* any Lexical code reads the value; a `style` is refused, since no editor of the project writes one.
|
|
31
|
+
*/
|
|
32
|
+
export declare const richTextGuard: (value: unknown) => string | undefined;
|
|
33
|
+
/** Whether a value passes `richTextGuard`, which is all the kind itself asks of one. */
|
|
34
|
+
export declare const isRichText: (value: unknown) => value is RichTextValue;
|
|
35
|
+
/** The node classes the registry's editor draws. A project's list spreads it: `[...defaultNodes, CalloutNode]`. */
|
|
36
|
+
export declare const defaultNodes: readonly Klass<LexicalNode>[];
|
|
37
|
+
/** Every node type a list of classes can read, the core ones included. */
|
|
38
|
+
export declare const typesOf: (nodes: readonly Klass<LexicalNode>[]) => ReadonlySet<string>;
|
|
39
|
+
/**
|
|
40
|
+
* The first node type under a value's `root` that `types` does not hold, or nothing, and `''`
|
|
41
|
+
* past the depth bound. Read off the JSON, since a production Lexical minifies its errors.
|
|
42
|
+
*/
|
|
43
|
+
export declare const unknownType: ({ node, types, depth }: {
|
|
44
|
+
node: unknown;
|
|
45
|
+
types: ReadonlySet<string>;
|
|
46
|
+
depth?: number;
|
|
47
|
+
}) => string | undefined;
|
|
48
|
+
/** An upload node as Payload's lexical editor writes it, v3. */
|
|
49
|
+
export type SerializedUploadNode = SerializedLexicalNode & {
|
|
50
|
+
type: 'upload';
|
|
51
|
+
version: 3;
|
|
52
|
+
format: ElementFormatType;
|
|
53
|
+
id: string;
|
|
54
|
+
relationTo: string;
|
|
55
|
+
value: string;
|
|
56
|
+
fields: Record<string, unknown> | null;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* A file inside rich text, in the JSON Payload's editor writes (`type: 'upload'`, `version: 3`).
|
|
60
|
+
* DOM-free, so the server checks a write with it. A project opts in: `[...defaultNodes, UploadNode]`.
|
|
61
|
+
*/
|
|
62
|
+
export declare class UploadNode extends DecoratorNode<unknown> {
|
|
63
|
+
$config(): import("lexical").BaseStaticNodeConfig & {
|
|
64
|
+
readonly upload?: {
|
|
65
|
+
readonly extends: typeof DecoratorNode;
|
|
66
|
+
readonly stateConfigs: readonly [{
|
|
67
|
+
readonly stateConfig: import("lexical").StateConfig<"format", ElementFormatType>;
|
|
68
|
+
readonly flat: true;
|
|
69
|
+
}, {
|
|
70
|
+
readonly stateConfig: import("lexical").StateConfig<"id", string>;
|
|
71
|
+
readonly flat: true;
|
|
72
|
+
}, {
|
|
73
|
+
readonly stateConfig: import("lexical").StateConfig<"relationTo", string>;
|
|
74
|
+
readonly flat: true;
|
|
75
|
+
}, {
|
|
76
|
+
readonly stateConfig: import("lexical").StateConfig<"value", string>;
|
|
77
|
+
readonly flat: true;
|
|
78
|
+
}, {
|
|
79
|
+
readonly stateConfig: import("lexical").StateConfig<"fields", Record<string, unknown> | null>;
|
|
80
|
+
readonly flat: true;
|
|
81
|
+
}];
|
|
82
|
+
} | undefined;
|
|
83
|
+
} & import("lexical").StaticNodeTypeAccessor<"upload"> & import("lexical").StaticNodeConfigAccessor<{
|
|
84
|
+
readonly extends: typeof DecoratorNode;
|
|
85
|
+
readonly stateConfigs: readonly [{
|
|
86
|
+
readonly stateConfig: import("lexical").StateConfig<"format", ElementFormatType>;
|
|
87
|
+
readonly flat: true;
|
|
88
|
+
}, {
|
|
89
|
+
readonly stateConfig: import("lexical").StateConfig<"id", string>;
|
|
90
|
+
readonly flat: true;
|
|
91
|
+
}, {
|
|
92
|
+
readonly stateConfig: import("lexical").StateConfig<"relationTo", string>;
|
|
93
|
+
readonly flat: true;
|
|
94
|
+
}, {
|
|
95
|
+
readonly stateConfig: import("lexical").StateConfig<"value", string>;
|
|
96
|
+
readonly flat: true;
|
|
97
|
+
}, {
|
|
98
|
+
readonly stateConfig: import("lexical").StateConfig<"fields", Record<string, unknown> | null>;
|
|
99
|
+
readonly flat: true;
|
|
100
|
+
}];
|
|
101
|
+
}>;
|
|
102
|
+
/** Refused where either half of what the node points at is missing, which an absent key leaves at its default. */
|
|
103
|
+
updateFromJSON(json: LexicalUpdateJSON<SerializedLexicalNode>): this;
|
|
104
|
+
/** Every key, defaults included, as Payload's editor writes it. */
|
|
105
|
+
exportJSON(): SerializedUploadNode;
|
|
106
|
+
getRelationTo(): string;
|
|
107
|
+
getValue(): string;
|
|
108
|
+
getFormat(): ElementFormatType;
|
|
109
|
+
/** Points the node at one file, with a fresh node id: an ObjectID's twelve bytes in hex. */
|
|
110
|
+
setUpload(relationTo: string, value: string): this;
|
|
111
|
+
createDOM(): HTMLElement;
|
|
112
|
+
updateDOM(): false;
|
|
113
|
+
isInline(): false;
|
|
114
|
+
decorate(): unknown;
|
|
115
|
+
}
|
|
116
|
+
/** What a write's value reads as: its text and whether it holds a decorator, or why it is refused. */
|
|
117
|
+
export type RichTextRead = {
|
|
118
|
+
text: string;
|
|
119
|
+
holdsDecorator?: boolean;
|
|
120
|
+
} | {
|
|
121
|
+
refused: string;
|
|
122
|
+
type?: string;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* A reader of `richText` values over a node list: the guard walk, then a headless parse.
|
|
126
|
+
* `uploads` names the kayan an `UploadNode` may point at.
|
|
127
|
+
*/
|
|
128
|
+
export declare const richTextReader: (nodes: readonly Klass<LexicalNode>[], uploads?: readonly string[]) => (value: unknown) => RichTextRead;
|
|
129
|
+
/**
|
|
130
|
+
* How a stored `richText` value is read back: a root with children and nothing more,
|
|
131
|
+
* so a value holding a node the project has since dropped still reads.
|
|
132
|
+
*/
|
|
133
|
+
export declare const storedRichText: (value: unknown) => RichTextRead;
|
|
134
|
+
/** What a converter left out of a value: the node's path in what it was handed, its type, and why. */
|
|
135
|
+
export type RichTextDropped = {
|
|
136
|
+
path: string;
|
|
137
|
+
type: string;
|
|
138
|
+
reason: string;
|
|
139
|
+
};
|
|
140
|
+
/** A converted value, which the guard and the parse of the same node list take, with what was left out of it, or why there is none. */
|
|
141
|
+
export type RichTextConverted = {
|
|
142
|
+
value: RichTextValue;
|
|
143
|
+
dropped: RichTextDropped[];
|
|
144
|
+
} | {
|
|
145
|
+
refused: string;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* A value Payload's lexical editor stored, as one this editor reads, with what could not come across.
|
|
149
|
+
* A link the editor would not write is replaced by its text; a `style` is cleared.
|
|
150
|
+
*/
|
|
151
|
+
export declare const convertPayloadToLexical: ({ data, nodes }: {
|
|
152
|
+
data: unknown;
|
|
153
|
+
nodes: readonly Klass<LexicalNode>[];
|
|
154
|
+
}) => RichTextConverted;
|
|
155
|
+
/** Plain text as a value: a blank line starts a paragraph, a newline is a `linebreak` and a tab a `tab`. */
|
|
156
|
+
export declare const convertPlaintextToLexical: ({ text, nodes }: {
|
|
157
|
+
text: string;
|
|
158
|
+
nodes: readonly Klass<LexicalNode>[];
|
|
159
|
+
}) => RichTextConverted;
|