@tabbr/module-cli 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,33 +1,34 @@
1
- {
2
- "name": "@tabbr/module-cli",
3
- "version": "0.1.0",
4
- "description": "Set up, build and publish a Native Federation club module to a tabbr club.",
5
- "license": "SEE LICENSE IN LICENSE",
6
- "author": "SystemWorks BV",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/systemworks-bv/gliderdesk.git",
10
- "directory": "tabbr-module-cli"
11
- },
12
- "publishConfig": {
13
- "access": "public",
14
- "registry": "https://registry.npmjs.org/"
15
- },
16
- "bin": {
17
- "tabbr-module": "bin/tabbr-module.js"
18
- },
19
- "files": [
20
- "bin",
21
- "src"
22
- ],
23
- "scripts": {
24
- "release": "node scripts/release.mjs"
25
- },
26
- "engines": {
27
- "node": ">=20"
28
- },
29
- "dependencies": {
30
- "archiver": "^7.0.1",
31
- "open": "^10.1.0"
32
- }
33
- }
1
+ {
2
+ "name": "@tabbr/module-cli",
3
+ "version": "0.3.0",
4
+ "description": "Set up, build and publish a Native Federation club module to a tabbr club.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "author": "SystemWorks BV",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/systemworks-bv/gliderdesk.git",
10
+ "directory": "tabbr-module-cli"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "registry": "https://registry.npmjs.org/"
15
+ },
16
+ "bin": {
17
+ "tabbr-module": "bin/tabbr-module.js"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src"
22
+ ],
23
+ "scripts": {
24
+ "release": "node scripts/release.mjs",
25
+ "publish:local": "yalc publish --push"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "dependencies": {
31
+ "archiver": "^7.0.1",
32
+ "open": "^10.1.0"
33
+ }
34
+ }
package/src/auth.js CHANGED
@@ -1,144 +1,144 @@
1
- 'use strict';
2
-
3
- const crypto = require('crypto');
4
- const fs = require('fs');
5
- const http = require('http');
6
- const os = require('os');
7
- const path = require('path');
8
-
9
- // Same Keycloak client the tabbr-ui shell and the SDK's dev bridge use - see
10
- // tabbr-ui/src/environments/environment.ts and tabbr-module-sdk/src/dev-bridge.
11
- const KEYCLOAK_URL = 'https://auth.tabbr.net';
12
- const KEYCLOAK_REALM = 'tabbr';
13
- const KEYCLOAK_CLIENT_ID = 'tabbr';
14
-
15
- const CREDENTIALS_PATH = path.join(os.homedir(), '.tabbr', 'credentials.json');
16
-
17
- function base64url(buffer) {
18
- return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
19
- }
20
-
21
- function readCachedCredentials() {
22
- try {
23
- return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
24
- } catch {
25
- return null;
26
- }
27
- }
28
-
29
- function writeCachedCredentials(creds) {
30
- fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true });
31
- fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
32
- }
33
-
34
- async function exchangeToken(params) {
35
- const response = await fetch(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, {
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
38
- body: new URLSearchParams(params).toString(),
39
- });
40
- if (!response.ok) {
41
- throw new Error(`Token request failed: ${response.status} ${await response.text()}`);
42
- }
43
- return response.json();
44
- }
45
-
46
- function toCachedCredentials(tokenResponse) {
47
- return {
48
- accessToken: tokenResponse.access_token,
49
- refreshToken: tokenResponse.refresh_token,
50
- // A little slack so a token that's about to expire doesn't get used anyway.
51
- expiresAt: Date.now() + (tokenResponse.expires_in - 30) * 1000,
52
- };
53
- }
54
-
55
- async function refreshAccessToken(cached) {
56
- const tokenResponse = await exchangeToken({
57
- grant_type: 'refresh_token',
58
- refresh_token: cached.refreshToken,
59
- client_id: KEYCLOAK_CLIENT_ID,
60
- });
61
- return toCachedCredentials(tokenResponse);
62
- }
63
-
64
- /** Full-page-redirect + PKCE login via a temporary local HTTP server catching the callback. */
65
- async function interactiveLogin() {
66
- const verifier = base64url(crypto.randomBytes(32));
67
- const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
68
-
69
- // Set once the server actually has a port (inside listen's callback) and reused below -
70
- // server.address() returns null once server.close() has been called, so the request handler
71
- // must not try to recompute it from the (by-then-closing) server itself.
72
- let redirectUri;
73
-
74
- const { code } = await new Promise((resolve, reject) => {
75
- const server = http.createServer((req, res) => {
76
- const url = new URL(req.url, 'http://localhost');
77
- if (url.pathname !== '/callback') {
78
- res.writeHead(404).end();
79
- return;
80
- }
81
- const code = url.searchParams.get('code');
82
- const error = url.searchParams.get('error');
83
- res.writeHead(200, { 'Content-Type': 'text/html' });
84
- res.end(error
85
- ? `<h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p>`
86
- : '<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>');
87
- server.close();
88
- if (error) {
89
- reject(new Error(`Keycloak login failed: ${error}`));
90
- } else {
91
- resolve({ code });
92
- }
93
- });
94
- server.listen(0, 'localhost', () => {
95
- const port = server.address().port;
96
- redirectUri = `http://localhost:${port}/callback`;
97
- const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`);
98
- authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID);
99
- authUrl.searchParams.set('response_type', 'code');
100
- authUrl.searchParams.set('scope', 'openid');
101
- authUrl.searchParams.set('redirect_uri', redirectUri);
102
- authUrl.searchParams.set('code_challenge', challenge);
103
- authUrl.searchParams.set('code_challenge_method', 'S256');
104
-
105
- console.log('Opening your browser to log in via Keycloak...');
106
- console.log(`If it doesn't open automatically, visit:\n${authUrl.toString()}\n`);
107
- import('open').then(({ default: open }) => open(authUrl.toString())).catch(() => {
108
- // Non-fatal: the URL is already printed above for manual use.
109
- });
110
- });
111
- server.on('error', reject);
112
- });
113
-
114
- const tokenResponse = await exchangeToken({
115
- grant_type: 'authorization_code',
116
- code,
117
- redirect_uri: redirectUri,
118
- client_id: KEYCLOAK_CLIENT_ID,
119
- code_verifier: verifier,
120
- });
121
- return toCachedCredentials(tokenResponse);
122
- }
123
-
124
- /** Returns a valid access token, reusing/refreshing the cache before falling back to a fresh login. */
125
- async function getAccessToken() {
126
- const cached = readCachedCredentials();
127
- if (cached && cached.expiresAt > Date.now()) {
128
- return cached.accessToken;
129
- }
130
- if (cached && cached.refreshToken) {
131
- try {
132
- const refreshed = await refreshAccessToken(cached);
133
- writeCachedCredentials(refreshed);
134
- return refreshed.accessToken;
135
- } catch {
136
- // Refresh token expired/revoked - fall through to interactive login.
137
- }
138
- }
139
- const creds = await interactiveLogin();
140
- writeCachedCredentials(creds);
141
- return creds.accessToken;
142
- }
143
-
144
- module.exports = { getAccessToken, CREDENTIALS_PATH };
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const http = require('http');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ // Same Keycloak client the tabbr-ui shell and the SDK's dev bridge use - see
10
+ // tabbr-ui/src/environments/environment.ts and tabbr-module-sdk/src/dev-bridge.
11
+ const KEYCLOAK_URL = 'https://auth.tabbr.net';
12
+ const KEYCLOAK_REALM = 'tabbr';
13
+ const KEYCLOAK_CLIENT_ID = 'tabbr';
14
+
15
+ const CREDENTIALS_PATH = path.join(os.homedir(), '.tabbr', 'credentials.json');
16
+
17
+ function base64url(buffer) {
18
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
19
+ }
20
+
21
+ function readCachedCredentials() {
22
+ try {
23
+ return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function writeCachedCredentials(creds) {
30
+ fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true });
31
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
32
+ }
33
+
34
+ async function exchangeToken(params) {
35
+ const response = await fetch(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
38
+ body: new URLSearchParams(params).toString(),
39
+ });
40
+ if (!response.ok) {
41
+ throw new Error(`Token request failed: ${response.status} ${await response.text()}`);
42
+ }
43
+ return response.json();
44
+ }
45
+
46
+ function toCachedCredentials(tokenResponse) {
47
+ return {
48
+ accessToken: tokenResponse.access_token,
49
+ refreshToken: tokenResponse.refresh_token,
50
+ // A little slack so a token that's about to expire doesn't get used anyway.
51
+ expiresAt: Date.now() + (tokenResponse.expires_in - 30) * 1000,
52
+ };
53
+ }
54
+
55
+ async function refreshAccessToken(cached) {
56
+ const tokenResponse = await exchangeToken({
57
+ grant_type: 'refresh_token',
58
+ refresh_token: cached.refreshToken,
59
+ client_id: KEYCLOAK_CLIENT_ID,
60
+ });
61
+ return toCachedCredentials(tokenResponse);
62
+ }
63
+
64
+ /** Full-page-redirect + PKCE login via a temporary local HTTP server catching the callback. */
65
+ async function interactiveLogin() {
66
+ const verifier = base64url(crypto.randomBytes(32));
67
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
68
+
69
+ // Set once the server actually has a port (inside listen's callback) and reused below -
70
+ // server.address() returns null once server.close() has been called, so the request handler
71
+ // must not try to recompute it from the (by-then-closing) server itself.
72
+ let redirectUri;
73
+
74
+ const { code } = await new Promise((resolve, reject) => {
75
+ const server = http.createServer((req, res) => {
76
+ const url = new URL(req.url, 'http://localhost');
77
+ if (url.pathname !== '/callback') {
78
+ res.writeHead(404).end();
79
+ return;
80
+ }
81
+ const code = url.searchParams.get('code');
82
+ const error = url.searchParams.get('error');
83
+ res.writeHead(200, { 'Content-Type': 'text/html' });
84
+ res.end(error
85
+ ? `<h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p>`
86
+ : '<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>');
87
+ server.close();
88
+ if (error) {
89
+ reject(new Error(`Keycloak login failed: ${error}`));
90
+ } else {
91
+ resolve({ code });
92
+ }
93
+ });
94
+ server.listen(0, 'localhost', () => {
95
+ const port = server.address().port;
96
+ redirectUri = `http://localhost:${port}/callback`;
97
+ const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`);
98
+ authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID);
99
+ authUrl.searchParams.set('response_type', 'code');
100
+ authUrl.searchParams.set('scope', 'openid');
101
+ authUrl.searchParams.set('redirect_uri', redirectUri);
102
+ authUrl.searchParams.set('code_challenge', challenge);
103
+ authUrl.searchParams.set('code_challenge_method', 'S256');
104
+
105
+ console.log('Opening your browser to log in via Keycloak...');
106
+ console.log(`If it doesn't open automatically, visit:\n${authUrl.toString()}\n`);
107
+ import('open').then(({ default: open }) => open(authUrl.toString())).catch(() => {
108
+ // Non-fatal: the URL is already printed above for manual use.
109
+ });
110
+ });
111
+ server.on('error', reject);
112
+ });
113
+
114
+ const tokenResponse = await exchangeToken({
115
+ grant_type: 'authorization_code',
116
+ code,
117
+ redirect_uri: redirectUri,
118
+ client_id: KEYCLOAK_CLIENT_ID,
119
+ code_verifier: verifier,
120
+ });
121
+ return toCachedCredentials(tokenResponse);
122
+ }
123
+
124
+ /** Returns a valid access token, reusing/refreshing the cache before falling back to a fresh login. */
125
+ async function getAccessToken() {
126
+ const cached = readCachedCredentials();
127
+ if (cached && cached.expiresAt > Date.now()) {
128
+ return cached.accessToken;
129
+ }
130
+ if (cached && cached.refreshToken) {
131
+ try {
132
+ const refreshed = await refreshAccessToken(cached);
133
+ writeCachedCredentials(refreshed);
134
+ return refreshed.accessToken;
135
+ } catch {
136
+ // Refresh token expired/revoked - fall through to interactive login.
137
+ }
138
+ }
139
+ const creds = await interactiveLogin();
140
+ writeCachedCredentials(creds);
141
+ return creds.accessToken;
142
+ }
143
+
144
+ module.exports = { getAccessToken, CREDENTIALS_PATH };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DEFAULT_ENTRY = 'backend/src/index.ts';
7
+
8
+ /** Matches BackendModuleRuntimeService's MODULE_GLOBAL on the server - a `export function foo() {}`
9
+ * in the module's TS ends up reachable as the global `__module.foo` once bundled this way. */
10
+ const GLOBAL_NAME = '__module';
11
+
12
+ /**
13
+ * The entities the module declares (`"entities"` in tabbr.config.json), checked just enough to fail here rather than at
14
+ * upload with a vaguer message: Tabbr validates them again against the club (names taken, entity fields pointing at
15
+ * entities the club has, ...). See `ModuleEntityDefinition` in tabbr-api-server for the full shape.
16
+ */
17
+ function entitiesOf(config) {
18
+ const entities = config.entities || [];
19
+ if (!Array.isArray(entities)) {
20
+ throw new Error('"entities" in tabbr.config.json must be an array.');
21
+ }
22
+ for (const entity of entities) {
23
+ if (!entity || !/^[a-z][a-zA-Z0-9]{1,39}$/.test(entity.name || '')) {
24
+ throw new Error(`Entity name "${entity && entity.name}" is not valid: camelCase letters and digits, starting lowercase.`);
25
+ }
26
+ if (!Array.isArray(entity.fields) || entity.fields.length === 0) {
27
+ throw new Error(`Entity "${entity.name}" declares no fields.`);
28
+ }
29
+ const keys = new Set();
30
+ for (const field of entity.fields) {
31
+ if (!/^[a-z][a-zA-Z0-9]{0,39}$/.test(field.key || '')) {
32
+ throw new Error(`Entity "${entity.name}": field key "${field.key}" is not valid.`);
33
+ }
34
+ if (keys.has(field.key)) {
35
+ throw new Error(`Entity "${entity.name}": field "${field.key}" is declared twice.`);
36
+ }
37
+ keys.add(field.key);
38
+ if (!field.type && !field.persistentType) {
39
+ throw new Error(`Entity "${entity.name}": field "${field.key}" has no type (e.g. "tabbr.text").`);
40
+ }
41
+ if (field.type === 'tabbr.entity' && !field.target) {
42
+ throw new Error(`Entity "${entity.name}": entity field "${field.key}" has no "target".`);
43
+ }
44
+ }
45
+ }
46
+ return entities;
47
+ }
48
+
49
+ /**
50
+ * Bundles a module workspace's backend part (if configured) into a single, dependency-free
51
+ * `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
52
+ * `export function`) even though nothing beyond that single file is ever loaded at runtime - the
53
+ * server evaluates it in a sandboxed GraalVM context with no module resolution of its own (see
54
+ * `BackendModuleRuntimeService`).
55
+ *
56
+ * Also writes the manifest the server reads (`manifest.json`): the backend config plus the entities the module declares
57
+ * - which a module can do without any backend code, so then only the manifest is produced (`bundleJs` null).
58
+ *
59
+ * Returns `null` when the workspace has neither a backend part nor entities, so `build.js` can skip
60
+ * it entirely for a frontend-only module.
61
+ */
62
+ async function buildBackend(cwd, config) {
63
+ const backendConfig = config.backend;
64
+ const entities = entitiesOf(config);
65
+ const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
66
+ if (bundleJs == null && entities.length === 0) {
67
+ return null;
68
+ }
69
+ const settings = backendConfig || {};
70
+ const manifest = {
71
+ // localAllowedHosts (e.g. a mock on localhost) only ever go into a --local publish, never into one for a real environment.
72
+ allowedHosts: [...(settings.allowedHosts || []), ...(config.local ? settings.localAllowedHosts || [] : [])],
73
+ secretFieldNames: settings.secretFieldNames || [],
74
+ triggers: bundleJs == null ? [] : settings.triggers || [],
75
+ entities,
76
+ // the module gives persons roles (tabbr.access.setManagedRoles) - shown to the club when it installs the module
77
+ manageRoles: bundleJs != null && settings.manageRoles === true,
78
+ };
79
+
80
+ return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
81
+ }
82
+
83
+ /** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
84
+ async function bundle(cwd, backendConfig) {
85
+ const entry = backendConfig.entry || DEFAULT_ENTRY;
86
+ const entryPath = path.join(cwd, entry);
87
+ if (!fs.existsSync(entryPath)) {
88
+ if (backendConfig.entry) {
89
+ // Explicitly configured but missing - fail loudly rather than silently skipping.
90
+ throw new Error(`Backend entry ${entryPath} does not exist (check "backend.entry" in tabbr.config.json).`);
91
+ }
92
+ return null;
93
+ }
94
+
95
+ let esbuild;
96
+ try {
97
+ // Resolved from the workspace being built, not from wherever this CLI happens to be installed -
98
+ // esbuild is the module author's devDependency, and the CLI may be hoisted, copied or linked.
99
+ esbuild = require(require.resolve('esbuild', { paths: [cwd] }));
100
+ } catch (err) {
101
+ throw new Error(
102
+ 'esbuild is required to build this module\'s backend part. Run "npm install --save-dev esbuild" in your module workspace.'
103
+ );
104
+ }
105
+
106
+ console.log(`Building backend module ${entryPath} ...`);
107
+ const result = await esbuild.build({
108
+ entryPoints: [entryPath],
109
+ bundle: true,
110
+ write: false,
111
+ format: 'iife',
112
+ globalName: GLOBAL_NAME,
113
+ target: 'es2022',
114
+ platform: 'neutral',
115
+ logLevel: 'silent',
116
+ });
117
+ if (!result.outputFiles || result.outputFiles.length === 0) {
118
+ throw new Error(`esbuild produced no output for ${entryPath}`);
119
+ }
120
+ return result.outputFiles[0].text;
121
+ }
122
+
123
+ module.exports = { buildBackend, entitiesOf, DEFAULT_ENTRY };