create-drobek-module 0.3.3

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/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # create-drobek-module
2
+
3
+ Scaffold a [drobek](https://github.com/freema/drobek) platform module:
4
+
5
+ ```sh
6
+ npm create drobek-module@latest erp
7
+ # → drobek-module-erp/ — the module "erp"
8
+ npm create drobek-module@latest @acme/drobek-module-erp
9
+ npm create drobek-module@latest acme-erp -- --module acmeerp
10
+ ```
11
+
12
+ Options: `--module <name>` (the module name, `^[a-z][a-z0-9]{1,30}$`;
13
+ default: the package name without `drobek-module-` and dashes), `--dir
14
+ <parent>`, `--force` (write into a non-empty directory).
15
+
16
+ The output is a working module against the module contract `^1.1`:
17
+
18
+ - `src/index.ts` — `defineModule` with `contract`, config + an owner
19
+ confirmation, a secret, a limit, an own error code and a GET/POST route
20
+ pair over the module's table;
21
+ - `src/sdk.ts` — the browser half (`drobek.<name>.list()` / `add(title)`);
22
+ - `src/schema.ts` + `migrations/0000_init.sql` — the table `mod_<name>_items`;
23
+ - `SKILL.md` — the five-section skill an agent reads with `skill_info`;
24
+ - `src/index.test.ts` — the routes through the production pipeline
25
+ (`createModuleTestContext`) over PGlite with the drobek core migrations;
26
+ - `src/skill.test.ts` — `checkSkill`, the SKILL.md gate of the built-in
27
+ modules (`npm run check`);
28
+ - `README.md` — building, publishing and installing it on a server
29
+ (`task selfhost:module:add`).
30
+
31
+ The generated `package.json` installs the contract from npm under the name
32
+ the code imports — `"@drobek/modules": "npm:@freema/drobek-modules@^X.Y.Z"`
33
+ in `devDependencies` — and declares the peer `"@drobek/modules": ">=X.Y.Z"`
34
+ the server's installer checks.
35
+
36
+ The guide is
37
+ [Writing a module](https://github.com/freema/drobek/blob/main/docs/MODULES.md#writing-a-module).
38
+ Licence: AGPL-3.0-only.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm create drobek-module@latest <name> [-- --module <name>] [--dir <parent>] [--force]
4
+ */
5
+ import { relative } from 'node:path';
6
+ import { parseTarget, scaffold } from './index.js';
7
+ const USAGE = 'usage: npm create drobek-module@latest <name> [-- --module <name>] [--dir <parent>] [--force]';
8
+ function main(argv) {
9
+ const positional = [];
10
+ const opts = { force: false };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a === '-h' || a === '--help') {
14
+ console.log(USAGE);
15
+ return 0;
16
+ }
17
+ if (a === '--force')
18
+ opts.force = true;
19
+ else if (a === '--module' || a === '--dir')
20
+ opts[a === '--module' ? 'module' : 'dir'] = argv[++i];
21
+ else if (a.startsWith('-')) {
22
+ console.error(`unknown option ${a}\n${USAGE}`);
23
+ return 2;
24
+ }
25
+ else
26
+ positional.push(a);
27
+ }
28
+ if (positional.length !== 1) {
29
+ console.error(USAGE);
30
+ return 2;
31
+ }
32
+ try {
33
+ const { dir, target, files } = scaffold(parseTarget(positional[0], opts.module), { parent: opts.dir, force: opts.force });
34
+ const rel = relative(process.cwd(), dir) || '.';
35
+ console.log(`Created ${rel}/ — the drobek module "${target.moduleName}" (${target.packageName}, ${files.length} files).
36
+
37
+ cd ${rel}
38
+ npm install
39
+ npm test # the routes through the production pipeline + the SKILL.md gate
40
+ npm run build
41
+
42
+ Install it on a drobek server (docs/MODULES.md → Writing a module):
43
+ task selfhost:module:add -- <npm spec, tarball or git URL>
44
+ DROBEK_MODULES=…,${target.modulesEntry}`);
45
+ return 0;
46
+ }
47
+ catch (err) {
48
+ // db-error-guard: allow — a scaffold refusal (bad name, non-empty dir), not a database error
49
+ console.error(err instanceof Error ? err.message : String(err));
50
+ return 1;
51
+ }
52
+ }
53
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,33 @@
1
+ /** The template shipped next to dist/ (and next to src/ in the repository). */
2
+ export declare const TEMPLATE_DIR: string;
3
+ export interface ScaffoldTarget {
4
+ /** The npm package name (`drobek-module-erp`, `@acme/drobek-module-erp`). */
5
+ packageName: string;
6
+ /** The module name (`erp`). */
7
+ moduleName: string;
8
+ /** The directory created (`drobek-module-erp`). */
9
+ dirName: string;
10
+ /** What the operator lists in DROBEK_MODULES: the short name when the package is `drobek-module-<name>`, else the package. */
11
+ modulesEntry: string;
12
+ }
13
+ /** Resolve `<name>` (a short name, `drobek-module-<x>` or a scoped package) to package, module and directory names. */
14
+ export declare function parseTarget(input: string, moduleOverride?: string): ScaffoldTarget;
15
+ export interface ScaffoldOptions {
16
+ /** Where the module directory is created (default: the working directory). */
17
+ parent?: string;
18
+ /** The @drobek/modules version the module is written against (default: this package's version — they are released together). */
19
+ drobekVersion?: string;
20
+ /** Write into an existing non-empty directory. */
21
+ force?: boolean;
22
+ }
23
+ export interface ScaffoldResult {
24
+ dir: string;
25
+ target: ScaffoldTarget;
26
+ /** The files written, relative to `dir`. */
27
+ files: string[];
28
+ }
29
+ /** This package's version (= the @drobek/modules release it ships with). */
30
+ export declare function ownVersion(): string;
31
+ /** Replace `{{module}}`, `{{MODULE}}`, `{{package}}`, `{{entry}}`, `{{drobekVersion}}`. */
32
+ export declare function renderTemplate(text: string, target: ScaffoldTarget, drobekVersion: string): string;
33
+ export declare function scaffold(target: ScaffoldTarget, opts?: ScaffoldOptions): ScaffoldResult;
package/dist/index.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * create-drobek-module (NSO-349) — scaffold an external drobek platform
3
+ * module from `template/`:
4
+ *
5
+ * npm create drobek-module@latest erp → drobek-module-erp/, module "erp"
6
+ * npm create drobek-module@latest @acme/drobek-module-erp
7
+ * npm create drobek-module@latest acme-erp → drobek-module-acme-erp/, module "acmeerp"
8
+ *
9
+ * The output is a complete module (contract ^1.1): a route pair over its own
10
+ * table (`mod_<name>_items`, migration 0000_init), the SDK slice, config with
11
+ * an owner confirmation, a secret, a limit, an own error code, SKILL.md in
12
+ * the five-section format, and tests — createModuleTestContext over PGlite
13
+ * with the core migrations, and the checkSkill gate (`npm run check`).
14
+ * examples/drobek-module-hello in the drobek repository is this output plus
15
+ * the slot demo.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join, relative, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ /** The template shipped next to dist/ (and next to src/ in the repository). */
21
+ export const TEMPLATE_DIR = fileURLToPath(new URL('../template', import.meta.url));
22
+ /** `defineModule({ name })`: also the URL segment, config key, skill name and table prefix. */
23
+ const MODULE_NAME_RE = /^[a-z][a-z0-9]{1,30}$/;
24
+ const RESERVED = ['sdk', 'v1', 'drobek', 'internal'];
25
+ /** The modules drobek ships: a new module needs its own name (replacing one is an operator decision, docs/MODULES.md). */
26
+ const BUILT_IN = ['auth', 'email', 'forms', 'data', 'proxy', 'files'];
27
+ const PACKAGE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
28
+ /** Resolve `<name>` (a short name, `drobek-module-<x>` or a scoped package) to package, module and directory names. */
29
+ export function parseTarget(input, moduleOverride) {
30
+ const raw = input.trim();
31
+ const packageName = raw.startsWith('@') || raw.startsWith('drobek-module-') ? raw : `drobek-module-${raw}`;
32
+ if (!PACKAGE_RE.test(packageName))
33
+ throw new Error(`"${raw}" is not a valid npm package name (lower case, digits, - . _; optionally @scope/)`);
34
+ const dirName = packageName.split('/').pop();
35
+ const moduleName = moduleOverride ?? dirName.replace(/^drobek-module-/, '').replace(/[^a-z0-9]/g, '');
36
+ if (!MODULE_NAME_RE.test(moduleName)) {
37
+ throw new Error(`module name "${moduleName}" must match ${MODULE_NAME_RE} (lower case letters and digits, 2–31 characters) — pass --module <name>`);
38
+ }
39
+ if (RESERVED.includes(moduleName))
40
+ throw new Error(`module name "${moduleName}" is reserved (${RESERVED.join(', ')})`);
41
+ if (BUILT_IN.includes(moduleName))
42
+ throw new Error(`"${moduleName}" is a built-in drobek module — pick another name (--module <name>)`);
43
+ return { packageName, moduleName, dirName, modulesEntry: packageName === `drobek-module-${moduleName}` ? moduleName : packageName };
44
+ }
45
+ /** This package's version (= the @drobek/modules release it ships with). */
46
+ export function ownVersion() {
47
+ return JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version;
48
+ }
49
+ /** Replace `{{module}}`, `{{MODULE}}`, `{{package}}`, `{{entry}}`, `{{drobekVersion}}`. */
50
+ export function renderTemplate(text, target, drobekVersion) {
51
+ const vars = {
52
+ module: target.moduleName,
53
+ MODULE: target.moduleName.toUpperCase(),
54
+ package: target.packageName,
55
+ entry: target.modulesEntry,
56
+ drobekVersion,
57
+ };
58
+ return text.replace(/\{\{(\w+)\}\}/g, (all, key) => vars[key] ?? all);
59
+ }
60
+ function templateFiles(dir, base = dir) {
61
+ return readdirSync(dir)
62
+ .sort()
63
+ .flatMap((name) => {
64
+ const p = join(dir, name);
65
+ return statSync(p).isDirectory() ? templateFiles(p, base) : [relative(base, p)];
66
+ });
67
+ }
68
+ /** `_gitignore` → `.gitignore` (npm pack drops a real `.gitignore` from the template). */
69
+ const RENAMED = { _gitignore: '.gitignore' };
70
+ function outputPath(rel) {
71
+ return rel
72
+ .split(/[\\/]/)
73
+ .map((seg) => RENAMED[seg] ?? seg)
74
+ .join('/');
75
+ }
76
+ export function scaffold(target, opts = {}) {
77
+ const dir = resolve(opts.parent ?? process.cwd(), target.dirName);
78
+ if (existsSync(dir) && readdirSync(dir).length > 0 && !opts.force) {
79
+ throw new Error(`${dir} exists and is not empty (use --force to write into it)`);
80
+ }
81
+ const drobekVersion = opts.drobekVersion ?? ownVersion();
82
+ const files = [];
83
+ for (const rel of templateFiles(TEMPLATE_DIR)) {
84
+ const out = outputPath(rel);
85
+ const text = renderTemplate(readFileSync(join(TEMPLATE_DIR, rel), 'utf8'), target, drobekVersion);
86
+ mkdirSync(dirname(join(dir, out)), { recursive: true });
87
+ writeFileSync(join(dir, out), text);
88
+ files.push(out);
89
+ }
90
+ return { dir, target, files };
91
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "create-drobek-module",
3
+ "version": "0.3.3",
4
+ "description": "Scaffold a drobek platform module: npm create drobek-module@latest <name> — routes, SDK, a migration, SKILL.md, vitest + PGlite tests and the checkSkill gate.",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "author": "Tomáš Grasl",
8
+ "homepage": "https://github.com/freema/drobek/blob/main/docs/MODULES.md#writing-a-module",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/freema/drobek.git",
12
+ "directory": "packages/create-drobek-module"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/freema/drobek/issues"
16
+ },
17
+ "keywords": [
18
+ "drobek",
19
+ "drobek-module",
20
+ "mcp"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "bin": {
29
+ "create-drobek-module": "./dist/cli.js"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "template",
40
+ "README.md",
41
+ "LICENSE"
42
+ ]
43
+ }
@@ -0,0 +1,57 @@
1
+ # {{package}}
2
+
3
+ A [drobek](https://github.com/freema/drobek) platform module: server routes
4
+ under `/__drobek/v1/{{module}}/…` on the app hosts, the browser SDK slice
5
+ `drobek.{{module}}`, a per-app config the agent sets with
6
+ `configure_module`, and a skill (`SKILL.md`) the agent reads with
7
+ `skill_info('{{module}}')`. Written against the module contract `^1.1`
8
+ (`@drobek/modules`).
9
+
10
+ ## Develop
11
+
12
+ ```sh
13
+ npm install
14
+ npm test # routes through the production pipeline (PGlite) + the SKILL.md gate
15
+ npm run check # the SKILL.md gate alone (checkSkill)
16
+ npm run typecheck
17
+ npm run build # dist/ — what a drobek server loads
18
+ ```
19
+
20
+ - `src/index.ts` — `defineModule(...)`: config, routes, limits, secrets,
21
+ errors, the SDK types, the migrations folder.
22
+ - `src/sdk.ts` — the browser half, bundled into `/__drobek/sdk.js`.
23
+ - `src/schema.ts` + `migrations/` — the module's own tables (`mod_{{module}}_*`,
24
+ journal `__drizzle_migrations_mod_{{module}}`). Add a migration as
25
+ `migrations/0001_<name>.sql` + a journal entry.
26
+ - `SKILL.md` — five sections, at most 150 lines; every code block is
27
+ compiled and typechecked by `npm run check`.
28
+
29
+ The contract is published on npm as `@freema/drobek-modules` and installed
30
+ under the name the code imports, `@drobek/modules`, through an npm alias in
31
+ `devDependencies` (`"@drobek/modules": "npm:@freema/drobek-modules@^X.Y.Z"`).
32
+ A module that imports `@drobek/sdk` directly adds
33
+ `"@drobek/sdk": "npm:@freema/drobek-sdk@^X.Y.Z"` the same way.
34
+
35
+ ## Install on a drobek server
36
+
37
+ Publish the package (`npm publish`) or pack it (`npm pack` → a tarball the
38
+ server can download). Then, on the server:
39
+
40
+ ```sh
41
+ task selfhost:module:add -- {{package}}@0.1.0 # or the URL of a packed tarball
42
+ ```
43
+
44
+ and add the module to `DROBEK_MODULES` in `.env.production`:
45
+
46
+ ```sh
47
+ DROBEK_MODULES=auth,email,forms,data,proxy,files,{{entry}}
48
+ ```
49
+
50
+ Then restart drobek (see the self-hosting guide). The server refuses a module whose
51
+ `contract` range does not match its module contract version; `zod`,
52
+ `drizzle-orm` and `@drobek/modules` come from the server (they are peer
53
+ dependencies, never bundled).
54
+
55
+ A module runs inside the drobek process with the whole database: an
56
+ operator installs only modules they trust. The guide:
57
+ [docs/MODULES.md → Writing a module](https://github.com/freema/drobek/blob/main/docs/MODULES.md#writing-a-module).
@@ -0,0 +1,88 @@
1
+ # {{module}} — keep a list of items on the server
2
+
3
+ ## 1. When to use
4
+
5
+ The app keeps a shared list on the server: every visitor of the app sees the
6
+ same items, and adding one is a server call. Nothing is stored in the
7
+ browser.
8
+
9
+ ## 2. Minimal working code
10
+
11
+ ```ts
12
+ // src/main.ts — the bare `drobek` import is the platform SDK (no install).
13
+ import { drobek, DrobekError } from 'drobek';
14
+
15
+ const list = document.querySelector<HTMLUListElement>('#items')!;
16
+ const form = document.querySelector<HTMLFormElement>('#add')!;
17
+
18
+ async function render() {
19
+ const { items } = await drobek.{{module}}.list();
20
+ list.replaceChildren(...items.map((item) => Object.assign(document.createElement('li'), { textContent: item.title })));
21
+ }
22
+
23
+ form.addEventListener('submit', async (event) => {
24
+ event.preventDefault();
25
+ const title = String(new FormData(form).get('title') ?? '');
26
+ try {
27
+ await drobek.{{module}}.add(title);
28
+ form.reset();
29
+ await render();
30
+ } catch (err) {
31
+ // err.code: see "Errors → fix"
32
+ alert(err instanceof DrobekError && err.code === '{{module}}_full' ? 'The list is full.' : String(err));
33
+ }
34
+ });
35
+
36
+ void render();
37
+ ```
38
+
39
+ ## 3. API and types
40
+
41
+ ```ts api
42
+ // drobek.{{module}}
43
+ export interface Item {
44
+ id: number;
45
+ title: string;
46
+ created_at: string;
47
+ }
48
+ export interface Api {
49
+ /** The app's items, newest first (at most 100); upstream: the owner set {{MODULE}}_API_KEY */
50
+ list(): Promise<{ items: Item[]; upstream: boolean }>;
51
+ /** title: 1–200 characters; rate-limited ({{MODULE}}_ADDS_PER_MINUTE per visitor IP per minute) */
52
+ add(title: string): Promise<Item>;
53
+ }
54
+ ```
55
+
56
+ HTTP (what the SDK calls): `GET /__drobek/v1/{{module}}/items` and
57
+ `POST /__drobek/v1/{{module}}/items` with `{ "title": "…" }`. Only the app
58
+ itself may call them (same origin; the SDK sends `X-Drobek-SDK: 1`).
59
+
60
+ Config (configure_module takes a partial config; `null` resets a key):
61
+
62
+ ```json
63
+ { "app_id": "…", "module": "{{module}}", "config": { "write": "user", "maxItems": 500 } }
64
+ ```
65
+
66
+ - `write`: `"public"` (anyone may add, the default) or `"user"` (only end
67
+ users signed in through the `auth` module). **Changing it to `"public"`
68
+ needs the app owner's confirmation**: configure_module answers
69
+ `applied: false` with a `confirm_url` — give the user that link.
70
+ - `maxItems` (1–100000, default 1000): the most items the app keeps.
71
+
72
+ ## 4. Rules and limits
73
+
74
+ - `add` is limited to `{{MODULE}}_ADDS_PER_MINUTE` calls per visitor IP per
75
+ minute (default 30; the operator may set another value per workspace).
76
+ - Optional secret `{{MODULE}}_API_KEY`: the app owner sets it in the
77
+ dashboard; `list()` answers `upstream: true` then. Never ask the user for
78
+ its value and never put it in the app's code.
79
+
80
+ ## 5. Errors → fix
81
+
82
+ | error | cause | fix |
83
+ |---|---|---|
84
+ | `invalid_request` + `details[].path = "title"` | empty title or over 200 characters | send 1–200 characters |
85
+ | `unauthorized` | `write` is `"user"` and nobody is signed in | sign in first (`skill_info('auth')`) |
86
+ | `{{module}}_full` (409) | the app keeps `maxItems` items already | tell the user; the owner can raise `maxItems` |
87
+ | `rate_limited` | too many adds from one visitor | retry after `Retry-After` seconds |
88
+ | `csrf_rejected` | called with fetch from another origin or without the SDK | call through `drobek.{{module}}` from the app itself |
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
@@ -0,0 +1,13 @@
1
+ -- {{package}}: the items of an app (one row per item).
2
+ -- Module tables are named `mod_{{module}}_*` and reference apps(id) with
3
+ -- ON DELETE CASCADE, so deleting an app deletes its module data.
4
+ CREATE TABLE IF NOT EXISTS "mod_{{module}}_items" (
5
+ "id" bigserial PRIMARY KEY NOT NULL,
6
+ "app_id" text NOT NULL,
7
+ "title" text NOT NULL,
8
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
9
+ );
10
+ --> statement-breakpoint
11
+ ALTER TABLE "mod_{{module}}_items" ADD CONSTRAINT "mod_{{module}}_items_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
12
+ --> statement-breakpoint
13
+ CREATE INDEX IF NOT EXISTS "mod_{{module}}_items_app_idx" ON "mod_{{module}}_items" USING btree ("app_id");
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1790150400000,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "{{package}}",
3
+ "version": "0.1.0",
4
+ "description": "A drobek platform module: GET/POST /__drobek/v1/{{module}}/items, drobek.{{module}}.list() / add(title).",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "migrations",
16
+ "SKILL.md"
17
+ ],
18
+ "keywords": [
19
+ "drobek-module"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc --noEmit -p tsconfig.json",
24
+ "test": "vitest run",
25
+ "check": "vitest run src/skill.test.ts",
26
+ "prepack": "npm run build"
27
+ },
28
+ "peerDependencies": {
29
+ "@drobek/modules": ">={{drobekVersion}}",
30
+ "drizzle-orm": ">=0.45.0"
31
+ },
32
+ "devDependencies": {
33
+ "@drobek/modules": "npm:@freema/drobek-modules@^{{drobekVersion}}",
34
+ "@electric-sql/pglite": "^0.2.17",
35
+ "@types/node": "^22",
36
+ "drizzle-orm": "^0.45.3",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^3.2.6",
39
+ "zod": "^4.4.3"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.0.0"
43
+ }
44
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The module under createModuleTestContext(): its routes run through the
3
+ * SAME pipeline production uses (rule, CSRF, rate limit, body validation,
4
+ * uniform errors), over PGlite with the drobek core + module migrations.
5
+ */
6
+ import { PGlite } from '@electric-sql/pglite';
7
+ import { drizzle } from 'drizzle-orm/pglite';
8
+ import { migrate } from 'drizzle-orm/pglite/migrator';
9
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
10
+ import type { DB, HookApp } from '@drobek/modules';
11
+ import { coreMigrationsDir, createModuleTestContext, createTestApp } from '@drobek/modules/testing';
12
+ import mod from './index.js';
13
+
14
+ let pg: PGlite;
15
+ let db: DB;
16
+ let app: HookApp;
17
+
18
+ beforeAll(async () => {
19
+ pg = new PGlite();
20
+ const d = drizzle(pg);
21
+ await migrate(d, { migrationsFolder: coreMigrationsDir(), migrationsTable: '__drizzle_migrations_core', migrationsSchema: 'drizzle' });
22
+ await migrate(d, { migrationsFolder: mod.migrations!.folder, migrationsTable: '__drizzle_migrations_mod_{{module}}', migrationsSchema: 'drizzle' });
23
+ app = await createTestApp(d);
24
+ db = d as unknown as DB;
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await pg.close();
29
+ });
30
+
31
+ describe('{{package}}', () => {
32
+ it('declares the module contract it is written against', () => {
33
+ expect(mod.name).toBe('{{module}}');
34
+ expect(mod.contract).toBe('^1.1');
35
+ });
36
+
37
+ it('POST /items adds an item, GET /items lists the newest first', async () => {
38
+ const t = createModuleTestContext(mod, { db, app });
39
+ expect((await t.request('GET', '/items')).body).toEqual({ items: [], upstream: false });
40
+ const first = await t.request('POST', '/items', { body: { title: 'First' } });
41
+ expect(first.status).toBe(200);
42
+ expect(first.body).toMatchObject({ id: expect.any(Number), title: 'First' });
43
+ await t.request('POST', '/items', { body: { title: 'Second' } });
44
+ const list = (await t.request('GET', '/items')).body as { items: { title: string }[] };
45
+ expect(list.items.map((i) => i.title)).toEqual(['Second', 'First']);
46
+ expect(t.audits.map((a) => a.action)).toEqual(['{{module}}.add', '{{module}}.add']);
47
+ });
48
+
49
+ it('validates the body with a field path', async () => {
50
+ const t = createModuleTestContext(mod, { db, app });
51
+ const bad = await t.request('POST', '/items', { body: { title: '' } });
52
+ expect(bad.status).toBe(400);
53
+ expect(bad.body).toMatchObject({ error: 'invalid_request', details: [{ path: 'title' }] });
54
+ });
55
+
56
+ it('write: "user" lets only signed-in end users add', async () => {
57
+ const t = createModuleTestContext(mod, { db, app, config: { write: 'user' } });
58
+ expect((await t.request('POST', '/items', { body: { title: 'x' } })).status).toBe(401);
59
+ t.setPrincipal({ kind: 'user', id: 'eu_1', email: 'ana@example.com', role: 'user' });
60
+ expect((await t.request('POST', '/items', { body: { title: 'x' } })).status).toBe(200);
61
+ });
62
+
63
+ it('answers its own error code {{module}}_full past maxItems', async () => {
64
+ const other = await createTestApp(db);
65
+ const t = createModuleTestContext(mod, { db, app: other, config: { maxItems: 1 } });
66
+ expect((await t.request('POST', '/items', { body: { title: 'a' } })).status).toBe(200);
67
+ const full = await t.request('POST', '/items', { body: { title: 'b' } });
68
+ expect(full.status).toBe(409);
69
+ expect(full.body).toMatchObject({ error: '{{module}}_full', details: { max: 1 } });
70
+ });
71
+
72
+ it('enforces {{MODULE}}_ADDS_PER_MINUTE (429)', async () => {
73
+ const t = createModuleTestContext(mod, { db, app, limits: { {{MODULE}}_ADDS_PER_MINUTE: 1 } });
74
+ expect((await t.request('POST', '/items', { body: { title: 'a' } })).status).toBe(200);
75
+ expect((await t.request('POST', '/items', { body: { title: 'b' } })).body).toMatchObject({ error: 'rate_limited' });
76
+ });
77
+
78
+ it('reads its secret without returning it', async () => {
79
+ const t = createModuleTestContext(mod, { db, app, secrets: { {{MODULE}}_API_KEY: 'k-123456' } });
80
+ const res = await t.request('GET', '/items');
81
+ expect(res.body).toMatchObject({ upstream: true });
82
+ expect(JSON.stringify(res.body)).not.toContain('k-123456');
83
+ });
84
+
85
+ it('opening write to everyone needs the owner\'s confirmation', async () => {
86
+ const t = createModuleTestContext(mod);
87
+ expect(await t.confirm({ write: 'user' }, { write: 'public' })).toEqual(['write: anyone may add items']);
88
+ expect(await t.confirm({}, { maxItems: 5 })).toEqual([]);
89
+ });
90
+ });