@geniee-inc/sfa-kit 1.16.0 → 1.17.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/GUIDE.md +91 -1
- package/package.json +19 -2
- package/src/bin/sfa-kit-bundle.d.ts +13 -0
- package/src/bin/sfa-kit-bundle.js +142 -0
- package/src/bundle.d.ts +26 -0
- package/src/bundle.js +1 -0
- package/src/lib/bundle/build-static-bundle.d.ts +45 -0
- package/src/lib/bundle/build-static-bundle.js +263 -0
- package/src/lib/bundle/esbuild-loader.d.ts +25 -0
- package/src/lib/bundle/esbuild-loader.js +27 -0
- package/src/lib/bundle/index.d.ts +2 -0
- package/src/lib/bundle/index.js +1 -0
- package/src/lib/bundle/request-signature.d.ts +22 -0
- package/src/lib/bundle/request-signature.js +81 -0
- package/src/lib/signing/compute-content-hash.d.ts +1 -1
- package/src/lib/signing/compute-signature.d.ts +17 -0
- package/src/lib/signing/compute-signature.js +17 -0
- package/src/lib/signing/create-manifest-from-directory.d.ts +13 -3
- package/src/lib/signing/create-manifest-from-directory.js +15 -28
- package/src/lib/signing/create-manifest.d.ts +7 -1
- package/src/lib/signing/create-manifest.js +8 -2
- package/src/lib/signing/index.d.ts +6 -5
- package/src/lib/signing/index.js +5 -4
- package/src/lib/signing/read-bundle-files.d.ts +19 -0
- package/src/lib/signing/read-bundle-files.js +41 -0
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
- package/src/signing.d.ts +9 -4
- package/src/signing.js +1 -1
package/GUIDE.md
CHANGED
|
@@ -21,7 +21,97 @@ import pkg from '@geniee-inc/sfa-kit/package.json';
|
|
|
21
21
|
// pkg.version -> '1.14.0'
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Three additional subpaths are available: `@geniee-inc/sfa-kit/signing` exposes bundle-manifest utilities, `@geniee-inc/sfa-kit/bundle` builds and signs a static bundle in one call (both Node.js-only), and `./package.json` exposes the manifest. Internal modules cannot be imported directly.
|
|
25
|
+
|
|
26
|
+
### Static bundle manifest (custom-tab uploads)
|
|
27
|
+
|
|
28
|
+
A page uploaded through GENIEE's SFA/CRM settings UI as a custom tab is a framework-free static bundle — `index.html` + `manifest.json` + `assets/` — not a Next.js app. Its `manifest.json` carries the three fields Keystone's `BundleSignatureVerifier` reads: `studio_version`, `content_hash`, `signature`.
|
|
29
|
+
|
|
30
|
+
**The signing key lives only in Keystone, so a page build never computes the signature itself.** It hashes the bundle locally and asks Keystone to sign:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import {
|
|
34
|
+
readBundleFiles,
|
|
35
|
+
computeContentHash,
|
|
36
|
+
} from '@geniee-inc/sfa-kit/signing';
|
|
37
|
+
import type { BundleManifest } from '@geniee-inc/sfa-kit/signing';
|
|
38
|
+
|
|
39
|
+
const files = await readBundleFiles('dist');
|
|
40
|
+
const contentHash = computeContentHash(files);
|
|
41
|
+
|
|
42
|
+
// Keystone holds the signing key, so ask it for the signature. The Bearer token
|
|
43
|
+
// is the same one the rest of the API is called with.
|
|
44
|
+
const response = await fetch(`${apiBase}/v1/pages/sign`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
Authorization: `Bearer ${accessToken}`,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
studio_version: studioVersion,
|
|
52
|
+
content_hash: contentHash,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) throw new Error(`Signing failed: ${response.status}`);
|
|
56
|
+
const { signature } = (await response.json()) as { signature: string };
|
|
57
|
+
|
|
58
|
+
const manifest: BundleManifest = {
|
|
59
|
+
studio_version: studioVersion, // the Studio build version, NOT the sfa-kit npm version
|
|
60
|
+
content_hash: contentHash,
|
|
61
|
+
signature,
|
|
62
|
+
};
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Building the bundle
|
|
66
|
+
|
|
67
|
+
`@geniee-inc/sfa-kit/bundle` does the whole build in one call: it bundles the entry module with esbuild, writes `index.html`, hashes the result, gets the signature from Keystone and writes `manifest.json`.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { buildStaticBundle } from '@geniee-inc/sfa-kit/bundle';
|
|
71
|
+
|
|
72
|
+
await buildStaticBundle({
|
|
73
|
+
entry: 'entry.tsx', // renders <SfaLoginGate>…</SfaLoginGate> into #root
|
|
74
|
+
outDir: 'dist',
|
|
75
|
+
studioVersion: '0.5.0', // the Studio build version, NOT the sfa-kit npm version
|
|
76
|
+
apiBase: 'https://keystone-api-staging.chikyu.mobi',
|
|
77
|
+
accessToken: token,
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or from the command line, which is the shorter path when a build script drives it:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
SFA_KIT_ACCESS_TOKEN=<token> npx sfa-kit-bundle \
|
|
85
|
+
--entry entry.tsx \
|
|
86
|
+
--out dist \
|
|
87
|
+
--studio-version 0.5.0 \
|
|
88
|
+
--api-base https://keystone-api-staging.chikyu.mobi
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The token comes from the environment rather than a flag, so it stays out of shell history and the process list.
|
|
92
|
+
|
|
93
|
+
Then zip the **contents** of `dist/` — the bundle's files have to sit at the zip root, not inside a wrapper folder. Zipping is left to the caller: Node ships no zip writer, and every caller already has one.
|
|
94
|
+
|
|
95
|
+
`esbuild` is an optional peer dependency; install it alongside the kit. Its options are fixed: JS, JSX, TS and CSS bundle, but there is no loader for images or fonts, so a page importing a `.png`, `.svg` or a font — including through `url()` in CSS — fails to build. Inline such assets as data URIs, or reference them by absolute URL.
|
|
96
|
+
Nothing is written when signing fails, so a failed build never leaves an unsigned bundle behind to be uploaded by mistake.
|
|
97
|
+
|
|
98
|
+
`studioVersion` has no default on purpose. The signature covers `"{studio_version}:{content_hash}"` and Keystone reads the field back separately, so a guessed value produces a valid signature over the wrong version.
|
|
99
|
+
|
|
100
|
+
### Signing exports
|
|
101
|
+
|
|
102
|
+
The lower-level pieces `buildStaticBundle` uses, for a caller that builds its own way:
|
|
103
|
+
|
|
104
|
+
| Export | Needs the signing key |
|
|
105
|
+
| ----------------------------- | --------------------- |
|
|
106
|
+
| `readBundleFiles` | No |
|
|
107
|
+
| `computeContentHash` | No |
|
|
108
|
+
| `computeSignature` | **Yes** |
|
|
109
|
+
| `createManifest` | **Yes** |
|
|
110
|
+
| `createManifestFromDirectory` | **Yes** |
|
|
111
|
+
|
|
112
|
+
The three key-taking exports exist for the service that holds the key. Calling them from a page build means inventing a key, which Keystone will not accept — and the mistake is invisible at first, because bundle verification ships in `disabled` mode: the upload succeeds, and only starts failing once the mode is switched to `enforced`.
|
|
113
|
+
|
|
114
|
+
`content_hash` must come from `computeContentHash`, never from hashing files back to back. The algorithm length-prefixes each path and each file's contents before feeding them into one running SHA-256; without those prefixes `"ab" + "c"` and `"a" + "bc"` hash identically, and the result never matches Keystone's own computation.
|
|
25
115
|
|
|
26
116
|
## Architecture
|
|
27
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geniee-inc/sfa-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "React hooks and components for building pages that connect to GENIEE SFA/CRM data.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -17,8 +17,19 @@
|
|
|
17
17
|
"import": "./src/signing.js",
|
|
18
18
|
"default": "./src/signing.js"
|
|
19
19
|
},
|
|
20
|
+
"./bundle": {
|
|
21
|
+
"types": "./src/bundle.d.ts",
|
|
22
|
+
"import": "./src/bundle.js",
|
|
23
|
+
"default": "./src/bundle.js"
|
|
24
|
+
},
|
|
20
25
|
"./package.json": "./package.json"
|
|
21
26
|
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"sfa-kit-bundle": "./src/bin/sfa-kit-bundle.js"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.1.0"
|
|
32
|
+
},
|
|
22
33
|
"sideEffects": false,
|
|
23
34
|
"files": [
|
|
24
35
|
"src",
|
|
@@ -33,9 +44,15 @@
|
|
|
33
44
|
"license": "MIT",
|
|
34
45
|
"peerDependencies": {
|
|
35
46
|
"react": "^18.0.0 || ^19.0.0",
|
|
36
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
47
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
48
|
+
"esbuild": ">=0.18.0"
|
|
37
49
|
},
|
|
38
50
|
"dependencies": {
|
|
39
51
|
"tslib": "^2.3.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"esbuild": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
40
57
|
}
|
|
41
58
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @description Runs the bundler from the command line.
|
|
4
|
+
*
|
|
5
|
+
* Exported so the test suite drives the same entry point the `bin` does, and
|
|
6
|
+
* so a caller embedding it can pass argv explicitly.
|
|
7
|
+
*
|
|
8
|
+
* @returns The process exit code. 0 on success, 1 on any failure.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runBundleCli(argv: readonly string[], env: Readonly<Record<string, string | undefined>>, out: {
|
|
11
|
+
log(message: string): void;
|
|
12
|
+
error(message: string): void;
|
|
13
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { __awaiter } from "tslib";
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { buildStaticBundle } from '../lib/bundle/index.js';
|
|
6
|
+
/** The access token is read from here, never from an argument. */
|
|
7
|
+
const TOKEN_ENV = 'SFA_KIT_ACCESS_TOKEN';
|
|
8
|
+
const USAGE = `sfa-kit-bundle — build and sign a static bundle for an SFA/CRM custom tab
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
${TOKEN_ENV}=<token> npx sfa-kit-bundle \\
|
|
12
|
+
--entry entry.tsx \\
|
|
13
|
+
--out dist \\
|
|
14
|
+
--studio-version 0.5.0 \\
|
|
15
|
+
--api-base https://keystone-api-staging.chikyu.mobi
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--entry Entry module that mounts the page into #root. Required.
|
|
19
|
+
--out Output directory. Required.
|
|
20
|
+
--studio-version The Studio build version this bundle targets, not the
|
|
21
|
+
sfa-kit npm version. Required.
|
|
22
|
+
--api-base Keystone API base, without a trailing slash. Required.
|
|
23
|
+
--title <title> of the generated index.html. Default: SFA/CRM.
|
|
24
|
+
--help Show this message.
|
|
25
|
+
|
|
26
|
+
Environment:
|
|
27
|
+
${TOKEN_ENV} Bearer token for Keystone, the same one the rest of the
|
|
28
|
+
API is called with. Required, and read from the environment rather than an
|
|
29
|
+
argument so it does not land in shell history or in the process list.
|
|
30
|
+
|
|
31
|
+
Writes index.html, assets/bundle.js and a signed manifest.json. Zip the
|
|
32
|
+
contents of the output directory — the files have to sit at the zip root.
|
|
33
|
+
Needs esbuild installed alongside the kit.`;
|
|
34
|
+
const OPTIONS = {
|
|
35
|
+
entry: { type: 'string' },
|
|
36
|
+
out: { type: 'string' },
|
|
37
|
+
'studio-version': { type: 'string' },
|
|
38
|
+
'api-base': { type: 'string' },
|
|
39
|
+
title: { type: 'string' },
|
|
40
|
+
help: { type: 'boolean' },
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* @description Reads the flags, reporting a malformed command line as data.
|
|
44
|
+
*
|
|
45
|
+
* NOTE: `parseArgs` throws on a misspelled flag, a flag with no value, and a
|
|
46
|
+
* stray positional. Left to propagate, those would reject the promise the bin
|
|
47
|
+
* only attaches `.then()` to, and the user would get a stack trace where the
|
|
48
|
+
* usage text belongs.
|
|
49
|
+
*/
|
|
50
|
+
function parseCliOptions(argv) {
|
|
51
|
+
try {
|
|
52
|
+
const { values } = parseArgs({
|
|
53
|
+
args: [...argv],
|
|
54
|
+
options: OPTIONS,
|
|
55
|
+
allowPositionals: false,
|
|
56
|
+
});
|
|
57
|
+
return { values };
|
|
58
|
+
}
|
|
59
|
+
catch (thrown) {
|
|
60
|
+
return { error: thrown instanceof Error ? thrown.message : String(thrown) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* @description Runs the bundler from the command line.
|
|
65
|
+
*
|
|
66
|
+
* Exported so the test suite drives the same entry point the `bin` does, and
|
|
67
|
+
* so a caller embedding it can pass argv explicitly.
|
|
68
|
+
*
|
|
69
|
+
* @returns The process exit code. 0 on success, 1 on any failure.
|
|
70
|
+
*/
|
|
71
|
+
export function runBundleCli(argv, env, out) {
|
|
72
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
73
|
+
var _a, _b, _c, _d, _e, _f;
|
|
74
|
+
const parsed = parseCliOptions(argv);
|
|
75
|
+
if (parsed.error != null) {
|
|
76
|
+
out.error(`${parsed.error}\n\n${USAGE}`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
const values = parsed.values;
|
|
80
|
+
if (values.help) {
|
|
81
|
+
out.log(USAGE);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
const missing = ['entry', 'out', 'studio-version', 'api-base']
|
|
85
|
+
.filter(name => { var _a; return ((_a = values[name]) !== null && _a !== void 0 ? _a : '') === ''; })
|
|
86
|
+
.map(name => `--${name}`);
|
|
87
|
+
const accessToken = (_a = env[TOKEN_ENV]) !== null && _a !== void 0 ? _a : '';
|
|
88
|
+
if (accessToken === '')
|
|
89
|
+
missing.push(TOKEN_ENV);
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
out.error(`Missing required: ${missing.join(', ')}\n\n${USAGE}`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const manifest = yield buildStaticBundle(Object.assign({ entry: (_b = values.entry) !== null && _b !== void 0 ? _b : '', outDir: (_c = values.out) !== null && _c !== void 0 ? _c : '', studioVersion: (_d = values['studio-version']) !== null && _d !== void 0 ? _d : '', apiBase: (_e = values['api-base']) !== null && _e !== void 0 ? _e : '', accessToken }, (((_f = values.title) !== null && _f !== void 0 ? _f : '') === '' ? {} : { title: values.title })));
|
|
96
|
+
out.log(`Built and signed ${values.out} ` +
|
|
97
|
+
`(studio_version ${manifest.studio_version}, ` +
|
|
98
|
+
`content_hash ${manifest.content_hash}).`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
catch (thrown) {
|
|
102
|
+
// NOTE: buildStaticBundle removes what it wrote, so there is normally
|
|
103
|
+
// nothing left to upload. The one case where output survives — cleanup
|
|
104
|
+
// itself failing — says so in its own message.
|
|
105
|
+
out.error(`${thrown instanceof Error ? thrown.message : String(thrown)}\n` +
|
|
106
|
+
'Nothing was signed, so nothing is ready to upload.');
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** True when this module is what node was asked to run, not an import of it. */
|
|
112
|
+
function isEntryPoint() {
|
|
113
|
+
// NOTE: the file name, not a substring of the path. `includes()` would also
|
|
114
|
+
// fire for a consumer script under, say, ~/work/sfa-kit-bundle-demo/, which
|
|
115
|
+
// would start the CLI merely because that script imported runBundleCli.
|
|
116
|
+
//
|
|
117
|
+
// `import.meta.url` would be the exact comparison, but Jest transforms this
|
|
118
|
+
// file to CommonJS, where that syntax does not parse and the whole suite
|
|
119
|
+
// fails to load.
|
|
120
|
+
const invoked = process.argv[1];
|
|
121
|
+
if (invoked == null)
|
|
122
|
+
return false;
|
|
123
|
+
const name = basename(invoked);
|
|
124
|
+
return name === 'sfa-kit-bundle' || name === 'sfa-kit-bundle.js';
|
|
125
|
+
}
|
|
126
|
+
/* istanbul ignore next -- the process wiring is exercised by running the bin */
|
|
127
|
+
if (isEntryPoint()) {
|
|
128
|
+
// NOTE: no top-level await — the package's module target does not allow it.
|
|
129
|
+
void runBundleCli(process.argv.slice(2), process.env, {
|
|
130
|
+
log: message => console.log(message),
|
|
131
|
+
error: message => console.error(message),
|
|
132
|
+
})
|
|
133
|
+
.then(code => {
|
|
134
|
+
process.exitCode = code;
|
|
135
|
+
})
|
|
136
|
+
.catch((thrown) => {
|
|
137
|
+
// Nothing above should reject, but an unhandled rejection would exit 0
|
|
138
|
+
// on some Node versions — reporting a failed build as a success.
|
|
139
|
+
console.error(thrown instanceof Error ? thrown.message : String(thrown));
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
});
|
|
142
|
+
}
|
package/src/bundle.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static bundle builder for GENIEE SFA/CRM custom-tab uploads.
|
|
3
|
+
*
|
|
4
|
+
* This subpath export (`@geniee-inc/sfa-kit/bundle`) is Node.js-only and needs
|
|
5
|
+
* `esbuild` installed alongside the kit. It produces the framework-free
|
|
6
|
+
* `index.html` + `assets/bundle.js` + signed `manifest.json` a page is uploaded
|
|
7
|
+
* as; the caller zips the result.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { buildStaticBundle } from '@geniee-inc/sfa-kit/bundle';
|
|
12
|
+
*
|
|
13
|
+
* await buildStaticBundle({
|
|
14
|
+
* entry: 'entry.tsx',
|
|
15
|
+
* outDir: 'dist',
|
|
16
|
+
* studioVersion: '0.5.0',
|
|
17
|
+
* apiBase: 'https://keystone-api-staging.chikyu.mobi',
|
|
18
|
+
* accessToken: token,
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
export type { BundleManifest } from './lib/signing/index.js';
|
|
25
|
+
export type { BuildStaticBundleOptions } from './lib/bundle/index.js';
|
|
26
|
+
export { buildStaticBundle } from './lib/bundle/index.js';
|
package/src/bundle.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildStaticBundle } from './lib/bundle/index.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { BundleManifest } from '../signing/types.js';
|
|
2
|
+
export interface BuildStaticBundleOptions {
|
|
3
|
+
/** Entry module that mounts the page, e.g. `entry.tsx`. */
|
|
4
|
+
readonly entry: string;
|
|
5
|
+
/** Directory the bundle is written to. Created if absent. */
|
|
6
|
+
readonly outDir: string;
|
|
7
|
+
/**
|
|
8
|
+
* The Studio build version this bundle targets — not the sfa-kit npm version.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: required on purpose. The signature covers
|
|
11
|
+
* `"{studio_version}:{content_hash}"` and Keystone reads the field back
|
|
12
|
+
* separately, so a default would sign the wrong version perfectly well.
|
|
13
|
+
*/
|
|
14
|
+
readonly studioVersion: string;
|
|
15
|
+
/** Keystone API base, e.g. `https://keystone-api-staging.chikyu.mobi`. */
|
|
16
|
+
readonly apiBase: string;
|
|
17
|
+
/** The same Bearer token the rest of the Keystone API is called with. */
|
|
18
|
+
readonly accessToken: string;
|
|
19
|
+
/** Title of the generated `index.html`. */
|
|
20
|
+
readonly title?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Extra manifest fields, e.g. `name` or `displayName`. Keystone ignores
|
|
23
|
+
* everything but the three signed fields, which cannot be overridden here.
|
|
24
|
+
*/
|
|
25
|
+
readonly manifestExtras?: Readonly<Record<string, unknown>>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* @description Builds the framework-free static bundle a page is uploaded as
|
|
29
|
+
* to become a custom tab, and signs it.
|
|
30
|
+
*
|
|
31
|
+
* Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
|
|
32
|
+
* `outDir`. The caller zips that directory's contents — the files have to sit
|
|
33
|
+
* at the zip root. Zipping is left out because Node ships no zip writer, and a
|
|
34
|
+
* dependency for it would reach a package whose runtime needs only `tslib`.
|
|
35
|
+
*
|
|
36
|
+
* Signing is not optional, and a failure removes the output: an unsigned bundle
|
|
37
|
+
* uploads successfully while Keystone's verifier runs in `disabled` mode and
|
|
38
|
+
* only starts failing at `enforced`, so a half-built directory left behind is
|
|
39
|
+
* something a caller can still zip and upload by mistake.
|
|
40
|
+
*
|
|
41
|
+
* **Node.js only**, and needs `esbuild` installed alongside the kit.
|
|
42
|
+
*
|
|
43
|
+
* @returns The manifest that was written.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildStaticBundle({ entry, outDir, studioVersion, apiBase, accessToken, title, manifestExtras, }: BuildStaticBundleOptions): Promise<BundleManifest>;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { join, posix, relative } from 'node:path';
|
|
4
|
+
import { computeContentHash } from '../signing/compute-content-hash.js';
|
|
5
|
+
import { readBundleFiles } from '../signing/read-bundle-files.js';
|
|
6
|
+
import { loadEsbuild } from './esbuild-loader.js';
|
|
7
|
+
import { requestSignature } from './request-signature.js';
|
|
8
|
+
/** Where the bundled script lands inside the output directory. */
|
|
9
|
+
const BUNDLE_PATH = 'assets/bundle.js';
|
|
10
|
+
/** esbuild writes this beside the script when the entry imports any CSS. */
|
|
11
|
+
const STYLES_PATH = 'assets/bundle.css';
|
|
12
|
+
/**
|
|
13
|
+
* Every path this builder writes, relative to `outDir` with forward slashes.
|
|
14
|
+
*
|
|
15
|
+
* NOTE: full paths, not top-level names. Matching on names would accept any
|
|
16
|
+
* directory shaped like a static site — an `index.html` beside an `assets/`
|
|
17
|
+
* folder — and clear it.
|
|
18
|
+
*/
|
|
19
|
+
const OWN_OUTPUT = new Set([
|
|
20
|
+
'index.html',
|
|
21
|
+
'manifest.json',
|
|
22
|
+
BUNDLE_PATH,
|
|
23
|
+
STYLES_PATH,
|
|
24
|
+
]);
|
|
25
|
+
/** How many unexpected paths an error names before it stops listing them. */
|
|
26
|
+
const UNEXPECTED_SHOWN = 5;
|
|
27
|
+
/**
|
|
28
|
+
* Removed rather than refused. Opening the output directory in Finder to zip
|
|
29
|
+
* it — which GUIDE.md tells the reader to do — leaves a `.DS_Store` behind,
|
|
30
|
+
* and refusing it would wedge the build → zip → rebuild loop on a hidden file.
|
|
31
|
+
*/
|
|
32
|
+
const JUNK_FILES = new Set(['.DS_Store', 'Thumbs.db']);
|
|
33
|
+
/**
|
|
34
|
+
* @description Builds the framework-free static bundle a page is uploaded as
|
|
35
|
+
* to become a custom tab, and signs it.
|
|
36
|
+
*
|
|
37
|
+
* Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
|
|
38
|
+
* `outDir`. The caller zips that directory's contents — the files have to sit
|
|
39
|
+
* at the zip root. Zipping is left out because Node ships no zip writer, and a
|
|
40
|
+
* dependency for it would reach a package whose runtime needs only `tslib`.
|
|
41
|
+
*
|
|
42
|
+
* Signing is not optional, and a failure removes the output: an unsigned bundle
|
|
43
|
+
* uploads successfully while Keystone's verifier runs in `disabled` mode and
|
|
44
|
+
* only starts failing at `enforced`, so a half-built directory left behind is
|
|
45
|
+
* something a caller can still zip and upload by mistake.
|
|
46
|
+
*
|
|
47
|
+
* **Node.js only**, and needs `esbuild` installed alongside the kit.
|
|
48
|
+
*
|
|
49
|
+
* @returns The manifest that was written.
|
|
50
|
+
*/
|
|
51
|
+
export function buildStaticBundle(_a) {
|
|
52
|
+
return __awaiter(this, arguments, void 0, function* ({ entry, outDir, studioVersion, apiBase, accessToken, title = 'SFA/CRM', manifestExtras = {}, }) {
|
|
53
|
+
for (const [name, value] of [
|
|
54
|
+
['entry', entry],
|
|
55
|
+
['outDir', outDir],
|
|
56
|
+
['studioVersion', studioVersion],
|
|
57
|
+
['apiBase', apiBase],
|
|
58
|
+
['accessToken', accessToken],
|
|
59
|
+
]) {
|
|
60
|
+
// NOTE: checked before esbuild runs. An empty apiBase otherwise surfaces as
|
|
61
|
+
// "Failed to parse URL from /v1/pages/sign" after a full build.
|
|
62
|
+
// NOTE: `== null` as well as empty. The subpath is published, and a JS
|
|
63
|
+
// caller omitting a field would otherwise pay for a full build to reach
|
|
64
|
+
// `Authorization: Bearer undefined`.
|
|
65
|
+
if (value == null || value === '') {
|
|
66
|
+
throw new Error(`${name} is required and must not be empty`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Serialised now so a value that cannot be JSON — cyclic, a BigInt — fails
|
|
70
|
+
// before anything is written, rather than after Keystone has signed.
|
|
71
|
+
assertSerialisable(manifestExtras);
|
|
72
|
+
const esbuild = yield loadEsbuild();
|
|
73
|
+
yield prepareOutDir(outDir);
|
|
74
|
+
yield mkdir(join(outDir, 'assets'), { recursive: true });
|
|
75
|
+
try {
|
|
76
|
+
yield esbuild.build({
|
|
77
|
+
entryPoints: [entry],
|
|
78
|
+
outfile: join(outDir, BUNDLE_PATH),
|
|
79
|
+
bundle: true,
|
|
80
|
+
minify: true,
|
|
81
|
+
format: 'esm',
|
|
82
|
+
jsx: 'automatic',
|
|
83
|
+
target: 'es2020',
|
|
84
|
+
});
|
|
85
|
+
// entry が CSS を import していると esbuild が bundle.css も書き出す。
|
|
86
|
+
// index.html から参照しないと、署名も検証も通ったうえで無スタイルの画面が
|
|
87
|
+
// 出る — どこにも失敗が現れない
|
|
88
|
+
const hasStyles = yield stat(join(outDir, STYLES_PATH)).then(() => true, () => false);
|
|
89
|
+
yield writeFile(join(outDir, 'index.html'), renderIndexHtml(title, hasStyles), 'utf-8');
|
|
90
|
+
// manifest.json is written last: the hash covers every other file, so the
|
|
91
|
+
// directory has to be complete before it is computed.
|
|
92
|
+
const contentHash = computeContentHash(yield readBundleFiles(outDir));
|
|
93
|
+
const manifest = {
|
|
94
|
+
studio_version: studioVersion,
|
|
95
|
+
content_hash: contentHash,
|
|
96
|
+
signature: yield requestSignature({
|
|
97
|
+
apiBase,
|
|
98
|
+
accessToken,
|
|
99
|
+
studioVersion,
|
|
100
|
+
contentHash,
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
yield writeFile(join(outDir, 'manifest.json'),
|
|
104
|
+
// NOTE: the decorative fields go first so the three signed ones cannot be
|
|
105
|
+
// overwritten by a caller's extras.
|
|
106
|
+
`${JSON.stringify(Object.assign(Object.assign({}, manifestExtras), manifest), null, 2)}\n`, 'utf-8');
|
|
107
|
+
return manifest;
|
|
108
|
+
}
|
|
109
|
+
catch (thrown) {
|
|
110
|
+
// Whatever was written is a bundle in every respect but the manifest, and
|
|
111
|
+
// a caller can still zip and upload it.
|
|
112
|
+
yield removeOwnOutput(outDir).catch(() => {
|
|
113
|
+
throw new Error(`${thrown instanceof Error ? thrown.message : String(thrown)} ` +
|
|
114
|
+
`Additionally, ${outDir} could not be cleaned up — delete it by ` +
|
|
115
|
+
'hand rather than uploading it; it carries no signature.');
|
|
116
|
+
});
|
|
117
|
+
throw thrown;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Fails now on a value `JSON.stringify` would reject after the bundle is signed. */
|
|
122
|
+
function assertSerialisable(extras) {
|
|
123
|
+
try {
|
|
124
|
+
JSON.stringify(extras);
|
|
125
|
+
}
|
|
126
|
+
catch (thrown) {
|
|
127
|
+
throw new Error('manifestExtras must be JSON-serialisable ' +
|
|
128
|
+
`(${thrown instanceof Error ? thrown.message : String(thrown)})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* @description Empties the output directory of previous build output, and
|
|
133
|
+
* refuses to touch anything else.
|
|
134
|
+
*
|
|
135
|
+
* NOTE: `outDir` comes from a caller's flag, so nothing is deleted recursively
|
|
136
|
+
* — `--out src` would take the source tree with it. Only the exact files this
|
|
137
|
+
* builder writes are removed, and any other file at any depth stops the build:
|
|
138
|
+
* a leftover lands in `content_hash` and then has to be in the zip for the
|
|
139
|
+
* signature to match, which quietly makes it part of the bundle.
|
|
140
|
+
*
|
|
141
|
+
* NOTE: matching those names is not enough to claim the directory. `index.html`
|
|
142
|
+
* beside `assets/bundle.js` is also what a hand-written static site looks like,
|
|
143
|
+
* and deleting one would be unrecoverable. A `manifest.json` carrying the three
|
|
144
|
+
* signed fields is the proof, because only this builder writes one.
|
|
145
|
+
*/
|
|
146
|
+
function prepareOutDir(outDir) {
|
|
147
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
148
|
+
var _a;
|
|
149
|
+
// NOTE: names only. Reading contents here — as readBundleFiles does — would
|
|
150
|
+
// pull the whole directory into memory to decide it is the wrong directory,
|
|
151
|
+
// so `--out .` in a repository would run out of memory instead of refusing.
|
|
152
|
+
const entries = yield readdir(outDir, {
|
|
153
|
+
recursive: true,
|
|
154
|
+
withFileTypes: true,
|
|
155
|
+
}).catch((thrown) => {
|
|
156
|
+
// Only "it is not there yet" means there is nothing to check. Swallowing
|
|
157
|
+
// every error would turn a permission problem into a silent build over
|
|
158
|
+
// whatever the directory held.
|
|
159
|
+
if (thrown.code === 'ENOENT')
|
|
160
|
+
return null;
|
|
161
|
+
throw thrown;
|
|
162
|
+
});
|
|
163
|
+
if (entries == null)
|
|
164
|
+
return;
|
|
165
|
+
const unexpected = [];
|
|
166
|
+
const junk = [];
|
|
167
|
+
const own = [];
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
if (!entry.isFile())
|
|
170
|
+
continue;
|
|
171
|
+
const parent = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
|
|
172
|
+
const path = relative(outDir, join(parent, entry.name))
|
|
173
|
+
.split(/[\\/]/)
|
|
174
|
+
.join(posix.sep);
|
|
175
|
+
if (OWN_OUTPUT.has(path)) {
|
|
176
|
+
own.push(path);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// NOTE: collected, not deleted yet. Removing junk from a directory the
|
|
180
|
+
// next few lines may refuse to touch would contradict the promise above.
|
|
181
|
+
if (JUNK_FILES.has(entry.name)) {
|
|
182
|
+
junk.push(join(parent, entry.name));
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
unexpected.push(path);
|
|
186
|
+
if (unexpected.length > UNEXPECTED_SHOWN)
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
if (unexpected.length > 0) {
|
|
190
|
+
const shown = unexpected.slice(0, UNEXPECTED_SHOWN).sort().join(', ');
|
|
191
|
+
const more = unexpected.length > UNEXPECTED_SHOWN ? ', and more' : '';
|
|
192
|
+
throw new Error(`${outDir} holds files this build did not produce ` +
|
|
193
|
+
`(${shown}${more}). They would be hashed into the bundle. ` +
|
|
194
|
+
'Point --out at an empty directory, or clear it first.');
|
|
195
|
+
}
|
|
196
|
+
if (own.length > 0 && !(yield hasOwnManifest(outDir))) {
|
|
197
|
+
throw new Error(`${outDir} holds ${own.sort().join(', ')} but no manifest.json this ` +
|
|
198
|
+
'builder wrote, so it is not a previous build of this bundle and will ' +
|
|
199
|
+
'not be overwritten. Point --out at an empty directory, or clear it ' +
|
|
200
|
+
'first.');
|
|
201
|
+
}
|
|
202
|
+
for (const path of junk) {
|
|
203
|
+
yield rm(path, { force: true });
|
|
204
|
+
}
|
|
205
|
+
yield removeOwnOutput(outDir);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Whether the directory carries a manifest only this builder could have written. */
|
|
209
|
+
function hasOwnManifest(outDir) {
|
|
210
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
211
|
+
const raw = yield readFile(join(outDir, 'manifest.json'), 'utf-8').catch(() => null);
|
|
212
|
+
if (raw == null)
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(raw);
|
|
216
|
+
return ['studio_version', 'content_hash', 'signature'].every(field => typeof parsed[field] === 'string' && parsed[field] !== '');
|
|
217
|
+
}
|
|
218
|
+
catch (_a) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* @description Deletes the files this builder writes, and nothing else.
|
|
225
|
+
*
|
|
226
|
+
* NOTE: separate from {@link prepareOutDir} because the failure path needs the
|
|
227
|
+
* removal without the validation. Reusing the checking function there would
|
|
228
|
+
* throw on any stray file and leave the unsigned bundle exactly where the
|
|
229
|
+
* contract promises it will not be.
|
|
230
|
+
*/
|
|
231
|
+
function removeOwnOutput(outDir) {
|
|
232
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
233
|
+
for (const path of OWN_OUTPUT) {
|
|
234
|
+
yield rm(join(outDir, path), { force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** The host page for the bundle: the script, and the stylesheet if there is one. */
|
|
239
|
+
function renderIndexHtml(title, hasStyles) {
|
|
240
|
+
const stylesheet = hasStyles
|
|
241
|
+
? `\n <link rel="stylesheet" href="./${STYLES_PATH}" />`
|
|
242
|
+
: '';
|
|
243
|
+
return `<!doctype html>
|
|
244
|
+
<html lang="ja">
|
|
245
|
+
<head>
|
|
246
|
+
<meta charset="utf-8" />
|
|
247
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
248
|
+
<title>${escapeHtml(title)}</title>${stylesheet}
|
|
249
|
+
</head>
|
|
250
|
+
<body>
|
|
251
|
+
<div id="root"></div>
|
|
252
|
+
<script type="module" src="./${BUNDLE_PATH}"></script>
|
|
253
|
+
</body>
|
|
254
|
+
</html>
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
function escapeHtml(value) {
|
|
258
|
+
return value
|
|
259
|
+
.replace(/&/g, '&')
|
|
260
|
+
.replace(/</g, '<')
|
|
261
|
+
.replace(/>/g, '>')
|
|
262
|
+
.replace(/"/g, '"');
|
|
263
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The slice of esbuild's API the bundler uses. */
|
|
2
|
+
export interface EsbuildModule {
|
|
3
|
+
build(options: {
|
|
4
|
+
entryPoints: string[];
|
|
5
|
+
outfile: string;
|
|
6
|
+
bundle: boolean;
|
|
7
|
+
minify: boolean;
|
|
8
|
+
format: 'esm';
|
|
9
|
+
jsx: 'automatic';
|
|
10
|
+
target: string;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* @description Loads esbuild at call time.
|
|
15
|
+
*
|
|
16
|
+
* esbuild is an optional peer: it is a build-time tool for the one caller that
|
|
17
|
+
* packages a static bundle, and adding it to `dependencies` would put a
|
|
18
|
+
* platform-specific binary into every install of a package whose runtime needs
|
|
19
|
+
* nothing but `tslib`.
|
|
20
|
+
*
|
|
21
|
+
* NOTE: the specifier goes through a variable so TypeScript does not resolve
|
|
22
|
+
* `esbuild` at compile time. Naming it directly would make the kit fail to
|
|
23
|
+
* build wherever esbuild is absent — which is everywhere it is not being used.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadEsbuild(): Promise<EsbuildModule>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
/**
|
|
3
|
+
* @description Loads esbuild at call time.
|
|
4
|
+
*
|
|
5
|
+
* esbuild is an optional peer: it is a build-time tool for the one caller that
|
|
6
|
+
* packages a static bundle, and adding it to `dependencies` would put a
|
|
7
|
+
* platform-specific binary into every install of a package whose runtime needs
|
|
8
|
+
* nothing but `tslib`.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: the specifier goes through a variable so TypeScript does not resolve
|
|
11
|
+
* `esbuild` at compile time. Naming it directly would make the kit fail to
|
|
12
|
+
* build wherever esbuild is absent — which is everywhere it is not being used.
|
|
13
|
+
*/
|
|
14
|
+
export function loadEsbuild() {
|
|
15
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
16
|
+
const specifier = 'esbuild';
|
|
17
|
+
try {
|
|
18
|
+
return (yield import(specifier));
|
|
19
|
+
}
|
|
20
|
+
catch (thrown) {
|
|
21
|
+
const reason = thrown instanceof Error ? thrown.message : String(thrown);
|
|
22
|
+
throw new Error('esbuild is required to build a static bundle but could not be loaded ' +
|
|
23
|
+
`(${reason}). Install it alongside @geniee-inc/sfa-kit ` +
|
|
24
|
+
'(npm install --save-dev esbuild).');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildStaticBundle } from './build-static-bundle.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
3
|
+
*
|
|
4
|
+
* The signing key never leaves Keystone: deploying it to the page builder as
|
|
5
|
+
* well was the original design and was dropped because Studio's secret store is
|
|
6
|
+
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
7
|
+
* The caller computes `contentHash` locally and Keystone signs it.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
10
|
+
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
11
|
+
* would produce an artifact that looks fine and starts being rejected the day
|
|
12
|
+
* the mode is switched to `enforced` — with nothing on this side to explain it.
|
|
13
|
+
*
|
|
14
|
+
* @param apiBase - Keystone API base. A trailing slash is trimmed.
|
|
15
|
+
* @param accessToken - The same Bearer token the rest of the API is called with.
|
|
16
|
+
*/
|
|
17
|
+
export declare function requestSignature({ apiBase, accessToken, studioVersion, contentHash, }: {
|
|
18
|
+
readonly apiBase: string;
|
|
19
|
+
readonly accessToken: string;
|
|
20
|
+
readonly studioVersion: string;
|
|
21
|
+
readonly contentHash: string;
|
|
22
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
/**
|
|
3
|
+
* Both the hash sent and the signature returned are hex SHA-256 / HMAC-SHA-256
|
|
4
|
+
* digests, so the same shape applies in each direction.
|
|
5
|
+
*/
|
|
6
|
+
const HEX_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
|
+
/** Matches the timeout the documented Python flow uses for the same call. */
|
|
8
|
+
const SIGN_TIMEOUT_MS = 30000;
|
|
9
|
+
/**
|
|
10
|
+
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
11
|
+
*
|
|
12
|
+
* The signing key never leaves Keystone: deploying it to the page builder as
|
|
13
|
+
* well was the original design and was dropped because Studio's secret store is
|
|
14
|
+
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
15
|
+
* The caller computes `contentHash` locally and Keystone signs it.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
18
|
+
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
19
|
+
* would produce an artifact that looks fine and starts being rejected the day
|
|
20
|
+
* the mode is switched to `enforced` — with nothing on this side to explain it.
|
|
21
|
+
*
|
|
22
|
+
* @param apiBase - Keystone API base. A trailing slash is trimmed.
|
|
23
|
+
* @param accessToken - The same Bearer token the rest of the API is called with.
|
|
24
|
+
*/
|
|
25
|
+
export function requestSignature(_a) {
|
|
26
|
+
return __awaiter(this, arguments, void 0, function* ({ apiBase, accessToken, studioVersion, contentHash, }) {
|
|
27
|
+
if (!HEX_DIGEST_PATTERN.test(contentHash)) {
|
|
28
|
+
throw new Error(`content_hash must be a 64-character hex digest, got ${JSON.stringify(contentHash)}`);
|
|
29
|
+
}
|
|
30
|
+
// 末尾のスラッシュを落とす。付いたままだと //v1/pages/sign になり、多くの
|
|
31
|
+
// ゲートウェイが 404 を返すため、設定ミスが認証の問題に見える
|
|
32
|
+
const base = apiBase.replace(/\/+$/, '');
|
|
33
|
+
const url = `${base}/v1/pages/sign`;
|
|
34
|
+
// NOTE: a bad host gives "fetch failed" and an empty apiBase "Failed to parse
|
|
35
|
+
// URL from /v1/pages/sign" — neither names the endpoint, the base, or the
|
|
36
|
+
// fact that the bundle is now unsigned.
|
|
37
|
+
const response = yield globalThis
|
|
38
|
+
.fetch(url, {
|
|
39
|
+
// NOTE: without a deadline a server that accepts the connection and never
|
|
40
|
+
// answers hangs the build, leaving an unsigned output directory behind.
|
|
41
|
+
signal: AbortSignal.timeout(SIGN_TIMEOUT_MS),
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
Authorization: `Bearer ${accessToken}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
studio_version: studioVersion,
|
|
49
|
+
content_hash: contentHash,
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
.catch((thrown) => {
|
|
53
|
+
throw new Error(`POST ${url} could not be reached ` +
|
|
54
|
+
`(${thrown instanceof Error ? thrown.message : String(thrown)}). ` +
|
|
55
|
+
'The bundle was not signed; check the API base and network access.');
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const detail = yield response.text().catch(() => '');
|
|
59
|
+
// NOTE: the endpoint exists (SFAJAI-186), so a 404 is a wrong apiBase and a
|
|
60
|
+
// 401 a token from another org — not a reason to ship the bundle unsigned.
|
|
61
|
+
throw new Error(`POST /v1/pages/sign failed: ${response.status} ${response.statusText}` +
|
|
62
|
+
`${detail ? ` — ${detail}` : ''}. The bundle was not signed and must ` +
|
|
63
|
+
'not be uploaded; check the API base and the access token.');
|
|
64
|
+
}
|
|
65
|
+
// NOTE: a proxy answering 200 with an HTML page would throw a bare
|
|
66
|
+
// SyntaxError here, escaping the messages built for every other failure.
|
|
67
|
+
const payload = (yield response.json().catch(() => null));
|
|
68
|
+
if (payload == null || typeof payload.signature !== 'string') {
|
|
69
|
+
throw new Error(`POST ${url} returned no signature. The bundle was not signed.`);
|
|
70
|
+
}
|
|
71
|
+
// NOTE: the hash sent is validated, so the signature coming back is too. An
|
|
72
|
+
// error page echoed into this field would otherwise be written into
|
|
73
|
+
// manifest.json, upload fine while the verifier is `disabled`, and fail only
|
|
74
|
+
// once it is switched to `enforced`.
|
|
75
|
+
if (!HEX_DIGEST_PATTERN.test(payload.signature)) {
|
|
76
|
+
throw new Error(`POST ${url} returned ${JSON.stringify(payload.signature)}, which is ` +
|
|
77
|
+
'not an HMAC-SHA-256 hex digest. The bundle was not signed.');
|
|
78
|
+
}
|
|
79
|
+
return payload.signature;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FileEntry } from './types';
|
|
1
|
+
import type { FileEntry } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Computes the SHA-256 content hash matching Keystone's
|
|
4
4
|
* {@link https://github.com/chikyuinc/chikyu/blob/branch_main/keystone/src/keystone/features/pages/domain/page_bundle_integrity.py compute_content_hash}.
|
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Signs the message `"{studioVersion}:{contentHash}"` with the given key.
|
|
6
6
|
*
|
|
7
|
+
* **The signing key lives only in Keystone (`KEYSTONE_STUDIO__SIGNING_KEY`),
|
|
8
|
+
* so a page build cannot call this.** Deploying the key to Studio as well was
|
|
9
|
+
* the original design and was dropped in SFAJAI-186: Studio's secret store is
|
|
10
|
+
* per project, while the key has to be identical across all of them. Keystone
|
|
11
|
+
* now signs on request, and a page build asks it:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const files = await readBundleFiles('dist');
|
|
15
|
+
* const contentHash = computeContentHash(files);
|
|
16
|
+
* // POST /v1/pages/sign { studio_version, content_hash } -> { signature }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Reach for this only from a service that legitimately holds the key. Signing
|
|
20
|
+
* with a made-up one is not caught at upload time while the verifier runs in
|
|
21
|
+
* `disabled` mode — the bundle is accepted, and only starts failing once the
|
|
22
|
+
* mode is switched to `enforced`.
|
|
23
|
+
*
|
|
7
24
|
* @param studioVersion - Studio version stored as page metadata.
|
|
8
25
|
* @param contentHash - Hex-encoded SHA-256 from {@link computeContentHash}.
|
|
9
26
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -5,6 +5,23 @@ import { createHmac } from 'node:crypto';
|
|
|
5
5
|
*
|
|
6
6
|
* Signs the message `"{studioVersion}:{contentHash}"` with the given key.
|
|
7
7
|
*
|
|
8
|
+
* **The signing key lives only in Keystone (`KEYSTONE_STUDIO__SIGNING_KEY`),
|
|
9
|
+
* so a page build cannot call this.** Deploying the key to Studio as well was
|
|
10
|
+
* the original design and was dropped in SFAJAI-186: Studio's secret store is
|
|
11
|
+
* per project, while the key has to be identical across all of them. Keystone
|
|
12
|
+
* now signs on request, and a page build asks it:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const files = await readBundleFiles('dist');
|
|
16
|
+
* const contentHash = computeContentHash(files);
|
|
17
|
+
* // POST /v1/pages/sign { studio_version, content_hash } -> { signature }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Reach for this only from a service that legitimately holds the key. Signing
|
|
21
|
+
* with a made-up one is not caught at upload time while the verifier runs in
|
|
22
|
+
* `disabled` mode — the bundle is accepted, and only starts failing once the
|
|
23
|
+
* mode is switched to `enforced`.
|
|
24
|
+
*
|
|
8
25
|
* @param studioVersion - Studio version stored as page metadata.
|
|
9
26
|
* @param contentHash - Hex-encoded SHA-256 from {@link computeContentHash}.
|
|
10
27
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import type { BundleManifest } from './types';
|
|
1
|
+
import type { BundleManifest } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Reads a dist directory and creates a signed {@link BundleManifest}.
|
|
4
4
|
*
|
|
5
|
-
* Walks the directory tree
|
|
6
|
-
*
|
|
5
|
+
* Walks the directory tree via {@link readBundleFiles} and delegates to
|
|
6
|
+
* {@link createManifest}.
|
|
7
|
+
*
|
|
8
|
+
* **Requires the shared signing key, which page builds do not have.** Keystone
|
|
9
|
+
* holds it and is the only party that signs: an earlier design deployed it to
|
|
10
|
+
* Studio as well, and was abandoned because Studio's secret store is per
|
|
11
|
+
* project while the key must be identical everywhere (SFAJAI-186). Use this
|
|
12
|
+
* only from a service that legitimately holds the key. A page build calls
|
|
13
|
+
* {@link readBundleFiles} + {@link computeContentHash} and posts the hash to
|
|
14
|
+
* `POST /v1/pages/sign` instead — signing with an invented key produces a
|
|
15
|
+
* bundle that uploads today, while the verifier runs in `disabled` mode, and
|
|
16
|
+
* starts failing the moment it is switched to `enforced`.
|
|
7
17
|
*
|
|
8
18
|
* This function is **Node.js-only** and must not be used in browser
|
|
9
19
|
* environments. It does NOT write the manifest file to disk; the caller
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createManifest } from './create-manifest';
|
|
2
|
+
import { createManifest } from './create-manifest.js';
|
|
3
|
+
import { readBundleFiles } from './read-bundle-files.js';
|
|
5
4
|
/**
|
|
6
5
|
* Reads a dist directory and creates a signed {@link BundleManifest}.
|
|
7
6
|
*
|
|
8
|
-
* Walks the directory tree
|
|
9
|
-
*
|
|
7
|
+
* Walks the directory tree via {@link readBundleFiles} and delegates to
|
|
8
|
+
* {@link createManifest}.
|
|
9
|
+
*
|
|
10
|
+
* **Requires the shared signing key, which page builds do not have.** Keystone
|
|
11
|
+
* holds it and is the only party that signs: an earlier design deployed it to
|
|
12
|
+
* Studio as well, and was abandoned because Studio's secret store is per
|
|
13
|
+
* project while the key must be identical everywhere (SFAJAI-186). Use this
|
|
14
|
+
* only from a service that legitimately holds the key. A page build calls
|
|
15
|
+
* {@link readBundleFiles} + {@link computeContentHash} and posts the hash to
|
|
16
|
+
* `POST /v1/pages/sign` instead — signing with an invented key produces a
|
|
17
|
+
* bundle that uploads today, while the verifier runs in `disabled` mode, and
|
|
18
|
+
* starts failing the moment it is switched to `enforced`.
|
|
10
19
|
*
|
|
11
20
|
* This function is **Node.js-only** and must not be used in browser
|
|
12
21
|
* environments. It does NOT write the manifest file to disk; the caller
|
|
@@ -18,29 +27,7 @@ import { createManifest } from './create-manifest';
|
|
|
18
27
|
*/
|
|
19
28
|
export function createManifestFromDirectory(distDir, studioVersion, signingKey) {
|
|
20
29
|
return __awaiter(this, void 0, void 0, function* () {
|
|
21
|
-
const files = yield
|
|
30
|
+
const files = yield readBundleFiles(distDir);
|
|
22
31
|
return createManifest(studioVersion, files, signingKey);
|
|
23
32
|
});
|
|
24
33
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Recursively collects all files under a directory as {@link FileEntry} objects.
|
|
27
|
-
* Paths are normalized to POSIX forward slashes.
|
|
28
|
-
*/
|
|
29
|
-
function collectFiles(dir) {
|
|
30
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
31
|
-
var _a;
|
|
32
|
-
const entries = yield readdir(dir, { withFileTypes: true, recursive: true });
|
|
33
|
-
const files = [];
|
|
34
|
-
for (const entry of entries) {
|
|
35
|
-
if (!entry.isFile())
|
|
36
|
-
continue;
|
|
37
|
-
const parentPath = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
|
|
38
|
-
const absolutePath = join(parentPath, entry.name);
|
|
39
|
-
const relativePath = relative(dir, absolutePath);
|
|
40
|
-
const posixPath = relativePath.split(/[\\/]/).join(posix.sep);
|
|
41
|
-
const content = yield readFile(absolutePath);
|
|
42
|
-
files.push({ path: posixPath, content });
|
|
43
|
-
}
|
|
44
|
-
return files;
|
|
45
|
-
});
|
|
46
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BundleManifest, FileEntry } from './types';
|
|
1
|
+
import type { BundleManifest, FileEntry } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Creates a signed {@link BundleManifest} from a list of bundle files.
|
|
4
4
|
*
|
|
@@ -6,6 +6,12 @@ import type { BundleManifest, FileEntry } from './types';
|
|
|
6
6
|
* Callers can spread decorative fields (e.g. `sfaKitVersion` for the npm
|
|
7
7
|
* package version, `name`, `displayName`) on top before writing to disk.
|
|
8
8
|
*
|
|
9
|
+
* **Requires the shared signing key, which page builds do not have** — see
|
|
10
|
+
* {@link computeSignature}. A page build computes `content_hash` with
|
|
11
|
+
* {@link computeContentHash} and gets the signature from Keystone's
|
|
12
|
+
* `POST /v1/pages/sign`, then writes the three fields as a
|
|
13
|
+
* {@link BundleManifest} literal.
|
|
14
|
+
*
|
|
9
15
|
* @param studioVersion - Studio version stored as page metadata.
|
|
10
16
|
* @param files - All files in the bundle (manifest.json is automatically excluded).
|
|
11
17
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computeContentHash } from './compute-content-hash';
|
|
2
|
-
import { computeSignature } from './compute-signature';
|
|
1
|
+
import { computeContentHash } from './compute-content-hash.js';
|
|
2
|
+
import { computeSignature } from './compute-signature.js';
|
|
3
3
|
/**
|
|
4
4
|
* Creates a signed {@link BundleManifest} from a list of bundle files.
|
|
5
5
|
*
|
|
@@ -7,6 +7,12 @@ import { computeSignature } from './compute-signature';
|
|
|
7
7
|
* Callers can spread decorative fields (e.g. `sfaKitVersion` for the npm
|
|
8
8
|
* package version, `name`, `displayName`) on top before writing to disk.
|
|
9
9
|
*
|
|
10
|
+
* **Requires the shared signing key, which page builds do not have** — see
|
|
11
|
+
* {@link computeSignature}. A page build computes `content_hash` with
|
|
12
|
+
* {@link computeContentHash} and gets the signature from Keystone's
|
|
13
|
+
* `POST /v1/pages/sign`, then writes the three fields as a
|
|
14
|
+
* {@link BundleManifest} literal.
|
|
15
|
+
*
|
|
10
16
|
* @param studioVersion - Studio version stored as page metadata.
|
|
11
17
|
* @param files - All files in the bundle (manifest.json is automatically excluded).
|
|
12
18
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export type { BundleManifest, FileEntry } from './types';
|
|
2
|
-
export { computeContentHash } from './compute-content-hash';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
1
|
+
export type { BundleManifest, FileEntry } from './types.js';
|
|
2
|
+
export { computeContentHash } from './compute-content-hash.js';
|
|
3
|
+
export { readBundleFiles } from './read-bundle-files.js';
|
|
4
|
+
export { computeSignature } from './compute-signature.js';
|
|
5
|
+
export { createManifest } from './create-manifest.js';
|
|
6
|
+
export { createManifestFromDirectory } from './create-manifest-from-directory.js';
|
package/src/lib/signing/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { computeContentHash } from './compute-content-hash';
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
1
|
+
export { computeContentHash } from './compute-content-hash.js';
|
|
2
|
+
export { readBundleFiles } from './read-bundle-files.js';
|
|
3
|
+
export { computeSignature } from './compute-signature.js';
|
|
4
|
+
export { createManifest } from './create-manifest.js';
|
|
5
|
+
export { createManifestFromDirectory } from './create-manifest-from-directory.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FileEntry } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* @description Reads every file under a built bundle directory as a
|
|
4
|
+
* {@link FileEntry}, ready for {@link computeContentHash}.
|
|
5
|
+
*
|
|
6
|
+
* This is the key-free half of building a bundle manifest: it needs no signing
|
|
7
|
+
* key, so a page build can compute its own `content_hash` and ask Keystone for
|
|
8
|
+
* the signature (`POST /v1/pages/sign`) rather than holding the shared secret.
|
|
9
|
+
*
|
|
10
|
+
* Paths are relative to `bundleDir` and use forward slashes on every platform,
|
|
11
|
+
* because the content hash is computed over the path bytes — a Windows
|
|
12
|
+
* backslash would hash to a different value than the same bundle built on
|
|
13
|
+
* Linux, and Keystone would reject it.
|
|
14
|
+
*
|
|
15
|
+
* **Node.js only.** Uses `node:fs`, so it must not reach a browser bundle.
|
|
16
|
+
*
|
|
17
|
+
* @param bundleDir - Absolute or relative path to the built bundle (e.g. `dist`).
|
|
18
|
+
*/
|
|
19
|
+
export declare function readBundleFiles(bundleDir: string): Promise<FileEntry[]>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, posix } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* @description Reads every file under a built bundle directory as a
|
|
6
|
+
* {@link FileEntry}, ready for {@link computeContentHash}.
|
|
7
|
+
*
|
|
8
|
+
* This is the key-free half of building a bundle manifest: it needs no signing
|
|
9
|
+
* key, so a page build can compute its own `content_hash` and ask Keystone for
|
|
10
|
+
* the signature (`POST /v1/pages/sign`) rather than holding the shared secret.
|
|
11
|
+
*
|
|
12
|
+
* Paths are relative to `bundleDir` and use forward slashes on every platform,
|
|
13
|
+
* because the content hash is computed over the path bytes — a Windows
|
|
14
|
+
* backslash would hash to a different value than the same bundle built on
|
|
15
|
+
* Linux, and Keystone would reject it.
|
|
16
|
+
*
|
|
17
|
+
* **Node.js only.** Uses `node:fs`, so it must not reach a browser bundle.
|
|
18
|
+
*
|
|
19
|
+
* @param bundleDir - Absolute or relative path to the built bundle (e.g. `dist`).
|
|
20
|
+
*/
|
|
21
|
+
export function readBundleFiles(bundleDir) {
|
|
22
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
23
|
+
var _a;
|
|
24
|
+
const entries = yield readdir(bundleDir, {
|
|
25
|
+
withFileTypes: true,
|
|
26
|
+
recursive: true,
|
|
27
|
+
});
|
|
28
|
+
const files = [];
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (!entry.isFile())
|
|
31
|
+
continue;
|
|
32
|
+
const parentPath = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
|
|
33
|
+
const absolutePath = join(parentPath, entry.name);
|
|
34
|
+
const relativePath = relative(bundleDir, absolutePath);
|
|
35
|
+
const posixPath = relativePath.split(/[\\/]/).join(posix.sep);
|
|
36
|
+
const content = yield readFile(absolutePath);
|
|
37
|
+
files.push({ path: posixPath, content });
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SFA_KIT_VERSION = "1.
|
|
1
|
+
export declare const SFA_KIT_VERSION = "1.17.0";
|
package/src/lib/version.js
CHANGED
package/src/signing.d.ts
CHANGED
|
@@ -5,14 +5,19 @@
|
|
|
5
5
|
* and should not be imported in browser environments. It uses `node:crypto`
|
|
6
6
|
* for SHA-256 and HMAC-SHA-256 operations.
|
|
7
7
|
*
|
|
8
|
+
* The signing key lives only in Keystone, so a page build computes the content
|
|
9
|
+
* hash here and asks Keystone for the signature. Only `readBundleFiles` and
|
|
10
|
+
* `computeContentHash` are usable without the key.
|
|
11
|
+
*
|
|
8
12
|
* @example
|
|
9
13
|
* ```ts
|
|
10
|
-
* import {
|
|
14
|
+
* import { readBundleFiles, computeContentHash } from '@geniee-inc/sfa-kit/signing';
|
|
11
15
|
*
|
|
12
|
-
* const
|
|
16
|
+
* const contentHash = computeContentHash(await readBundleFiles('./dist'));
|
|
17
|
+
* // POST /v1/pages/sign { studio_version, content_hash } -> { signature }
|
|
13
18
|
* ```
|
|
14
19
|
*
|
|
15
20
|
* @packageDocumentation
|
|
16
21
|
*/
|
|
17
|
-
export type { BundleManifest, FileEntry } from './lib/signing';
|
|
18
|
-
export { computeContentHash, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing';
|
|
22
|
+
export type { BundleManifest, FileEntry } from './lib/signing/index.js';
|
|
23
|
+
export { computeContentHash, readBundleFiles, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing/index.js';
|
package/src/signing.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { computeContentHash, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing';
|
|
1
|
+
export { computeContentHash, readBundleFiles, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing/index.js';
|