@geniee-inc/sfa-kit 1.14.1 → 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 +131 -12
- 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/auth/ObjectKeyGuard.d.ts +12 -0
- package/src/lib/auth/ObjectKeyGuard.js +61 -0
- package/src/lib/auth/SfaLoginGate.js +5 -4
- 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/hooks/splitBulkRows.d.ts +8 -4
- package/src/lib/hooks/splitBulkRows.js +17 -4
- package/src/lib/hooks/useBulkCreateRecords.d.ts +8 -3
- package/src/lib/hooks/useBulkCreateRecords.js +5 -2
- package/src/lib/hooks/useBulkDeleteRecords.d.ts +14 -1
- package/src/lib/hooks/useBulkDeleteRecords.js +4 -0
- package/src/lib/hooks/useBulkUpdateRecords.d.ts +8 -2
- package/src/lib/hooks/useBulkUpdateRecords.js +3 -1
- package/src/lib/provider/SfaClient.d.ts +1 -0
- package/src/lib/provider/SfaClient.js +18 -5
- package/src/lib/provider/types.d.ts +19 -3
- 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
|
|
|
@@ -286,9 +376,11 @@ that did land, and those are not recoverable anywhere else.
|
|
|
286
376
|
whole picture leaves it looking neither written nor retryable. Re-read the
|
|
287
377
|
records to find out what landed
|
|
288
378
|
- `failed` — the rows that did not land **and can be sent again**: `conflict`,
|
|
289
|
-
`invalid` and `
|
|
290
|
-
that they did not apply; there is no
|
|
291
|
-
on
|
|
379
|
+
`invalid`, `not_attempted`, `not_found` and `not_permitted`. Retry exactly
|
|
380
|
+
these. What makes that safe is that they did not apply; there is no
|
|
381
|
+
server-side de-duplication to fall back on. Read each row's `status` before
|
|
382
|
+
offering a retry: `not_found` and `not_permitted` are the server's settled
|
|
383
|
+
answer, and resending alone will not change it
|
|
292
384
|
- `unresolved` — the rows that may already be written but cannot be reported
|
|
293
385
|
either way. They keep whatever `id` arrived. **Re-read these; never retry
|
|
294
386
|
them**: a create that already landed would be written twice
|
|
@@ -375,7 +467,9 @@ return (
|
|
|
375
467
|
<ErrorSnackbar
|
|
376
468
|
error={null}
|
|
377
469
|
message={`${results.length} 件中 ${updated.length} 件を保存しました。${failed.length} 件は保存されていません。`}
|
|
378
|
-
onRetry={() =>
|
|
470
|
+
onRetry={() =>
|
|
471
|
+
void bulkUpdate(rowsFor(resendable(failed))).catch(() => undefined)
|
|
472
|
+
}
|
|
379
473
|
onClose={reset}
|
|
380
474
|
/>
|
|
381
475
|
)}
|
|
@@ -385,17 +479,42 @@ return (
|
|
|
385
479
|
|
|
386
480
|
Counts only: which row failed and why belongs in the grid, where the reader
|
|
387
481
|
acts on it — `results` carries each row's `index` and its per-field errors.
|
|
388
|
-
|
|
389
|
-
|
|
482
|
+
`unresolved` rows are re-read instead, so label the action accordingly
|
|
483
|
+
(`retryLabel`).
|
|
484
|
+
|
|
485
|
+
Offer `onRetry` for the `failed` rows a resend can still change — that is what
|
|
486
|
+
`resendable` stands for above:
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
const SETTLED = new Set(['not_found', 'not_permitted']);
|
|
490
|
+
|
|
491
|
+
const resendable = (rows: readonly SfaBulkRowResult[]) =>
|
|
492
|
+
rows.filter(row => !SETTLED.has(row.status));
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Every row in `failed` is safe to send again, but a `not_found` or
|
|
496
|
+
`not_permitted` one comes back refused however often it is sent. Showing those
|
|
497
|
+
under a retry button gives the reader an action that cannot succeed; report
|
|
498
|
+
what they say instead.
|
|
390
499
|
|
|
391
500
|
A row counts as written only when it carries what a write produces: the record
|
|
392
501
|
for a create or an update, the id for a delete. Anything short of that is
|
|
393
502
|
`failed` or `unresolved`, and which one is decided by what the answer says,
|
|
394
|
-
not by elimination.
|
|
395
|
-
may be sent again:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
503
|
+
not by elimination. Five statuses mean the row did not apply, and only those
|
|
504
|
+
may be sent again:
|
|
505
|
+
|
|
506
|
+
| Status | What it means | What to do |
|
|
507
|
+
| --------------- | -------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
508
|
+
| `conflict` | Another write got there first | Retry with the `version` the row reports, or re-read first if none arrived |
|
|
509
|
+
| `invalid` | The row failed validation | Correct the row from its per-field errors, then resend |
|
|
510
|
+
| `not_attempted` | The call ran out of budget before starting the row | Resend exactly as it was |
|
|
511
|
+
| `not_found` | The record the row named does not exist | Tell the reader; a resend answers the same |
|
|
512
|
+
| `not_permitted` | The caller may not write that record | Tell the reader; a resend answers the same |
|
|
513
|
+
|
|
514
|
+
`not_found` and `not_permitted` are refusals of a row the call did reach, so no
|
|
515
|
+
write happened and resending is safe — it is simply futile until the record or
|
|
516
|
+
the permission changes. Their `error.code` may be an empty string: keystone has
|
|
517
|
+
not minted one for `not_permitted`, so branch on `status` rather than `code`.
|
|
399
518
|
|
|
400
519
|
Everything else is `unresolved`: `indeterminate`, the server saying it cannot
|
|
401
520
|
tell whether the row applied; a claimed success missing what it should carry —
|
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,12 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
interface ObjectKeyGuardProps {
|
|
3
|
+
readonly objectKey: string;
|
|
4
|
+
readonly children: ReactNode;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* objectKey が組織のオブジェクトカタログに存在することを検証する。
|
|
8
|
+
* 不一致の場合はページ本体の代わりにエラーバナーを表示し、
|
|
9
|
+
* singular / typo のキーがリフレッシュ時に 404 で壊れる前に検出する。
|
|
10
|
+
*/
|
|
11
|
+
export declare function ObjectKeyGuard({ objectKey, children }: ObjectKeyGuardProps): JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useObjectCatalog } from '../hooks/useObjectCatalog';
|
|
3
|
+
import { font, space } from '../theme';
|
|
4
|
+
/**
|
|
5
|
+
* objectKey が組織のオブジェクトカタログに存在することを検証する。
|
|
6
|
+
* 不一致の場合はページ本体の代わりにエラーバナーを表示し、
|
|
7
|
+
* singular / typo のキーがリフレッシュ時に 404 で壊れる前に検出する。
|
|
8
|
+
*/
|
|
9
|
+
export function ObjectKeyGuard({ objectKey, children }) {
|
|
10
|
+
const { objects, isLoading, error } = useObjectCatalog();
|
|
11
|
+
// カタログ取得中やエラー時はブロックせずそのまま表示する。
|
|
12
|
+
// ガードが止めるのは「カタログに無い」と確定した場合のみ。
|
|
13
|
+
if (isLoading || error || objects.length === 0)
|
|
14
|
+
return _jsx(_Fragment, { children: children });
|
|
15
|
+
const knownKeys = objects.map(o => o.key);
|
|
16
|
+
if (knownKeys.includes(objectKey))
|
|
17
|
+
return _jsx(_Fragment, { children: children });
|
|
18
|
+
// 提案のみ大文字小文字を無視する。一致判定は API がキーを厳密比較するため厳密のまま
|
|
19
|
+
const target = objectKey.toLowerCase();
|
|
20
|
+
const suggestion = knownKeys.find(knownKey => {
|
|
21
|
+
const key = knownKey.toLowerCase();
|
|
22
|
+
return key.startsWith(target) || target.startsWith(key);
|
|
23
|
+
});
|
|
24
|
+
return (_jsx("div", { style: {
|
|
25
|
+
padding: space.lg,
|
|
26
|
+
fontFamily: font.family,
|
|
27
|
+
maxWidth: 560,
|
|
28
|
+
margin: '80px auto',
|
|
29
|
+
}, children: _jsxs("div", { style: {
|
|
30
|
+
background: '#FEF2F2',
|
|
31
|
+
border: '1px solid #FECACA',
|
|
32
|
+
borderRadius: 8,
|
|
33
|
+
padding: space.lg,
|
|
34
|
+
}, children: [_jsx("div", { style: {
|
|
35
|
+
fontWeight: 700,
|
|
36
|
+
fontSize: 15,
|
|
37
|
+
color: '#991B1B',
|
|
38
|
+
marginBottom: 8,
|
|
39
|
+
}, children: "Invalid object key" }), _jsxs("p", { style: {
|
|
40
|
+
margin: 0,
|
|
41
|
+
fontSize: 14,
|
|
42
|
+
color: '#7F1D1D',
|
|
43
|
+
lineHeight: 1.5,
|
|
44
|
+
}, children: [_jsx("code", { style: {
|
|
45
|
+
background: '#FEE2E2',
|
|
46
|
+
padding: '2px 6px',
|
|
47
|
+
borderRadius: 4,
|
|
48
|
+
fontSize: 13,
|
|
49
|
+
}, children: objectKey }), ' ', "does not match any object in this organization.", suggestion && (_jsxs(_Fragment, { children: [' ', "Did you mean", ' ', _jsx("code", { style: {
|
|
50
|
+
background: '#DCFCE7',
|
|
51
|
+
padding: '2px 6px',
|
|
52
|
+
borderRadius: 4,
|
|
53
|
+
fontSize: 13,
|
|
54
|
+
color: '#166534',
|
|
55
|
+
}, children: suggestion }), "?"] }))] }), _jsx("p", { style: {
|
|
56
|
+
margin: '12px 0 0',
|
|
57
|
+
fontSize: 12,
|
|
58
|
+
color: '#9CA3AF',
|
|
59
|
+
lineHeight: 1.4,
|
|
60
|
+
}, children: "Object keys are the plural collection name from the object catalogue (e.g. \"business_discussions\", not \"business_discussion\"). Check the objectKey prop on SfaLoginGate and the manifest." })] }) }));
|
|
61
|
+
}
|
|
@@ -6,6 +6,7 @@ import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from
|
|
|
6
6
|
import { SFA_KIT_VERSION } from '../version';
|
|
7
7
|
import { DevToolbar } from './DevToolbar';
|
|
8
8
|
import { authenticate } from './keystone-auth';
|
|
9
|
+
import { ObjectKeyGuard } from './ObjectKeyGuard';
|
|
9
10
|
import { KEYSTONE_PRODUCTION_API_BASE, resolveApiBase, } from './resolve-api-base';
|
|
10
11
|
import { loadSession, saveSession, clearSession, loadLastOrgId, } from './session-store';
|
|
11
12
|
import { StandalonePreviewCtx } from './StandalonePreviewContext';
|
|
@@ -102,7 +103,7 @@ export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, ob
|
|
|
102
103
|
return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u3067\u304D\u307E\u305B\u3093", message: "SFA/CRM \u306E\u30DB\u30B9\u30C8\u30AA\u30EA\u30B8\u30F3\u3092\u78BA\u8A8D\u3067\u304D\u306A\u3044\u305F\u3081\u3001\u3053\u306E\u30DA\u30FC\u30B8\u3092\u8868\u793A\u3067\u304D\u307E\u305B\u3093\u3002\u57CB\u3081\u8FBC\u307F\u8A2D\u5B9A\u3092\u7BA1\u7406\u8005\u306B\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044\u3002" }));
|
|
103
104
|
}
|
|
104
105
|
// Embedded: the SFA/CRM host that framed us decides the environment.
|
|
105
|
-
return (_jsx(EmbedFlow, { trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
|
|
106
|
+
return (_jsx(EmbedFlow, { objectKey: objectKey, trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
|
|
106
107
|
}
|
|
107
108
|
// Standalone: there is no host, so the page's own origin decides.
|
|
108
109
|
return (_jsx(StandaloneFlow, { apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvePageOrigin()), defaultOrganizationId: defaultOrganizationId, objectKey: objectKey, level: level, children: children }));
|
|
@@ -110,7 +111,7 @@ export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, ob
|
|
|
110
111
|
// ---------------------------------------------------------------------------
|
|
111
112
|
// Embed flow (iframe + postMessage)
|
|
112
113
|
// ---------------------------------------------------------------------------
|
|
113
|
-
function EmbedFlow({ trustedOrigin, apiBase, children, }) {
|
|
114
|
+
function EmbedFlow({ objectKey, trustedOrigin, apiBase, children, }) {
|
|
114
115
|
const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
|
|
115
116
|
const { phase, handleAuthError } = useEmbedSession({
|
|
116
117
|
apiBase: normalizedBase,
|
|
@@ -135,7 +136,7 @@ function EmbedFlow({ trustedOrigin, apiBase, children, }) {
|
|
|
135
136
|
}
|
|
136
137
|
if (!auth)
|
|
137
138
|
return null;
|
|
138
|
-
return (_jsx(EmbedContextValue.Provider, { value: phase.context, children: _jsx(SfaProvider, { apiBase: normalizedBase, auth: auth, mode: "direct", organizationId: phase.session.organizationId, onAuthError: handleAuthError, children: children }) }));
|
|
139
|
+
return (_jsx(EmbedContextValue.Provider, { value: phase.context, children: _jsx(SfaProvider, { apiBase: normalizedBase, auth: auth, mode: "direct", organizationId: phase.session.organizationId, onAuthError: handleAuthError, children: objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children) }) }));
|
|
139
140
|
}
|
|
140
141
|
function EmbedStatus({ title, message, showReload, }) {
|
|
141
142
|
const handleReload = useCallback(() => {
|
|
@@ -241,7 +242,7 @@ function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, chil
|
|
|
241
242
|
if (!session || !auth) {
|
|
242
243
|
return (_jsx(LoginForm, { defaultApiBase: apiBase, defaultOrganizationId: defaultOrganizationId, onSubmit: handleLogin, status: status, error: error }));
|
|
243
244
|
}
|
|
244
|
-
return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), children] }) }));
|
|
245
|
+
return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children)] }) }));
|
|
245
246
|
}
|
|
246
247
|
// ---------------------------------------------------------------------------
|
|
247
248
|
// GENIEE SFA/CRM-branded login form
|
|
@@ -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>;
|