@nexa-stack/framework 1.0.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.
Files changed (81) hide show
  1. package/.env.example +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/bin/nexa.mjs +41 -0
  5. package/bin/nexa.ts +334 -0
  6. package/docs/AI.md +69 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/EXAMPLES.md +114 -0
  9. package/docs/FRAMEWORK.md +226 -0
  10. package/docs/LANGUAGE.md +39 -0
  11. package/docs/README.md +7 -0
  12. package/docs/READY.md +51 -0
  13. package/docs/REFERENCE.md +255 -0
  14. package/docs/START.md +98 -0
  15. package/docs/advanced.md +97 -0
  16. package/docs/authentication.md +54 -0
  17. package/docs/cli.md +15 -0
  18. package/docs/compare.md +51 -0
  19. package/docs/configuration.md +65 -0
  20. package/docs/database.md +396 -0
  21. package/docs/installation.md +57 -0
  22. package/docs/localization.md +47 -0
  23. package/docs/resources.md +75 -0
  24. package/docs/routing.md +59 -0
  25. package/docs/seeding.md +33 -0
  26. package/docs/services.md +146 -0
  27. package/package.json +77 -0
  28. package/packages/auth/src/auth.test.ts +23 -0
  29. package/packages/auth/src/auth.ts +287 -0
  30. package/packages/auth/src/index.ts +17 -0
  31. package/packages/cache/src/index.ts +203 -0
  32. package/packages/client/src/index.ts +49 -0
  33. package/packages/core/src/app.ts +97 -0
  34. package/packages/core/src/config.ts +55 -0
  35. package/packages/core/src/dev.ts +104 -0
  36. package/packages/core/src/fields.test.ts +81 -0
  37. package/packages/core/src/fields.ts +309 -0
  38. package/packages/core/src/index.ts +60 -0
  39. package/packages/core/src/lang.ts +79 -0
  40. package/packages/core/src/loader.ts +42 -0
  41. package/packages/core/src/migrate.ts +91 -0
  42. package/packages/core/src/policy.ts +36 -0
  43. package/packages/core/src/registry.ts +17 -0
  44. package/packages/core/src/reload.ts +92 -0
  45. package/packages/core/src/resource.test.ts +32 -0
  46. package/packages/core/src/resource.ts +87 -0
  47. package/packages/core/src/routes.ts +22 -0
  48. package/packages/core/src/runtime.ts +11 -0
  49. package/packages/database/src/builder.ts +266 -0
  50. package/packages/database/src/database.ts +252 -0
  51. package/packages/database/src/dialect.ts +186 -0
  52. package/packages/database/src/index.ts +6 -0
  53. package/packages/database/src/mysql.ts +114 -0
  54. package/packages/database/src/postgres.ts +117 -0
  55. package/packages/database/src/query.ts +115 -0
  56. package/packages/database/src/sqlite.ts +216 -0
  57. package/packages/database/src/types.ts +104 -0
  58. package/packages/events/src/index.ts +17 -0
  59. package/packages/export/src/index.ts +36 -0
  60. package/packages/log/src/index.ts +38 -0
  61. package/packages/mail/src/index.ts +130 -0
  62. package/packages/notifications/src/index.ts +84 -0
  63. package/packages/plugins/src/index.ts +39 -0
  64. package/packages/queue/src/index.ts +185 -0
  65. package/packages/queue/src/jobs.ts +9 -0
  66. package/packages/schedule/src/index.ts +64 -0
  67. package/packages/server/src/index.ts +1 -0
  68. package/packages/server/src/middleware.ts +143 -0
  69. package/packages/server/src/query.ts +40 -0
  70. package/packages/server/src/router.ts +813 -0
  71. package/packages/sms/src/index.ts +33 -0
  72. package/packages/storage/src/upload.ts +36 -0
  73. package/packages/testing/src/index.ts +67 -0
  74. package/packages/validation/src/index.ts +1 -0
  75. package/packages/validation/src/validate.test.ts +35 -0
  76. package/packages/validation/src/validate.ts +112 -0
  77. package/public/admin.html +369 -0
  78. package/public/compare.html +66 -0
  79. package/public/dev-bar.js +213 -0
  80. package/public/docs.html +315 -0
  81. package/public/index.html +66 -0
@@ -0,0 +1,309 @@
1
+ export type FieldType =
2
+ | "string"
3
+ | "number"
4
+ | "boolean"
5
+ | "text"
6
+ | "email"
7
+ | "money"
8
+ | "date"
9
+ | "datetime"
10
+ | "select"
11
+ | "relation"
12
+ | "hasMany"
13
+ | "belongsToMany"
14
+ | "file"
15
+ | "image";
16
+
17
+ export type RelationMode = "belongsTo" | "hasMany" | "belongsToMany";
18
+
19
+ export interface FieldDef {
20
+ type: FieldType;
21
+ required: boolean;
22
+ min?: number;
23
+ max?: number;
24
+ relation?: string;
25
+ column?: string;
26
+ relationMode?: RelationMode;
27
+ options?: string[];
28
+ label?: string;
29
+ /** Override pivot table name for belongsToMany */
30
+ pivot?: string;
31
+ }
32
+
33
+ export class FieldBuilder {
34
+ constructor(private def: FieldDef) {}
35
+
36
+ required(): FieldBuilder {
37
+ return new FieldBuilder({ ...this.def, required: true });
38
+ }
39
+
40
+ min(n: number): FieldBuilder {
41
+ return new FieldBuilder({ ...this.def, min: n });
42
+ }
43
+
44
+ max(n: number): FieldBuilder {
45
+ return new FieldBuilder({ ...this.def, max: n });
46
+ }
47
+
48
+ label(text: string): FieldBuilder {
49
+ return new FieldBuilder({ ...this.def, label: text });
50
+ }
51
+
52
+ build(): FieldDef {
53
+ return this.def;
54
+ }
55
+ }
56
+
57
+ export function string(): FieldBuilder {
58
+ return new FieldBuilder({ type: "string", required: false });
59
+ }
60
+
61
+ export function number(): FieldBuilder {
62
+ return new FieldBuilder({ type: "number", required: false });
63
+ }
64
+
65
+ export function boolean(): FieldBuilder {
66
+ return new FieldBuilder({ type: "boolean", required: false });
67
+ }
68
+
69
+ export function text(): FieldBuilder {
70
+ return new FieldBuilder({ type: "text", required: false });
71
+ }
72
+
73
+ export function email(): FieldBuilder {
74
+ return new FieldBuilder({ type: "email", required: false });
75
+ }
76
+
77
+ export function money(): FieldBuilder {
78
+ return new FieldBuilder({ type: "money", required: false });
79
+ }
80
+
81
+ export function date(): FieldBuilder {
82
+ return new FieldBuilder({ type: "date", required: false });
83
+ }
84
+
85
+ export function datetime(): FieldBuilder {
86
+ return new FieldBuilder({ type: "datetime", required: false });
87
+ }
88
+
89
+ /** Dropdown — options are plain strings stored as TEXT */
90
+ export function select(options: string[]): FieldBuilder {
91
+ return new FieldBuilder({ type: "select", required: false, options: [...options] });
92
+ }
93
+
94
+ export function file(): FieldBuilder {
95
+ return new FieldBuilder({ type: "file", required: false });
96
+ }
97
+
98
+ export function image(): FieldBuilder {
99
+ return new FieldBuilder({ type: "image", required: false });
100
+ }
101
+
102
+ export function belongsTo(target: string): FieldBuilder {
103
+ return new FieldBuilder({
104
+ type: "relation",
105
+ required: false,
106
+ relation: target.toLowerCase(),
107
+ relationMode: "belongsTo",
108
+ });
109
+ }
110
+
111
+ export function hasMany(target: string): FieldBuilder {
112
+ return new FieldBuilder({
113
+ type: "hasMany",
114
+ required: false,
115
+ relation: target.toLowerCase(),
116
+ relationMode: "hasMany",
117
+ });
118
+ }
119
+
120
+ /** Many-to-many — auto pivot table (e.g. posts + tags → post_tag) */
121
+ export function belongsToMany(target: string, pivot?: string): FieldBuilder {
122
+ return new FieldBuilder({
123
+ type: "belongsToMany",
124
+ required: false,
125
+ relation: target.toLowerCase(),
126
+ relationMode: "belongsToMany",
127
+ pivot: pivot?.toLowerCase(),
128
+ });
129
+ }
130
+
131
+ const SQL_RESERVED = new Set(
132
+ [
133
+ "abort",
134
+ "action",
135
+ "add",
136
+ "after",
137
+ "all",
138
+ "alter",
139
+ "analyze",
140
+ "and",
141
+ "as",
142
+ "asc",
143
+ "attach",
144
+ "autoincrement",
145
+ "before",
146
+ "begin",
147
+ "between",
148
+ "by",
149
+ "cascade",
150
+ "case",
151
+ "cast",
152
+ "check",
153
+ "collate",
154
+ "column",
155
+ "commit",
156
+ "conflict",
157
+ "constraint",
158
+ "create",
159
+ "cross",
160
+ "current_date",
161
+ "current_time",
162
+ "current_timestamp",
163
+ "database",
164
+ "default",
165
+ "deferrable",
166
+ "deferred",
167
+ "delete",
168
+ "desc",
169
+ "detach",
170
+ "distinct",
171
+ "drop",
172
+ "each",
173
+ "else",
174
+ "end",
175
+ "escape",
176
+ "except",
177
+ "exclusive",
178
+ "exists",
179
+ "explain",
180
+ "fail",
181
+ "for",
182
+ "foreign",
183
+ "from",
184
+ "full",
185
+ "glob",
186
+ "group",
187
+ "having",
188
+ "if",
189
+ "ignore",
190
+ "immediate",
191
+ "in",
192
+ "index",
193
+ "indexed",
194
+ "initially",
195
+ "inner",
196
+ "insert",
197
+ "instead",
198
+ "intersect",
199
+ "into",
200
+ "is",
201
+ "isnull",
202
+ "join",
203
+ "key",
204
+ "left",
205
+ "like",
206
+ "limit",
207
+ "match",
208
+ "natural",
209
+ "no",
210
+ "not",
211
+ "notnull",
212
+ "null",
213
+ "of",
214
+ "offset",
215
+ "on",
216
+ "or",
217
+ "order",
218
+ "outer",
219
+ "plan",
220
+ "pragma",
221
+ "primary",
222
+ "query",
223
+ "raise",
224
+ "recursive",
225
+ "references",
226
+ "regexp",
227
+ "reindex",
228
+ "release",
229
+ "rename",
230
+ "replace",
231
+ "restrict",
232
+ "right",
233
+ "rollback",
234
+ "row",
235
+ "savepoint",
236
+ "select",
237
+ "set",
238
+ "table",
239
+ "temp",
240
+ "temporary",
241
+ "then",
242
+ "to",
243
+ "transaction",
244
+ "trigger",
245
+ "union",
246
+ "unique",
247
+ "update",
248
+ "using",
249
+ "vacuum",
250
+ "values",
251
+ "view",
252
+ "virtual",
253
+ "when",
254
+ "where",
255
+ "with",
256
+ "without",
257
+ ].map((w) => w.toLowerCase())
258
+ );
259
+
260
+ export function relationColumnName(fieldName: string): string {
261
+ const base = fieldName.endsWith("_id") ? fieldName.slice(0, -3) : fieldName;
262
+ return `${base}_id`;
263
+ }
264
+
265
+ export function resolveFields(
266
+ fields: Record<string, FieldBuilder>
267
+ ): Record<string, FieldDef> {
268
+ const resolved: Record<string, FieldDef> = {};
269
+ for (const [name, builder] of Object.entries(fields)) {
270
+ if (SQL_RESERVED.has(name.toLowerCase())) {
271
+ throw new Error(
272
+ `Field name "${name}" is a SQL reserved word. Rename it (e.g. lesson_order instead of order).`
273
+ );
274
+ }
275
+ const def = builder.build();
276
+ if (def.type === "relation" && !def.column) {
277
+ def.column = relationColumnName(name);
278
+ }
279
+ resolved[name] = def;
280
+ }
281
+ return resolved;
282
+ }
283
+
284
+ export function fieldColumn(name: string, field: FieldDef): string | null {
285
+ if (field.type === "hasMany" || field.type === "belongsToMany") return null;
286
+ if (field.type === "relation") return field.column ?? relationColumnName(name);
287
+ return name;
288
+ }
289
+
290
+ export function isStoredField(field: FieldDef): boolean {
291
+ return field.type !== "hasMany" && field.type !== "belongsToMany";
292
+ }
293
+
294
+ export function singularize(table: string): string {
295
+ const t = table.toLowerCase();
296
+ if (t.endsWith("ies") && t.length > 3) return `${t.slice(0, -3)}y`;
297
+ if (t.endsWith("ses")) return t.slice(0, -2);
298
+ if (t.endsWith("s") && t.length > 1) return t.slice(0, -1);
299
+ return t;
300
+ }
301
+
302
+ export function foreignKeyFor(parentTable: string): string {
303
+ return `${singularize(parentTable)}_id`;
304
+ }
305
+
306
+ /** Alphabetical pivot table: posts + tags → post_tag */
307
+ export function pivotTableFor(a: string, b: string): string {
308
+ return [singularize(a), singularize(b)].sort().join("_");
309
+ }
@@ -0,0 +1,60 @@
1
+ export { resource, type Resource, type ResourceDefinition } from "./resource.js";
2
+ export {
3
+ string,
4
+ number,
5
+ boolean,
6
+ text,
7
+ email,
8
+ money,
9
+ date,
10
+ datetime,
11
+ select,
12
+ belongsTo,
13
+ hasMany,
14
+ belongsToMany,
15
+ file,
16
+ image,
17
+ type FieldDef,
18
+ FieldBuilder,
19
+ } from "./fields.js";
20
+ export { loadResources } from "./loader.js";
21
+ export {
22
+ checkPolicy,
23
+ policyFromRole,
24
+ type PolicyAction,
25
+ type PolicyRule,
26
+ type ResourcePolicy,
27
+ } from "./policy.js";
28
+ export { start, db, connect, serve, getDb } from "./app.js";
29
+ export { route, clearCustomRoutes, type RouteHandler } from "./routes.js";
30
+ export { runMigrations, rollbackMigrations, runSeeders } from "./migrate.js";
31
+ export { QueryBuilder } from "../../database/src/builder.js";
32
+ export { createClient } from "../../client/src/index.js";
33
+ export { setupAuth, seedAdmin, login, register, signToken, verifyToken, hasRole } from "../../auth/src/index.js";
34
+ export { config, loadEnv } from "./config.js";
35
+ export { on, emit, clearEvents } from "../../events/src/index.js";
36
+ export { setupQueue, dispatch, processJobs, registerBuiltInJobs, workQueue } from "../../queue/src/index.js";
37
+ export { saveUpload, ensureStorage } from "../../storage/src/upload.js";
38
+ export { Mail, mail, sendMail } from "../../mail/src/index.js";
39
+ export {
40
+ notify,
41
+ setupNotifications,
42
+ unreadNotifications,
43
+ markNotificationRead,
44
+ markAllRead,
45
+ } from "../../notifications/src/index.js";
46
+ export { cache } from "../../cache/src/index.js";
47
+ export { log } from "../../log/src/index.js";
48
+ export { schedule, runDueTasks, startScheduler, stopScheduler } from "../../schedule/src/index.js";
49
+ export { sms } from "../../sms/src/index.js";
50
+ export { toCsv, exportResponse } from "../../export/src/index.js";
51
+ export { plugin, bootPlugins, hook, listPlugins, clearPlugins } from "../../plugins/src/index.js";
52
+ export { use, rateLimit, cors, clearMiddleware } from "../../server/src/middleware.js";
53
+ export { createTestApp } from "../../testing/src/index.js";
54
+ export {
55
+ forgotPassword,
56
+ resetPassword,
57
+ verifyEmail,
58
+ resendVerification,
59
+ sendVerificationEmail,
60
+ } from "../../auth/src/auth.js";
@@ -0,0 +1,79 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { config } from "./config.js";
4
+
5
+ type LangData = Record<string, string | Record<string, string>>;
6
+
7
+ const cache = new Map<string, LangData>();
8
+
9
+ const defaultUi: Record<string, string> = {
10
+ title: "Nexa",
11
+ dashboard: "Dashboard",
12
+ login: "Login",
13
+ logout: "Logout",
14
+ add: "Add",
15
+ delete: "Delete",
16
+ empty: "No data",
17
+ confirm_delete: "Delete?",
18
+ };
19
+
20
+ /** When a locale file is missing, fall back to APP_LOCALE then English. */
21
+ function fallbackLocale(locale: string): string {
22
+ const preferred = config("APP_LOCALE") || "en";
23
+ if (locale !== preferred) return preferred;
24
+ return "en";
25
+ }
26
+
27
+ export function loadLang(locale: string, root = process.cwd()): LangData {
28
+ const key = `${root}:${locale}`;
29
+ if (cache.has(key)) return cache.get(key)!;
30
+
31
+ const file = join(root, "lang", `${locale}.json`);
32
+ if (existsSync(file)) {
33
+ const data = JSON.parse(readFileSync(file, "utf-8")) as LangData;
34
+ cache.set(key, data);
35
+ return data;
36
+ }
37
+
38
+ const alt = fallbackLocale(locale);
39
+ if (alt !== locale) {
40
+ const altFile = join(root, "lang", `${alt}.json`);
41
+ if (existsSync(altFile)) {
42
+ const data = JSON.parse(readFileSync(altFile, "utf-8")) as LangData;
43
+ cache.set(key, data);
44
+ return data;
45
+ }
46
+ }
47
+
48
+ const data: LangData = { ui: { ...defaultUi } };
49
+ cache.set(key, data);
50
+ return data;
51
+ }
52
+
53
+ export function clearLangCache(): void {
54
+ cache.clear();
55
+ }
56
+
57
+ export function t(key: string, locale = "en", root = process.cwd()): string {
58
+ const data = loadLang(locale, root);
59
+ const val = data[key];
60
+ return typeof val === "string" ? val : key;
61
+ }
62
+
63
+ export function ui(key: string, locale = "en", root = process.cwd()): string {
64
+ const data = loadLang(locale, root);
65
+ const uiBlock = data.ui;
66
+ if (typeof uiBlock === "object" && key in uiBlock) return uiBlock[key];
67
+ return defaultUi[key] ?? key;
68
+ }
69
+
70
+ export function getUi(locale = "en", root = process.cwd()): Record<string, string> {
71
+ const data = loadLang(locale, root);
72
+ const uiBlock = data.ui;
73
+ if (typeof uiBlock === "object") return { ...defaultUi, ...uiBlock };
74
+ return { ...defaultUi };
75
+ }
76
+
77
+ export function defaultLocale(): string {
78
+ return config("APP_LOCALE") || "en";
79
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
2
+ import { join, dirname, basename } from "path";
3
+ import { pathToFileURL } from "url";
4
+
5
+ /** Import a TS file in a fresh module context (Bun ignores ?query cache bust). */
6
+ export async function importFreshTs(absPath: string): Promise<void> {
7
+ const dir = dirname(absPath);
8
+ const shadow = join(dir, `.nexa-reload-${Date.now()}-${basename(absPath)}`);
9
+ writeFileSync(shadow, readFileSync(absPath, "utf8"));
10
+ try {
11
+ await import(pathToFileURL(shadow).href);
12
+ } finally {
13
+ try {
14
+ unlinkSync(shadow);
15
+ } catch {
16
+ /* ignore */
17
+ }
18
+ }
19
+ }
20
+
21
+ /** Auto-import every `resources/*.ts` so you never wire them in app.ts */
22
+ export async function loadResources(
23
+ cwd = process.cwd(),
24
+ opts?: { reload?: boolean }
25
+ ): Promise<number> {
26
+ const dir = join(cwd, "resources");
27
+ let files: string[] = [];
28
+ try {
29
+ files = readdirSync(dir)
30
+ .filter((f) => f.endsWith(".ts") && !f.startsWith(".nexa-reload-"))
31
+ .sort();
32
+ } catch {
33
+ return 0;
34
+ }
35
+
36
+ for (const file of files) {
37
+ const abs = join(dir, file);
38
+ if (opts?.reload) await importFreshTs(abs);
39
+ else await import(pathToFileURL(abs).href);
40
+ }
41
+ return files.length;
42
+ }
@@ -0,0 +1,91 @@
1
+ import { join } from "path";
2
+ import { readdirSync } from "fs";
3
+ import { getResources } from "./registry.js";
4
+ import { createDatabase, type Database } from "../../database/src/index.js";
5
+ import { migrationsTableSql } from "../../database/src/dialect.js";
6
+
7
+ export async function runMigrations(dbPath: string): Promise<Database> {
8
+ const db = createDatabase(dbPath);
9
+ await db.connect();
10
+
11
+ await db.run(migrationsTableSql(db.dialect));
12
+
13
+ for (const def of getResources()) {
14
+ await db.createTable(def);
15
+ }
16
+
17
+ const dir = join(process.cwd(), "database", "migrations");
18
+ let files: string[] = [];
19
+ try {
20
+ files = readdirSync(dir).filter((f) => f.endsWith(".ts")).sort();
21
+ } catch {
22
+ /* no migrations folder */
23
+ }
24
+
25
+ for (const file of files) {
26
+ const name = file.replace(/\.ts$/, "");
27
+ const ran = await db.getOne("SELECT id FROM _nexa_migrations WHERE name = ?", [name]);
28
+ if (ran) continue;
29
+
30
+ const mod = await import(new URL(`file:///${dir.replace(/\\/g, "/")}/${file}`).href);
31
+ if (mod.up) await mod.up(db);
32
+ await db.run("INSERT INTO _nexa_migrations (name) VALUES (?)", [name]);
33
+ console.log(`Migrated: ${name}`);
34
+ }
35
+
36
+ console.log("Migrations complete");
37
+ return db;
38
+ }
39
+
40
+ /** Create / sync tables for all registered resources (hot reload). */
41
+ export async function syncResourceTables(db: Database): Promise<void> {
42
+ for (const def of getResources()) {
43
+ await db.createTable(def);
44
+ await db.syncColumns(def);
45
+ }
46
+ }
47
+
48
+ /** Roll back the last N file migrations that define `down` */
49
+ export async function rollbackMigrations(db: Database, steps = 1): Promise<number> {
50
+ const rows = await db.query(
51
+ `SELECT name FROM _nexa_migrations ORDER BY id DESC LIMIT ?`,
52
+ [steps]
53
+ );
54
+ const dir = join(process.cwd(), "database", "migrations").replace(/\\/g, "/");
55
+ let done = 0;
56
+ for (const row of rows) {
57
+ const name = String(row.name);
58
+ try {
59
+ const mod = await import(new URL(`file:///${dir}/${name}.ts`).href);
60
+ if (mod.down) {
61
+ await mod.down(db);
62
+ await db.run(`DELETE FROM _nexa_migrations WHERE name = ?`, [name]);
63
+ console.log(`Rolled back: ${name}`);
64
+ done++;
65
+ } else {
66
+ console.log(`Skip rollback (no down): ${name}`);
67
+ }
68
+ } catch (e) {
69
+ console.error(`Rollback failed: ${name}`, e);
70
+ break;
71
+ }
72
+ }
73
+ return done;
74
+ }
75
+
76
+ export async function runSeeders(db: Database): Promise<void> {
77
+ const dir = join(process.cwd(), "database", "seeders");
78
+ let files: string[] = [];
79
+ try {
80
+ files = readdirSync(dir).filter((f) => f.endsWith(".ts")).sort();
81
+ } catch {
82
+ return;
83
+ }
84
+
85
+ for (const file of files) {
86
+ const mod = await import(new URL(`file:///${dir.replace(/\\/g, "/")}/${file}`).href);
87
+ if (mod.default) await mod.default(db);
88
+ else if (mod.seed) await mod.seed(db);
89
+ console.log(`Seeded: ${file}`);
90
+ }
91
+ }
@@ -0,0 +1,36 @@
1
+ import type { AuthUser } from "../../auth/src/auth.js";
2
+ import { hasRole } from "../../auth/src/auth.js";
3
+
4
+ export type PolicyAction = "view" | "create" | "update" | "delete";
5
+ export type PolicyRule = string | ((user: AuthUser | null, item?: Record<string, unknown>) => boolean);
6
+
7
+ export interface ResourcePolicy {
8
+ view?: PolicyRule;
9
+ create?: PolicyRule;
10
+ update?: PolicyRule;
11
+ delete?: PolicyRule;
12
+ }
13
+
14
+ export function checkPolicy(
15
+ policy: ResourcePolicy | null,
16
+ action: PolicyAction,
17
+ user: AuthUser | null,
18
+ item?: Record<string, unknown>
19
+ ): boolean {
20
+ if (!policy) return true;
21
+
22
+ const rule = policy[action];
23
+ if (!rule) return true;
24
+
25
+ if (typeof rule === "string") return hasRole(user, rule);
26
+ return rule(user, item);
27
+ }
28
+
29
+ export function policyFromRole(role: string): ResourcePolicy {
30
+ return {
31
+ view: role,
32
+ create: role,
33
+ update: role,
34
+ delete: role,
35
+ };
36
+ }
@@ -0,0 +1,17 @@
1
+ import type { ResourceDefinition } from "./resource.js";
2
+
3
+ const registry: ResourceDefinition[] = [];
4
+
5
+ export function registerResource(def: ResourceDefinition): void {
6
+ const i = registry.findIndex((r) => r.name === def.name);
7
+ if (i >= 0) registry[i] = def;
8
+ else registry.push(def);
9
+ }
10
+
11
+ export function getResources(): ResourceDefinition[] {
12
+ return registry;
13
+ }
14
+
15
+ export function clearRegistry(): void {
16
+ registry.length = 0;
17
+ }