create-buntubi 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ibrahima Touré
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npm create buntubi@latest [folder]`: a Buntubi project run with Docker, with fresh secrets,
4
+ * ready for `pnpm run setup`. Every question has a flag, so it also runs unattended:
5
+ *
6
+ * --name "Acme" --slug acme --url local | https://id.acme.com --console-url --portal-url
7
+ * --email log | resend --resend-key re_... --email-from "Acme <no-reply@acme.com>" --yes
8
+ */
9
+ import { randomBytes } from 'node:crypto';
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { basename, dirname, join, resolve } from 'node:path';
12
+ import { createInterface } from 'node:readline/promises';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { parseArgs } from 'node:util';
15
+ import { generate, SLUG } from './generate.js';
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const own = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
18
+ const { values, positionals } = parseArgs({
19
+ allowPositionals: true,
20
+ options: {
21
+ name: { type: 'string' },
22
+ slug: { type: 'string' },
23
+ url: { type: 'string' },
24
+ 'console-url': { type: 'string' },
25
+ 'portal-url': { type: 'string' },
26
+ email: { type: 'string' },
27
+ 'resend-key': { type: 'string' },
28
+ 'email-from': { type: 'string' },
29
+ yes: { type: 'boolean', short: 'y' },
30
+ },
31
+ });
32
+ const prompt = !values.yes && process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
33
+ async function ask(question, fallback) {
34
+ if (!prompt)
35
+ return fallback;
36
+ return (await prompt.question(`${question} (${fallback}) `)).trim() || fallback;
37
+ }
38
+ const toSlug = (text) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^(\d)/, 'p$1');
39
+ /** An https origin, the only kind a public provider accepts. */
40
+ function origin(text, what) {
41
+ const url = new URL(text);
42
+ if (url.protocol !== 'https:')
43
+ throw new Error(`${what} must be https: ${text}`);
44
+ return url.origin;
45
+ }
46
+ /** On this machine, or at public addresses: the provider's, then the console's and the portal's beside it. */
47
+ async function chooseAddress() {
48
+ const answer = values.url ?? (await ask('Public address of the provider, or local?', 'local'));
49
+ if (answer === 'local')
50
+ return { local: true };
51
+ const provider = origin(answer, 'the provider address');
52
+ const host = new URL(provider).host;
53
+ const consoleUrl = origin(values['console-url'] ?? (await ask('Address of the console?', `https://console.${host}`)), 'the console address');
54
+ const portalUrl = origin(values['portal-url'] ?? (await ask('Address of the developer portal?', `https://portal.${host}`)), 'the portal address');
55
+ return { local: false, provider, console: consoleUrl, portal: portalUrl };
56
+ }
57
+ /** To the log while it runs only here; through Resend once people outside can sign up. */
58
+ async function chooseEmail(isPublic) {
59
+ const answer = values.email ?? (await ask(isPublic ? 'Email through resend?' : 'Email: log (codes in the log) or resend?', isPublic ? 'resend' : 'log'));
60
+ if (answer === 'log')
61
+ return { through: 'log' };
62
+ if (answer !== 'resend')
63
+ throw new Error(`--email is log or resend, not ${answer}`);
64
+ const key = values['resend-key'] ?? (await ask('Resend API key?', ''));
65
+ const from = values['email-from'] ?? (await ask('Sender, on a domain verified at Resend?', ''));
66
+ if (!key || !from)
67
+ throw new Error('Resend needs its API key and a sender');
68
+ return { through: 'resend', key, from };
69
+ }
70
+ async function main() {
71
+ const folder = resolve(positionals[0] ?? (await ask('Folder for the project?', 'my-identity')));
72
+ const name = values.name ?? (await ask('Name on the sign-in pages?', basename(folder)));
73
+ const slug = values.slug ?? (await ask('Short id?', toSlug(name) || 'identity'));
74
+ if (!SLUG.test(slug))
75
+ throw new Error(`"${slug}" must be lowercase letters, digits and hyphens, starting with a letter`);
76
+ const address = await chooseAddress();
77
+ const email = await chooseEmail(!address.local);
78
+ prompt?.close();
79
+ if (existsSync(folder) && readdirSync(folder).length > 0)
80
+ throw new Error(`${folder} is not empty`);
81
+ const files = generate({ name, slug, address, email }, own.version, join(here, '..', 'templates'), () => randomBytes(32).toString('base64url'));
82
+ for (const [path, content] of files) {
83
+ mkdirSync(dirname(join(folder, path)), { recursive: true });
84
+ writeFileSync(join(folder, path), content);
85
+ }
86
+ console.log(`\n${name} is ready in ${folder}. Next:\n\n cd ${folder}\n pnpm install\n pnpm run setup\n pnpm up\n`);
87
+ return 0;
88
+ }
89
+ main().then((code) => {
90
+ process.exitCode = code;
91
+ }, (error) => {
92
+ prompt?.close();
93
+ console.error(`create-buntubi: ${error instanceof Error ? error.message : String(error)}`);
94
+ process.exitCode = 1;
95
+ });
@@ -0,0 +1,32 @@
1
+ export interface Choices {
2
+ /** The platform's name, on its sign-in pages. */
3
+ name: string;
4
+ /** Lowercase letters, digits and hyphens: the compose project and the package name. */
5
+ slug: string;
6
+ /** Where people reach it: on this machine, or at public https addresses. */
7
+ address: {
8
+ local: true;
9
+ } | {
10
+ local: false;
11
+ provider: string;
12
+ console: string;
13
+ portal: string;
14
+ };
15
+ /** How it sends email: to its log (local only), or through Resend. */
16
+ email: {
17
+ through: 'log';
18
+ } | {
19
+ through: 'resend';
20
+ key: string;
21
+ from: string;
22
+ };
23
+ }
24
+ /** The three addresses the project answers at. */
25
+ export declare const addressesOf: (choices: Choices) => {
26
+ provider: string;
27
+ console: string;
28
+ portal: string;
29
+ };
30
+ export type Files = Map<string, string>;
31
+ export declare const SLUG: RegExp;
32
+ export declare function generate(choices: Choices, version: string, templates: string, secret: () => string): Files;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * A new Buntubi project, as files: every path it starts with and its contents. Nothing here
3
+ * touches the disk, so a project can be checked before it is written.
4
+ */
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { join, relative } from 'node:path';
7
+ /** The addresses on this machine: the console and the portal share the provider's network. */
8
+ const LOCAL = { provider: 'http://localhost:5010', console: 'http://localhost:3420', portal: 'http://localhost:3410' };
9
+ /** The three addresses the project answers at. */
10
+ export const addressesOf = (choices) => (choices.address.local ? LOCAL : choices.address);
11
+ export const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
12
+ /** Template files are stored with a leading underscore where npm would drop a dotfile. */
13
+ const DOTFILE = /(^|\/)_(gitignore)$/;
14
+ export function generate(choices, version, templates, secret) {
15
+ if (!SLUG.test(choices.slug))
16
+ throw new Error(`"${choices.slug}" must be lowercase letters, digits and hyphens, starting with a letter`);
17
+ // The provider refuses to start on a public address with email that only goes to its log.
18
+ if (!choices.address.local && choices.email.through === 'log')
19
+ throw new Error('a public address needs email that leaves the machine: choose resend');
20
+ const at = addressesOf(choices);
21
+ const files = new Map();
22
+ for (const file of walk(templates)) {
23
+ const path = relative(templates, file).replace(DOTFILE, '$1.$2');
24
+ const text = readFileSync(file, 'utf8')
25
+ .replaceAll('{{name}}', choices.name)
26
+ .replaceAll('{{slug}}', choices.slug)
27
+ .replaceAll('{{version}}', version)
28
+ .replaceAll('{{consoleUrl}}', at.console)
29
+ .replaceAll('{{portalUrl}}', at.portal);
30
+ files.set(path, text);
31
+ }
32
+ files.set('package.json', `${JSON.stringify({
33
+ name: choices.slug,
34
+ private: true,
35
+ type: 'module',
36
+ scripts: { setup: 'buntubi setup', build: 'buntubi build', up: 'buntubi up', down: 'buntubi down' },
37
+ devDependencies: { '@buntubi/sdk': `^${version}` },
38
+ }, null, 2)}\n`);
39
+ files.set('pnpm-workspace.yaml', 'packages:\n - plugins/*/ui\n');
40
+ // Docker copies plugins/ into the provider's image and mounts ui-plugins/: both must exist.
41
+ files.set('plugins/.gitkeep', '');
42
+ files.set('ui-plugins/.gitkeep', '');
43
+ for (const [file, entries] of envFiles(choices, secret)) {
44
+ files.set(file, env(entries, true));
45
+ files.set(`${file}.example`, env(entries, false));
46
+ }
47
+ return files;
48
+ }
49
+ function walk(dir) {
50
+ return readdirSync(dir).flatMap((entry) => {
51
+ const path = join(dir, entry);
52
+ return statSync(path).isDirectory() ? walk(path) : [path];
53
+ });
54
+ }
55
+ function env(entries, withValues) {
56
+ const secret = /SECRET|PASSWORD|KEY$/;
57
+ return `${entries.map(([key, value, comment]) => `${comment ? `# ${comment}\n` : ''}${key}=${withValues || !secret.test(key) ? value : ''}`).join('\n')}\n`;
58
+ }
59
+ function envFiles(choices, secret) {
60
+ const at = addressesOf(choices);
61
+ const email = choices.email.through === 'log'
62
+ ? [['IDP_EMAIL_TEST_MODE', 'true', 'Local only: emails, sign-in codes included, go to the log.']]
63
+ : [
64
+ ['IDP_EMAIL_TEST_MODE', 'false', ''],
65
+ ['IDP_RESEND_API_KEY', choices.email.key, 'Email through Resend: the API key from resend.com.'],
66
+ ['IDP_EMAIL_FROM', choices.email.from, 'The sender, on a domain verified at Resend.'],
67
+ ];
68
+ return [
69
+ ['.env', [['POSTGRES_PASSWORD', secret(), 'Read by docker compose: the database password, shared by postgres and the provider.']]],
70
+ [
71
+ 'identity.env',
72
+ [
73
+ ['IDP_ISSUER', at.provider, "The provider's address; every IDP_* setting is documented in Buntubi."],
74
+ ['IDP_SECRET_KEY', secret(), 'Never change it: it signs sessions and encrypts authenticator secrets.'],
75
+ ['IDP_SECURE_COOKIES', 'true', ''],
76
+ ...email,
77
+ ['IDP_RATE_LIMIT_STORAGE', 'memory://', 'One provider process; several need a shared store (redis://...).'],
78
+ ['IDP_BRAND_NAME', choices.name, ''],
79
+ ['IDP_PLUGINS', '[]', 'The provider plugins turned on, by id, in order; `buntubi add plugin` adds to it.'],
80
+ ['IDP_GOOGLE_CLIENT_ID', '', 'Sign-in with Google: an OAuth client from Google Cloud, callback <issuer>/signin/google/callback; list google under [social] in platform.toml.'],
81
+ ['IDP_GOOGLE_CLIENT_SECRET', '', ''],
82
+ ['IDP_GITHUB_CLIENT_ID', '', 'Sign-in with GitHub: an OAuth App, or a GitHub App with Email addresses read-only; callback <issuer>/signin/github/callback.'],
83
+ ['IDP_GITHUB_CLIENT_SECRET', '', ''],
84
+ ],
85
+ ],
86
+ [
87
+ 'console.env',
88
+ [
89
+ ['IDP_URL', at.provider, ''],
90
+ ['APP_URL', at.console, ''],
91
+ ['PORTAL_URL', at.portal, ''],
92
+ ['CONSOLE_CLIENT_ID', 'owner-console', ''],
93
+ ['CONSOLE_CLIENT_SECRET', '', 'Written by `pnpm run setup`.'],
94
+ ['SESSION_SECRET', secret(), 'Encrypts the session cookie: at least 32 characters.'],
95
+ ],
96
+ ],
97
+ [
98
+ 'portal.env',
99
+ [
100
+ ['IDP_URL', at.provider, ''],
101
+ ['APP_URL', at.portal, ''],
102
+ ['PORTAL_CLIENT_ID', 'developer-portal', ''],
103
+ ['PORTAL_CLIENT_SECRET', '', 'Written by `pnpm run setup`.'],
104
+ ['SESSION_SECRET', secret(), 'Encrypts the session cookie: at least 32 characters.'],
105
+ ],
106
+ ],
107
+ ];
108
+ }
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "create-buntubi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.0",
4
+ "description": "Start an identity provider on Buntubi, run with Docker",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-buntubi": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates"
13
+ ],
14
+ "devDependencies": {
15
+ "@types/node": "^22.0.0",
16
+ "typescript": "^7.0.2",
17
+ "vitest": "^5.0.3"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ }
6
24
  }
@@ -0,0 +1,17 @@
1
+ # {{name}}
2
+
3
+ An identity provider on Buntubi, run with Docker.
4
+
5
+ ```sh
6
+ pnpm install
7
+ pnpm run setup # once: its database, a signing key, the first admin, the apps
8
+ pnpm up # the provider on :5010, the console on :3420, the portal on :3410
9
+ pnpm down
10
+ ```
11
+
12
+ On public addresses, a reverse proxy sends each one to its port with https: the provider's to
13
+ 5010, the console's to 3420, the portal's to 3410.
14
+
15
+ Its apps are in `platform.toml`, its settings in the `.env` files, written with fresh secrets
16
+ when the project was made. `pnpm exec buntubi add plugin <id>` adds a plugin of its own in
17
+ `plugins/`; `pnpm build` builds the plugins' pages into `ui-plugins/`.
@@ -0,0 +1,13 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.env
5
+ !*.env.example
6
+ keys/
7
+ ui-plugins/*
8
+ !ui-plugins/.gitkeep
9
+ *.dump
10
+ # Written by the UI plugin build.
11
+ fluixi.d.ts
12
+ *.log
13
+ .DS_Store
@@ -0,0 +1,96 @@
1
+ # {{name}}'s identity provider: Buntubi from its published images, its provider built with this
2
+ # project's plugins (provider.Dockerfile), their UI halves mounted from ui-plugins/.
3
+ #
4
+ # `pnpm run setup` the first time (database, signing key, the first admin, the apps in
5
+ # platform.toml), then `pnpm up` and `pnpm down`. The console and the portal share the
6
+ # provider's network namespace, so http://localhost:5010, :3420 and :3410 mean the same thing to
7
+ # the browser and to the containers.
8
+ #
9
+ # Upgrading: change the version below and in provider.Dockerfile, then `pnpm run setup` again (it
10
+ # only adds what is missing) and `pnpm up`.
11
+
12
+ name: {{slug}}
13
+
14
+ services:
15
+ postgres:
16
+ image: postgres:17-alpine
17
+ environment:
18
+ POSTGRES_USER: idp
19
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
20
+ POSTGRES_DB: idp
21
+ volumes:
22
+ - postgres:/var/lib/postgresql/data
23
+ healthcheck:
24
+ test: ["CMD", "pg_isready", "-U", "idp", "-d", "idp"]
25
+ interval: 5s
26
+ retries: 10
27
+
28
+ provider:
29
+ build:
30
+ context: .
31
+ dockerfile: provider.Dockerfile
32
+ image: {{slug}}-provider
33
+ env_file: identity.env
34
+ environment:
35
+ IDP_DATABASE_URL: postgresql+psycopg://idp:${POSTGRES_PASSWORD}@postgres:5432/idp
36
+ IDP_SIGNING_KEYS_DIR: /keys
37
+ # The issuer is http://localhost:5010, so the provider listens there inside the namespace too.
38
+ command: ["gunicorn", "--bind", "0.0.0.0:5010", "--access-logfile", "-", "buntubi.app:create_app()"]
39
+ healthcheck:
40
+ test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(urllib.request.urlopen('http://127.0.0.1:5010/healthz', timeout=4).status != 200)"]
41
+ interval: 10s
42
+ start_period: 20s
43
+ volumes:
44
+ - ./keys:/keys
45
+ - &ui-plugins ./ui-plugins:/app/ui-plugins:ro
46
+ ports:
47
+ - "5010:5010"
48
+ - "3420:3420"
49
+ - "3410:3410"
50
+ extra_hosts:
51
+ - "host.docker.internal:host-gateway"
52
+ depends_on:
53
+ postgres:
54
+ condition: service_healthy
55
+
56
+ # Sends organization events to the apps' webhooks.
57
+ webhooks:
58
+ image: {{slug}}-provider
59
+ env_file: identity.env
60
+ environment:
61
+ IDP_DATABASE_URL: postgresql+psycopg://idp:${POSTGRES_PASSWORD}@postgres:5432/idp
62
+ IDP_SIGNING_KEYS_DIR: /keys
63
+ IDP_PLUGINS_SYNC_ON_START: "false"
64
+ command: ["flask", "webhooks", "deliver"]
65
+ # The image checks its web server, which this process does not run.
66
+ healthcheck:
67
+ disable: true
68
+ volumes:
69
+ - ./keys:/keys
70
+ extra_hosts:
71
+ - "host.docker.internal:host-gateway"
72
+ restart: unless-stopped
73
+ depends_on:
74
+ provider:
75
+ condition: service_started
76
+
77
+ console:
78
+ image: ghcr.io/buntubi/idp-console:{{version}}
79
+ env_file: console.env
80
+ volumes:
81
+ - *ui-plugins
82
+ network_mode: service:provider
83
+ depends_on:
84
+ - provider
85
+
86
+ portal:
87
+ image: ghcr.io/buntubi/idp-portal:{{version}}
88
+ env_file: portal.env
89
+ volumes:
90
+ - *ui-plugins
91
+ network_mode: service:provider
92
+ depends_on:
93
+ - provider
94
+
95
+ volumes:
96
+ postgres:
@@ -0,0 +1,36 @@
1
+ # {{name}}'s apps, created by `pnpm run setup`. Running it again only adds what is missing. A
2
+ # confidential app's secret is written into the env file `secret` names, relative to this file,
3
+ # and never printed. Add the apps that sign in through this provider below.
4
+
5
+ [[apps]]
6
+ client_id = "owner-console"
7
+ name = "Owner console"
8
+ redirect_uris = ["{{consoleUrl}}/auth/callback"]
9
+ post_logout_redirect_uris = ["{{consoleUrl}}/"]
10
+ scope = "openid profile email admin"
11
+ homepage_url = "{{consoleUrl}}/"
12
+ backchannel_logout_uri = "{{consoleUrl}}/auth/backchannel-logout"
13
+ secret = { file = "console.env", key = "CONSOLE_CLIENT_SECRET" }
14
+
15
+ [[apps]]
16
+ client_id = "developer-portal"
17
+ name = "Developer portal"
18
+ redirect_uris = ["{{portalUrl}}/auth/callback"]
19
+ post_logout_redirect_uris = ["{{portalUrl}}/"]
20
+ scope = "openid profile email developer"
21
+ homepage_url = "{{portalUrl}}/"
22
+ backchannel_logout_uri = "{{portalUrl}}/auth/backchannel-logout"
23
+ secret = { file = "portal.env", key = "PORTAL_CLIENT_SECRET" }
24
+
25
+ # Social sign-in, once IDP_GOOGLE_* or IDP_GITHUB_* is set in identity.env.
26
+ # [social]
27
+ # providers = ["google", "github"]
28
+
29
+ # Whether people can make an account; invited addresses always can.
30
+ # [signup]
31
+ # open = true
32
+ # terms_version = "2026-10" # asks people to accept the terms at IDP_BRAND_TERMS_URL
33
+
34
+ # The profile fields asked of people: "hidden" (the default), "optional" or "required".
35
+ # [profile]
36
+ # birthdate = "optional"
@@ -0,0 +1,10 @@
1
+ # The provider: Buntubi's published image with this project's provider plugins installed, each
2
+ # one's plugins/<id>/provider package. Without plugins it is the published image as it is.
3
+ FROM ghcr.io/buntubi/idp-provider:{{version}}
4
+ USER root
5
+ COPY plugins /plugins
6
+ RUN for plugin in /plugins/*/provider; do \
7
+ if [ -f "$plugin/pyproject.toml" ]; then pip install --no-cache-dir "$plugin"; fi; \
8
+ done \
9
+ && rm -rf /plugins
10
+ USER idp
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.