@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
|
@@ -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
|
+
}
|
|
@@ -8,7 +8,9 @@ export interface BulkRowSplit<T> {
|
|
|
8
8
|
readonly conflicts: readonly SfaBulkRowResult[];
|
|
9
9
|
/**
|
|
10
10
|
* The rows that did not land and can be sent again. A `conflict` is one of
|
|
11
|
-
* them, and also appears in `conflicts`.
|
|
11
|
+
* them, and also appears in `conflicts`. Read each row's `status` to say why
|
|
12
|
+
* it failed: `not_found` and `not_permitted` are settled refusals that a
|
|
13
|
+
* resend alone will not clear.
|
|
12
14
|
*/
|
|
13
15
|
readonly failed: readonly SfaBulkRowResult[];
|
|
14
16
|
}
|
|
@@ -16,9 +18,11 @@ export interface BulkRowSplit<T> {
|
|
|
16
18
|
* @description Splits per-row outcomes by what the caller may do with them.
|
|
17
19
|
*
|
|
18
20
|
* NOTE: `failed` is decided by what the answer says, never by exclusion.
|
|
19
|
-
* `conflict`, `invalid` and `
|
|
20
|
-
* those are the ones safe to send again — a row
|
|
21
|
-
* unwritten as one the server
|
|
21
|
+
* `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
|
|
22
|
+
* mean the row did not apply, so those are the ones safe to send again — a row
|
|
23
|
+
* the call never started is as unwritten as one the server refused. Each keeps
|
|
24
|
+
* its own `status`, so a caller can tell "no permission" from "failed
|
|
25
|
+
* validation" without a bucket per reason. Anything else the kit cannot place —
|
|
22
26
|
* `indeterminate`, no record, no version, a status outside the contract or
|
|
23
27
|
* belonging to another operation — may already be written, and a retry would
|
|
24
28
|
* write it twice.
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The outcomes that mean the row did not apply, so sending it again is safe.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: safe to send again, not sure to succeed. `invalid` needs the row
|
|
5
|
+
* corrected first, `conflict` a fresh `version`, and `not_found` /
|
|
6
|
+
* `not_permitted` answer the same until the record or the permission changes.
|
|
7
|
+
* What the set decides is that no write happened, so the caller may resend
|
|
8
|
+
* without writing a second record — which is the question `unresolved` exists
|
|
9
|
+
* to answer, and the reason these belong in `failed` instead.
|
|
10
|
+
*/
|
|
2
11
|
const RETRYABLE_STATUSES = new Set([
|
|
3
12
|
'conflict',
|
|
4
13
|
'invalid',
|
|
5
14
|
'not_attempted',
|
|
15
|
+
'not_found',
|
|
16
|
+
'not_permitted',
|
|
6
17
|
]);
|
|
7
18
|
/**
|
|
8
19
|
* @description Splits per-row outcomes by what the caller may do with them.
|
|
9
20
|
*
|
|
10
21
|
* NOTE: `failed` is decided by what the answer says, never by exclusion.
|
|
11
|
-
* `conflict`, `invalid` and `
|
|
12
|
-
* those are the ones safe to send again — a row
|
|
13
|
-
* unwritten as one the server
|
|
22
|
+
* `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
|
|
23
|
+
* mean the row did not apply, so those are the ones safe to send again — a row
|
|
24
|
+
* the call never started is as unwritten as one the server refused. Each keeps
|
|
25
|
+
* its own `status`, so a caller can tell "no permission" from "failed
|
|
26
|
+
* validation" without a bucket per reason. Anything else the kit cannot place —
|
|
14
27
|
* `indeterminate`, no record, no version, a status outside the contract or
|
|
15
28
|
* belonging to another operation — may already be written, and a retry would
|
|
16
29
|
* write it twice.
|
|
@@ -18,7 +18,9 @@ export interface UseBulkCreateRecordsResult {
|
|
|
18
18
|
/**
|
|
19
19
|
* The rows that did not land and can be sent again, with their `index` and
|
|
20
20
|
* `error`. A batch can be partly successful, so this being non-empty does
|
|
21
|
-
* not mean nothing was written
|
|
21
|
+
* not mean nothing was written. Read each row's `status` before retrying:
|
|
22
|
+
* `not_found` and `not_permitted` are settled refusals that come back the
|
|
23
|
+
* same however often they are resent.
|
|
22
24
|
*/
|
|
23
25
|
readonly failed: readonly SfaBulkRowResult[];
|
|
24
26
|
/**
|
|
@@ -50,7 +52,10 @@ export interface UseBulkCreateRecordsResult {
|
|
|
50
52
|
* the call as a whole.
|
|
51
53
|
*
|
|
52
54
|
* Retry only the rows in `failed`: those did not apply, which is what makes
|
|
53
|
-
* resending them safe
|
|
54
|
-
*
|
|
55
|
+
* resending them safe — safe, not sure to succeed. A `not_found` or
|
|
56
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
57
|
+
* changes, so branch on `status` rather than offering one retry for the whole
|
|
58
|
+
* bucket. Re-read the rows in `unresolved` instead — nothing protects against
|
|
59
|
+
* writing them twice.
|
|
55
60
|
*/
|
|
56
61
|
export declare function useBulkCreateRecords(collection: string): UseBulkCreateRecordsResult;
|
|
@@ -14,8 +14,11 @@ const sendBulkCreate = (client, collection, rows) => client.bulkCreate(collectio
|
|
|
14
14
|
* the call as a whole.
|
|
15
15
|
*
|
|
16
16
|
* Retry only the rows in `failed`: those did not apply, which is what makes
|
|
17
|
-
* resending them safe
|
|
18
|
-
*
|
|
17
|
+
* resending them safe — safe, not sure to succeed. A `not_found` or
|
|
18
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
19
|
+
* changes, so branch on `status` rather than offering one retry for the whole
|
|
20
|
+
* bucket. Re-read the rows in `unresolved` instead — nothing protects against
|
|
21
|
+
* writing them twice.
|
|
19
22
|
*/
|
|
20
23
|
export function useBulkCreateRecords(collection) {
|
|
21
24
|
const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkCreate);
|
|
@@ -20,7 +20,16 @@ export interface UseBulkDeleteRecordsResult {
|
|
|
20
20
|
* whether it still wants the record gone.
|
|
21
21
|
*/
|
|
22
22
|
readonly conflicts: readonly SfaBulkRowResult[];
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The rows that were not removed by this call, conflicts included.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: not all of them are still in place. A `not_found` row says the
|
|
27
|
+
* record was not there to delete — already gone, or never existing. It is
|
|
28
|
+
* reported here rather than in `deletedIds` because this call removed
|
|
29
|
+
* nothing, and the kit will not claim a deletion the server did not make.
|
|
30
|
+
* A caller whose grid wants the row cleared checks for that `status`;
|
|
31
|
+
* `not_permitted` is the one that leaves the record in place.
|
|
32
|
+
*/
|
|
24
33
|
readonly failed: readonly SfaBulkRowResult[];
|
|
25
34
|
/**
|
|
26
35
|
* The rows that may already be removed but cannot be reported either way.
|
|
@@ -46,5 +55,9 @@ export interface UseBulkDeleteRecordsResult {
|
|
|
46
55
|
* place while the rest of the batch applies. There is no rollback, so a
|
|
47
56
|
* partly failed batch has already removed records — which is why per-row
|
|
48
57
|
* outcomes are returned as data rather than thrown.
|
|
58
|
+
*
|
|
59
|
+
* Retry only the rows in `failed`, and read each row's `status` first: a
|
|
60
|
+
* `not_found` row was not there to delete and a `not_permitted` one may not be
|
|
61
|
+
* deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
|
|
49
62
|
*/
|
|
50
63
|
export declare function useBulkDeleteRecords(collection: string): UseBulkDeleteRecordsResult;
|
|
@@ -11,6 +11,10 @@ const sendBulkDelete = (client, collection, rows) => client.bulkDelete(collectio
|
|
|
11
11
|
* place while the rest of the batch applies. There is no rollback, so a
|
|
12
12
|
* partly failed batch has already removed records — which is why per-row
|
|
13
13
|
* outcomes are returned as data rather than thrown.
|
|
14
|
+
*
|
|
15
|
+
* Retry only the rows in `failed`, and read each row's `status` first: a
|
|
16
|
+
* `not_found` row was not there to delete and a `not_permitted` one may not be
|
|
17
|
+
* deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
|
|
14
18
|
*/
|
|
15
19
|
export function useBulkDeleteRecords(collection) {
|
|
16
20
|
const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkDelete);
|
|
@@ -21,7 +21,11 @@ export interface UseBulkUpdateRecordsResult {
|
|
|
21
21
|
* without a `version`, re-read it first — there is nothing to lock against.
|
|
22
22
|
*/
|
|
23
23
|
readonly conflicts: readonly SfaBulkRowResult[];
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The rows that did not land and can be sent again, conflicts included.
|
|
26
|
+
* Read each row's `status`: `not_found` and `not_permitted` are settled
|
|
27
|
+
* refusals, and a resend alone will not clear them.
|
|
28
|
+
*/
|
|
25
29
|
readonly failed: readonly SfaBulkRowResult[];
|
|
26
30
|
/**
|
|
27
31
|
* The rows that may already be applied but cannot be reported either way.
|
|
@@ -49,7 +53,9 @@ export interface UseBulkUpdateRecordsResult {
|
|
|
49
53
|
* as data rather than thrown errors.
|
|
50
54
|
*
|
|
51
55
|
* Retry only the rows in `failed`. A conflict is retried with the version from
|
|
52
|
-
* its `record`, never by re-sending the stale one.
|
|
56
|
+
* its `record`, never by re-sending the stale one. A `not_found` or
|
|
57
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
58
|
+
* changes — report it rather than offering a retry. Rows in `unresolved` are
|
|
53
59
|
* re-read, not retried.
|
|
54
60
|
*/
|
|
55
61
|
export declare function useBulkUpdateRecords(collection: string): UseBulkUpdateRecordsResult;
|
|
@@ -13,7 +13,9 @@ const sendBulkUpdate = (client, collection, rows) => client.bulkUpdate(collectio
|
|
|
13
13
|
* as data rather than thrown errors.
|
|
14
14
|
*
|
|
15
15
|
* Retry only the rows in `failed`. A conflict is retried with the version from
|
|
16
|
-
* its `record`, never by re-sending the stale one.
|
|
16
|
+
* its `record`, never by re-sending the stale one. A `not_found` or
|
|
17
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
18
|
+
* changes — report it rather than offering a retry. Rows in `unresolved` are
|
|
17
19
|
* re-read, not retried.
|
|
18
20
|
*/
|
|
19
21
|
export function useBulkUpdateRecords(collection) {
|
|
@@ -35,6 +35,7 @@ export declare class SfaClient implements SfaClientInterface {
|
|
|
35
35
|
/** In-memory cache for describe results, keyed by objectKey. Stores Promises
|
|
36
36
|
* so concurrent callers share a single in-flight request. */
|
|
37
37
|
private readonly describeCache;
|
|
38
|
+
private listObjectsCache;
|
|
38
39
|
constructor(config: SfaClientConfig);
|
|
39
40
|
private resolveBase;
|
|
40
41
|
get(collection: string, recordId: string): Promise<SfaRecord>;
|