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/LICENSE +661 -0
- package/README.md +38 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +53 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +91 -0
- package/package.json +43 -0
- package/template/README.md +57 -0
- package/template/SKILL.md +88 -0
- package/template/_gitignore +3 -0
- package/template/migrations/0000_init.sql +13 -0
- package/template/migrations/meta/_journal.json +13 -0
- package/template/package.json +44 -0
- package/template/src/index.test.ts +90 -0
- package/template/src/index.ts +111 -0
- package/template/src/schema.ts +18 -0
- package/template/src/sdk.ts +20 -0
- package/template/src/skill.test.ts +15 -0
- package/template/tsconfig.build.json +20 -0
- package/template/tsconfig.json +17 -0
- package/template/vitest.config.ts +6 -0
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
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));
|
package/dist/index.d.ts
ADDED
|
@@ -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,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,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
|
+
});
|