@starci/hfs 4.7.20 → 4.7.22
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/CHANGELOG.md +8 -0
- package/db/dump.mjs +14 -2
- package/emit/static-graph.mjs +15 -1
- package/package.json +1 -1
- package/release/sonar-quality-gate.mjs +32 -7
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/modules/schemas/provision-operation.schema.yaml +165 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +22 -6
- package/runtime/scripts/hfs/stacks-gate.mjs +8 -0
- package/runtime/scripts/lib/json-schema.mjs +96 -0
- package/runtime/scripts/lib/provision-operation-schema.mjs +17 -0
- package/sync/index.mjs +1 -1
- package/templates/app/hooks/husky/pre-commit +1 -1
- package/templates/app/tool-config/prettierignore +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +2 -1
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.22 - 2026-10-22
|
|
4
|
+
- Fixed: the bundled runtime carries `modules/schemas/provision-operation.schema.yaml`; 4.7.21 left it out, so `starci app check` and `starci app lint` failed with ENOENT in every app whose stack declaration has an `operations` block. The composition reads both schemas through `new URL(..., import.meta.url)`, the form the runtime copy follows. Spec: `src/provision-operation-schema-bundle.spec.mjs`.
|
|
5
|
+
|
|
6
|
+
## 4.7.21 - 2026-10-22
|
|
7
|
+
- Fixed: the release gate's Sonar read retries a network error or HTTP 5xx up to 5 times with backoff inside its 600 s wait; a 4xx and every gate status keep their verdict. Spec: `src/sonar-quality-gate.spec.mjs`.
|
|
8
|
+
- Fixed: `starci app db dump` archives its parts with tar run inside the scratch directory and relative names only (no `-C`, no absolute path): GNU tar, first on PATH under Git Bash on Windows, read the absolute drive-letter archive path as a remote host and every backup failed with `DB_BACKUP_TOOL_FAILED`. Spec: `src/db-dump-archive.spec.mjs`.
|
|
9
|
+
- Fixed: the bundled stacks gate validates a declaration's `operations` block with the composed provision-operation schema instead of crashing on its cross-file `$ref`; the bundled schema lets a SonarCloud admin credential name a runtime secret.env variable (`hostConnection: sonar-runtime-secret-env`).
|
|
10
|
+
|
|
3
11
|
## 4.7.20 - 2026-10-22
|
|
4
12
|
- Changed: the bundled failure-code catalog carries the Sonar quality-gate ensure results (`sonar-gate-*`); canon pins follow the matching package cohort.
|
|
5
13
|
|
package/db/dump.mjs
CHANGED
|
@@ -17,6 +17,19 @@ export function rowcounts(session) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** The archive name inside the plaintext scratch directory. */
|
|
21
|
+
export const ARCHIVE_NAME = 'backup.tar';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Archive `parts` of `scratch` into scratch/backup.tar. tar runs inside scratch with relative names only: no -C and no
|
|
25
|
+
* absolute path, because GNU tar (Git Bash puts it first on Windows) reads a drive-letter path (the `C:` of an absolute Windows path) as a remote `host:path`, while bsdtar
|
|
26
|
+
* does not; relative arguments mean the same to both. Returns the archive's absolute path.
|
|
27
|
+
*/
|
|
28
|
+
export function archiveParts(ctx, scratch, parts) {
|
|
29
|
+
backupTool(ctx, 'tar', ['-cf', ARCHIVE_NAME, ...parts], { cwd: scratch });
|
|
30
|
+
return path.join(scratch, ARCHIVE_NAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
/** Writes only ciphertext and its receipt permanently; a receipt is published only after header, size and hash checks. */
|
|
21
34
|
function dumpDatabase(ctx, options = {}) {
|
|
22
35
|
if (!options.out) throw refusal('DB_USAGE', '--out is required');
|
|
@@ -41,8 +54,7 @@ function dumpDatabase(ctx, options = {}) {
|
|
|
41
54
|
const counts = rowcounts(session);
|
|
42
55
|
fs.writeFileSync(path.join(scratch, 'rowcounts.json'), `${JSON.stringify(counts, null, 2)}\n`, { mode: 0o600 });
|
|
43
56
|
const parts = [...jobs.map(([file]) => file), 'rowcounts.json'];
|
|
44
|
-
const tar =
|
|
45
|
-
backupTool(ctx, 'tar', ['-cf', tar, '-C', scratch, ...parts]);
|
|
57
|
+
const tar = archiveParts(ctx, scratch, parts);
|
|
46
58
|
const bytes = encryptFile(ctx, tar, archive, recipients);
|
|
47
59
|
const receipt = { schema: RECEIPT_SCHEMA, project: session.project, target: session.target.kind, ref: session.target.ref ?? null,
|
|
48
60
|
at, file: path.basename(archive), bytes, sha256: sha256(archive), parts,
|
package/emit/static-graph.mjs
CHANGED
|
@@ -28,6 +28,20 @@ const undef = { t: 'undef' };
|
|
|
28
28
|
const nul = { t: 'nul' };
|
|
29
29
|
const free = (why) => ({ t: 'free', why });
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* JavaScript's abstract equality (`==`) for the literal kinds this graph forces - string, number, boolean - without
|
|
33
|
+
* the `==` operator: one type compares strictly, a boolean compares as its number, and a number meets a string as
|
|
34
|
+
* Number(string), exactly as the language coerces them (so '' == 0, '1' == true, NaN never equals).
|
|
35
|
+
*/
|
|
36
|
+
export function looseLiteralEqual(x, y) {
|
|
37
|
+
if (typeof x === typeof y) return x === y;
|
|
38
|
+
if (typeof x === 'boolean') return looseLiteralEqual(Number(x), y);
|
|
39
|
+
if (typeof y === 'boolean') return looseLiteralEqual(x, Number(y));
|
|
40
|
+
if (typeof x === 'number' && typeof y === 'string') return x === Number(y);
|
|
41
|
+
if (typeof x === 'string' && typeof y === 'number') return Number(x) === y;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
export function createGraphReader({ ts, host }) {
|
|
32
46
|
const files = new Map();
|
|
33
47
|
const classes = new Map();
|
|
@@ -194,7 +208,7 @@ export function createGraphReader({ ts, host }) {
|
|
|
194
208
|
const nullish = (v) => v.t === 'undef' || v.t === 'nul';
|
|
195
209
|
if (nullish(a) && nullish(b)) return loose ? true : a.t === b.t;
|
|
196
210
|
if (nullish(a) || nullish(b)) return ['lit', 'class', 'obj', 'arr'].includes(nullish(a) ? b.t : a.t) ? false : null;
|
|
197
|
-
if (a.t === 'lit' && b.t === 'lit') return loose ? a.v
|
|
211
|
+
if (a.t === 'lit' && b.t === 'lit') return loose ? looseLiteralEqual(a.v, b.v) : a.v === b.v;
|
|
198
212
|
if (a.t === 'class' && b.t === 'class') return a === b;
|
|
199
213
|
return null;
|
|
200
214
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// that the report was uploaded; the server computes the analysis afterwards. This reads the task the scanner wrote
|
|
3
3
|
// (`.scannerwork/report-task.txt`), polls `api/ce/task` until the compute-engine task ends, then reads the quality gate of that
|
|
4
4
|
// analysis from `api/qualitygates/project_status` with the same token. OK passes; ERROR is red and names every failed condition;
|
|
5
|
-
// a task that ends FAILED or CANCELED, an unreadable answer and a deadline passed are typed refusals in the row's detail.
|
|
5
|
+
// a task that ends FAILED or CANCELED, an unreadable answer and a deadline passed are typed refusals in the row's detail. A transient
|
|
6
|
+
// read failure (a network error or an HTTP 5xx) is retried with a bounded backoff inside the same wait before it is unreadable.
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
|
|
@@ -21,13 +22,36 @@ function reportTask(cwd) {
|
|
|
21
22
|
return Object.fromEntries(text.split(/\r?\n/u).map((line) => line.split('=')).filter(([key, ...rest]) => key && rest.length).map(([key, ...rest]) => [key.trim(), rest.join('=').trim()]));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/** The bound of a transient read failure: attempts per request and the first pause, doubled after each retry (milliseconds). */
|
|
26
|
+
const TRANSIENT_RETRY = Object.freeze({ attempts: 5, firstPause: 2_000 });
|
|
27
|
+
|
|
28
|
+
/** One authenticated JSON GET; a failed request or a non-JSON body throws. A network error or an HTTP 5xx is marked transient. */
|
|
29
|
+
async function getJsonOnce(ctx, url, token) {
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await ctx.fetch(url, { headers: { Authorization: `Basic ${Buffer.from(`${token}:`).toString('base64')}` }, signal: AbortSignal.timeout(30_000) });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw Object.assign(new Error(`${String(error?.message ?? error)} (${url.split('?')[0]})`), { transient: true });
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status} from ${url.split('?')[0]}`), { transient: response.status >= 500 });
|
|
28
37
|
return response.json();
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
/**
|
|
41
|
+
* getJsonOnce, retried only when the failure is transient (a network error or an HTTP 5xx): at most TRANSIENT_RETRY.attempts
|
|
42
|
+
* tries, pausing 2 s, 4 s, 8 s, ... between them, and never past `deadline`. A 4xx (401 and 403 included) and an unreadable body
|
|
43
|
+
* throw at once; the last transient failure throws as itself, so a retry never turns an unreadable answer into a verdict.
|
|
44
|
+
*/
|
|
45
|
+
async function getJson(ctx, url, token, { sleep, deadline }) {
|
|
46
|
+
for (let attempt = 1, pause = TRANSIENT_RETRY.firstPause; ; attempt += 1, pause *= 2) {
|
|
47
|
+
try { return await getJsonOnce(ctx, url, token); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!error.transient || attempt >= TRANSIENT_RETRY.attempts || ctx.now() + pause > deadline) throw error;
|
|
50
|
+
await sleep(pause);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
/** The failed conditions of a quality gate answer, as `metric comparator threshold (actual value)` lines. */
|
|
32
56
|
const failedConditions = (status) => (status?.conditions ?? []).filter((condition) => condition.status === 'ERROR')
|
|
33
57
|
.map((condition) => `${condition.metricKey} ${condition.comparator ?? ''} ${condition.errorThreshold ?? ''} (actual ${condition.actualValue ?? '?'})`.replace(/\s+/gu, ' '));
|
|
@@ -42,10 +66,11 @@ export async function sonarQualityGate(ctx, { cwd, host, token, wait = QUALITY_G
|
|
|
42
66
|
const server = String(task.serverUrl || host).replace(/\/$/u, '');
|
|
43
67
|
const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
44
68
|
const deadline = ctx.now() + wait.deadline;
|
|
69
|
+
const retry = { sleep, deadline };
|
|
45
70
|
let analysisId = null;
|
|
46
71
|
try {
|
|
47
72
|
for (;;) {
|
|
48
|
-
const state = (await getJson(ctx, `${server}/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`, token))?.task;
|
|
73
|
+
const state = (await getJson(ctx, `${server}/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`, token, retry))?.task;
|
|
49
74
|
if (DONE.has(state?.status)) {
|
|
50
75
|
if (state.status !== 'SUCCESS') return { pass: false, detail: `${SONAR_GATE_FAILED}: compute-engine task ${task.ceTaskId} ended ${state.status}${state.errorMessage ? `: ${state.errorMessage}` : ''}` };
|
|
51
76
|
analysisId = state.analysisId;
|
|
@@ -55,7 +80,7 @@ export async function sonarQualityGate(ctx, { cwd, host, token, wait = QUALITY_G
|
|
|
55
80
|
await sleep(wait.interval);
|
|
56
81
|
}
|
|
57
82
|
const query = analysisId ? `analysisId=${encodeURIComponent(analysisId)}` : `projectKey=${encodeURIComponent(task.projectKey ?? '')}`;
|
|
58
|
-
const status = (await getJson(ctx, `${server}/api/qualitygates/project_status?${query}`, token))?.projectStatus;
|
|
83
|
+
const status = (await getJson(ctx, `${server}/api/qualitygates/project_status?${query}`, token, retry))?.projectStatus;
|
|
59
84
|
if (status?.status === 'OK') return { pass: true, detail: 'exit 0, quality gate OK' };
|
|
60
85
|
if (status?.status === 'ERROR') {
|
|
61
86
|
const conditions = failedConditions(status);
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.29
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.28
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -50,7 +50,7 @@ pins:
|
|
|
50
50
|
source: packages/stylelint/package.json
|
|
51
51
|
why: 'the front-end stylelint canon: no-class-selector refuses a compound class selector (`div.card`); the brand layer accepts `--font-sans` and `--font-mono`; status-contrast measures the HeroUI soft pairs per theme; brand-layer-shape reads the shared :root,.light,.dark block and the info soft pair; appTokens come from loadAppTokens(import.meta.url) for the managed one-line stylelint.config.mjs.'
|
|
52
52
|
'@starci/tsconfig':
|
|
53
|
-
version: 2.0.
|
|
53
|
+
version: 2.0.5
|
|
54
54
|
group: starci
|
|
55
55
|
install: registry
|
|
56
56
|
side: both
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.22
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.22
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
$schema: https://json-schema.org/draft/2020-12/schema
|
|
2
|
+
$id: urn:starci:provision-operation:1
|
|
3
|
+
title: Target-bound provider operations
|
|
4
|
+
description: Public target and custody references for executable provider adapters; values never enter this declaration.
|
|
5
|
+
type: object
|
|
6
|
+
additionalProperties: false
|
|
7
|
+
required: [targets]
|
|
8
|
+
properties:
|
|
9
|
+
targets:
|
|
10
|
+
type: object
|
|
11
|
+
minProperties: 1
|
|
12
|
+
additionalProperties: {$ref: '#/$defs/target'}
|
|
13
|
+
$defs:
|
|
14
|
+
goalGrants:
|
|
15
|
+
type: object
|
|
16
|
+
additionalProperties: false
|
|
17
|
+
required: [schema, grants]
|
|
18
|
+
properties:
|
|
19
|
+
schema: {const: 'starci/provision-grants@1'}
|
|
20
|
+
grants:
|
|
21
|
+
type: array
|
|
22
|
+
items:
|
|
23
|
+
type: object
|
|
24
|
+
additionalProperties: false
|
|
25
|
+
required: [op, operation, digest]
|
|
26
|
+
properties:
|
|
27
|
+
op: {type: string, pattern: '^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$'}
|
|
28
|
+
operation: {type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$'}
|
|
29
|
+
digest: {type: string, pattern: '^[a-f0-9]{64}$'}
|
|
30
|
+
credential:
|
|
31
|
+
oneOf:
|
|
32
|
+
- {$ref: '#/$defs/serviceCredential'}
|
|
33
|
+
- {$ref: '#/$defs/hostCredential'}
|
|
34
|
+
serviceCredential:
|
|
35
|
+
$ref: application-stacks.schema.yaml#/$defs/serviceCredential
|
|
36
|
+
hostCredential:
|
|
37
|
+
description: >-
|
|
38
|
+
A fixed host bridge for the admin role, never a value. sonar-selfhost-admin is the native self-hosted Sonar administrator;
|
|
39
|
+
sonar-runtime-secret-env is the variable `env` of the runtime's untracked secret.env (the reader `starci gate sonar up` uses),
|
|
40
|
+
for a SonarCloud target only.
|
|
41
|
+
oneOf:
|
|
42
|
+
- type: object
|
|
43
|
+
additionalProperties: false
|
|
44
|
+
required: [id, hostConnection]
|
|
45
|
+
properties:
|
|
46
|
+
id: {const: admin}
|
|
47
|
+
hostConnection: {const: sonar-selfhost-admin}
|
|
48
|
+
- type: object
|
|
49
|
+
additionalProperties: false
|
|
50
|
+
required: [id, hostConnection, env]
|
|
51
|
+
properties:
|
|
52
|
+
id: {const: admin}
|
|
53
|
+
hostConnection: {const: sonar-runtime-secret-env}
|
|
54
|
+
env: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}
|
|
55
|
+
purpose: {type: string, minLength: 1}
|
|
56
|
+
target:
|
|
57
|
+
type: object
|
|
58
|
+
additionalProperties: false
|
|
59
|
+
required: [provider, context, resource, credentials]
|
|
60
|
+
oneOf:
|
|
61
|
+
- required: [origin]
|
|
62
|
+
properties:
|
|
63
|
+
provider: {enum: [supabase-cloud, supabase-auth, sonar-server, sonar-community, sonar-cloud, app, github, ghcr]}
|
|
64
|
+
endpoint: false
|
|
65
|
+
- required: [endpoint]
|
|
66
|
+
properties:
|
|
67
|
+
provider: {const: app-stack}
|
|
68
|
+
origin: false
|
|
69
|
+
endpoint: {$ref: '#/$defs/swarmEndpoint'}
|
|
70
|
+
- required: [endpoint]
|
|
71
|
+
properties:
|
|
72
|
+
provider: {const: app-db}
|
|
73
|
+
origin: false
|
|
74
|
+
endpoint: {$ref: '#/$defs/databaseEndpoint'}
|
|
75
|
+
oneOf:
|
|
76
|
+
- properties:
|
|
77
|
+
context: {const: local}
|
|
78
|
+
endpoint:
|
|
79
|
+
properties:
|
|
80
|
+
mode: {const: local}
|
|
81
|
+
- properties:
|
|
82
|
+
endpoint:
|
|
83
|
+
properties:
|
|
84
|
+
mode: {enum: [linked, url-env]}
|
|
85
|
+
properties:
|
|
86
|
+
provider:
|
|
87
|
+
enum: [supabase-cloud, supabase-auth, sonar-server, sonar-community, sonar-cloud, app, github, ghcr, app-stack, app-db]
|
|
88
|
+
context:
|
|
89
|
+
enum: [local, development, staging, production]
|
|
90
|
+
origin:
|
|
91
|
+
type: string
|
|
92
|
+
pattern: '^https?://[^\s]+$'
|
|
93
|
+
description: Auth-free origin; the resolver permits HTTP only for an explicitly local loopback target.
|
|
94
|
+
endpoint:
|
|
95
|
+
type: object
|
|
96
|
+
description: Closed native endpoint selected by app-stack or app-db; public declaration checks precede custody and the effective private endpoint is compared before network access.
|
|
97
|
+
resource:
|
|
98
|
+
type: object
|
|
99
|
+
description: Adapter-owned public configuration; each adapter refuses fields outside its action contract.
|
|
100
|
+
credentials:
|
|
101
|
+
type: array
|
|
102
|
+
items: {$ref: '#/$defs/credential'}
|
|
103
|
+
description: Named roles using the existing service credential contract; ids become the execution role map.
|
|
104
|
+
optionalCredentials:
|
|
105
|
+
type: array
|
|
106
|
+
uniqueItems: true
|
|
107
|
+
items: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
|
|
108
|
+
description: Declared roles whose absence may be inspected without substituting another credential.
|
|
109
|
+
destination:
|
|
110
|
+
$ref: '#/$defs/credential'
|
|
111
|
+
actions:
|
|
112
|
+
type: array
|
|
113
|
+
minItems: 1
|
|
114
|
+
uniqueItems: true
|
|
115
|
+
items: {type: string, pattern: '^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$'}
|
|
116
|
+
description: Exact action ids allowed for this target; the catalog and execution owner enforce the selected action.
|
|
117
|
+
swarmEndpoint:
|
|
118
|
+
type: object
|
|
119
|
+
additionalProperties: false
|
|
120
|
+
required: [kind, environment, stack, host, user, port]
|
|
121
|
+
properties:
|
|
122
|
+
kind: {const: docker-swarm}
|
|
123
|
+
environment: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
|
|
124
|
+
stack: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
|
|
125
|
+
host: {type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9.-]*$'}
|
|
126
|
+
user: {type: string, pattern: '^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$'}
|
|
127
|
+
port: {const: 22}
|
|
128
|
+
databaseEndpoint:
|
|
129
|
+
type: object
|
|
130
|
+
additionalProperties: false
|
|
131
|
+
required: [kind, mode, environment]
|
|
132
|
+
oneOf:
|
|
133
|
+
- required: [localProject, port]
|
|
134
|
+
properties:
|
|
135
|
+
mode: {const: local}
|
|
136
|
+
projectRef: false
|
|
137
|
+
urlEnv: false
|
|
138
|
+
host: false
|
|
139
|
+
database: false
|
|
140
|
+
user: false
|
|
141
|
+
- required: [projectRef]
|
|
142
|
+
properties:
|
|
143
|
+
mode: {const: linked}
|
|
144
|
+
localProject: false
|
|
145
|
+
port: false
|
|
146
|
+
urlEnv: false
|
|
147
|
+
host: false
|
|
148
|
+
database: false
|
|
149
|
+
user: false
|
|
150
|
+
- required: [urlEnv, host, port, database, user]
|
|
151
|
+
properties:
|
|
152
|
+
mode: {const: url-env}
|
|
153
|
+
localProject: false
|
|
154
|
+
projectRef: false
|
|
155
|
+
properties:
|
|
156
|
+
kind: {const: supabase-db}
|
|
157
|
+
mode: {enum: [local, linked, url-env]}
|
|
158
|
+
environment: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
|
|
159
|
+
localProject: {type: string, pattern: '^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$'}
|
|
160
|
+
projectRef: {type: string, pattern: '^[a-z0-9]{20}$'}
|
|
161
|
+
urlEnv: {type: string, pattern: '^[A-Z][A-Z0-9_]*$'}
|
|
162
|
+
host: {type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9.-]*$'}
|
|
163
|
+
port: {type: integer, minimum: 1, maximum: 65535}
|
|
164
|
+
database: {type: string, pattern: '^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$'}
|
|
165
|
+
user: {type: string, pattern: '^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$'}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/cli/gen-catalog.mjs — do not edit
|
|
2
2
|
export const CATALOG = {
|
|
3
3
|
"schema": "starci/cli-catalog@1",
|
|
4
|
-
"hash": "
|
|
4
|
+
"hash": "4018e220cb9abde27d6d8cd79cd502c4d0c64a5312d7549c8e631553a22cb878",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -1240,7 +1240,8 @@ export const CATALOG = {
|
|
|
1240
1240
|
"disabled or unavailable Sonar or an unset token refuses RELEASE_SONAR_UNAVAILABLE",
|
|
1241
1241
|
"a scanner exit 0 is not a pass: the row polls api/ce/task until the analysis ends, then reads api/qualitygates/project_status with the same token",
|
|
1242
1242
|
"OK passes; ERROR is red as RELEASE_SONAR_GATE_FAILED naming each failed condition, as is a FAILED or CANCELED task",
|
|
1243
|
-
"a task still running after 600 s is RELEASE_SONAR_GATE_TIMEOUT; no report-task, an HTTP
|
|
1243
|
+
"a task still running after 600 s is RELEASE_SONAR_GATE_TIMEOUT; no report-task, an HTTP 4xx or no gate is RELEASE_SONAR_GATE_UNREADABLE",
|
|
1244
|
+
"a network error or HTTP 5xx is retried up to 5 times with backoff inside the 600 s; still failing, it is RELEASE_SONAR_GATE_UNREADABLE",
|
|
1244
1245
|
"OWNER RULING candidate-ci (2026-10-18): the gate no longer reads the default-branch CI; the verdict is the candidate run of the sha",
|
|
1245
1246
|
"release.requireCi (default false, owner ruling release-require-ci-per-project) decides whether push waits for CI; the local gate is never skipped",
|
|
1246
1247
|
"requireCi false: push needs the tag and the green L4 record, runs the dry run, pushes atomically and states CI was not required",
|
|
@@ -10182,13 +10183,14 @@ export const CATALOG = {
|
|
|
10182
10183
|
"runtimeSide": "host"
|
|
10183
10184
|
},
|
|
10184
10185
|
"project": {
|
|
10185
|
-
"summary": "ensure a declared Sonar project through its provider adapter",
|
|
10186
|
+
"summary": "ensure a declared Sonar project, or move it to its declared key, through its provider adapter",
|
|
10186
10187
|
"owner": "runtime",
|
|
10187
10188
|
"positional": [
|
|
10188
10189
|
{
|
|
10189
10190
|
"name": "action",
|
|
10190
10191
|
"enum": [
|
|
10191
|
-
"ensure"
|
|
10192
|
+
"ensure",
|
|
10193
|
+
"rekey"
|
|
10192
10194
|
],
|
|
10193
10195
|
"required": true,
|
|
10194
10196
|
"variadic": false
|
|
@@ -10234,7 +10236,8 @@ export const CATALOG = {
|
|
|
10234
10236
|
},
|
|
10235
10237
|
"json": "always",
|
|
10236
10238
|
"examples": [
|
|
10237
|
-
"starci sonar project ensure --target quality --operation project-bootstrap"
|
|
10239
|
+
"starci sonar project ensure --target quality --operation project-bootstrap",
|
|
10240
|
+
"starci sonar project rekey --target quality --operation project-rename"
|
|
10238
10241
|
],
|
|
10239
10242
|
"editions": [
|
|
10240
10243
|
"full",
|
|
@@ -10252,7 +10255,9 @@ export const CATALOG = {
|
|
|
10252
10255
|
"owner"
|
|
10253
10256
|
],
|
|
10254
10257
|
"conventions": [
|
|
10255
|
-
"a target and existing accepted operation scope bind provider effects; execute grants no authority"
|
|
10258
|
+
"a target and existing accepted operation scope bind provider effects; execute grants no authority",
|
|
10259
|
+
"rekey moves the project at previousKey to projectKey (api/projects/update_key); its analyses, issues and quality gate stay with it",
|
|
10260
|
+
"rekey is verified when the new key reads back and the old key no longer does"
|
|
10256
10261
|
],
|
|
10257
10262
|
"operationPolicy": {
|
|
10258
10263
|
"ensure": {
|
|
@@ -10265,6 +10270,17 @@ export const CATALOG = {
|
|
|
10265
10270
|
"checkpoints": [
|
|
10266
10271
|
"account-authority"
|
|
10267
10272
|
]
|
|
10273
|
+
},
|
|
10274
|
+
"rekey": {
|
|
10275
|
+
"action": "sonar.project.rekey",
|
|
10276
|
+
"effect": "remote",
|
|
10277
|
+
"credentials": [
|
|
10278
|
+
"admin"
|
|
10279
|
+
],
|
|
10280
|
+
"destination": "none",
|
|
10281
|
+
"checkpoints": [
|
|
10282
|
+
"account-authority"
|
|
10283
|
+
]
|
|
10268
10284
|
}
|
|
10269
10285
|
},
|
|
10270
10286
|
"runtimeSide": "host"
|
|
@@ -3,6 +3,8 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { lsFiles } from '../api/git/ls-files.mjs';
|
|
5
5
|
import {parseYaml} from '../../engine/yaml.mjs';
|
|
6
|
+
import { validateAgainstSchema } from '../lib/json-schema.mjs';
|
|
7
|
+
import { PROVISION_OPERATION_SCHEMA_REF, provisionOperationSchema } from '../lib/provision-operation-schema.mjs';
|
|
6
8
|
import {slash} from '../lib/path-key.mjs';
|
|
7
9
|
import { isInside, walkFiles } from '../lib/walk.mjs';
|
|
8
10
|
import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../lib/swarm-stack.mjs';
|
|
@@ -92,7 +94,13 @@ function validateSchema(value){
|
|
|
92
94
|
if(shape.minItems&&node.length<shape.minItems)errors.push({path:at,message:'array too short'});
|
|
93
95
|
node.forEach((item,index)=>shape.items&&check(item,shape.items,`${at}/${index}`));
|
|
94
96
|
};
|
|
97
|
+
// The operations block refers to its own schema file: its owner's composed schema validates it, as the provision target owner does.
|
|
98
|
+
const checkOperations=(node,at)=>{
|
|
99
|
+
for(const error of validateAgainstSchema(node,provisionOperationSchema()))errors.push({path:at,message:error});
|
|
100
|
+
};
|
|
95
101
|
const check=(node,shape,at)=>{
|
|
102
|
+
if(shape?.$ref===PROVISION_OPERATION_SCHEMA_REF)return checkOperations(node,at);
|
|
103
|
+
if(shape?.$ref&&!shape.$ref.startsWith('#/')){errors.push({path:at,message:`unresolved schema reference ${shape.$ref}`});return;}
|
|
96
104
|
if(shape?.$ref)return check(node,resolve(shape.$ref),at);
|
|
97
105
|
if(Array.isArray(shape?.anyOf)){
|
|
98
106
|
checkAlternatives(node,shape,at,check);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// json-schema.mjs - a minimal draft-2020-12 JSON schema walker over the keywords the runtime's yaml schemas use.
|
|
2
|
+
import {isPlainObject} from '../../engine/plain-object.mjs';
|
|
3
|
+
|
|
4
|
+
// A minimal draft-2020-12 walker over the keywords op.schema.yaml uses. ajv is
|
|
5
|
+
// a devDependency; the runtime has no npm dependencies, so the walker is here.
|
|
6
|
+
/** Validate the supported schema keywords, including exact-one and at-least-one alternatives, without mutating input. */
|
|
7
|
+
export function validateAgainstSchema(value, schema) {
|
|
8
|
+
const errors = [];
|
|
9
|
+
const resolve = (ref) => String(ref).replace(/^#\//, '').split('/')
|
|
10
|
+
.reduce((node, key) => node?.[key.replaceAll('~1', '/').replaceAll('~0', '~')], schema);
|
|
11
|
+
const typeOk = (node, type) => {
|
|
12
|
+
if (Array.isArray(type)) return type.some((t) => typeOk(node, t));
|
|
13
|
+
if (type === 'object') return isPlainObject(node);
|
|
14
|
+
if (type === 'array') return Array.isArray(node);
|
|
15
|
+
if (type === 'integer') return Number.isInteger(node);
|
|
16
|
+
if (type === 'null') return node === null;
|
|
17
|
+
return typeof node === type;
|
|
18
|
+
};
|
|
19
|
+
const validateType = (node, shape, at) => {
|
|
20
|
+
if (shape.type !== undefined && !typeOk(node, shape.type)) {
|
|
21
|
+
errors.push(`${at}: expected ${Array.isArray(shape.type) ? shape.type.join('|') : shape.type}`);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
const validateString = (node, shape, at) => {
|
|
27
|
+
if (shape.minLength !== undefined && node.length < shape.minLength) errors.push(`${at}: empty or too short`);
|
|
28
|
+
if (shape.pattern && !new RegExp(shape.pattern, 'u').test(node)) errors.push(`${at}: ${JSON.stringify(node)} does not match ${shape.pattern}`);
|
|
29
|
+
};
|
|
30
|
+
const validateNumber = (node, shape, at) => {
|
|
31
|
+
if (shape.minimum !== undefined && node < shape.minimum) errors.push(`${at}: below minimum ${shape.minimum}`);
|
|
32
|
+
if (shape.maximum !== undefined && node > shape.maximum) errors.push(`${at}: above maximum ${shape.maximum}`);
|
|
33
|
+
};
|
|
34
|
+
const validateScalar = (node, shape, at) => {
|
|
35
|
+
if (Object.hasOwn(shape, 'const') && node !== shape.const) errors.push(`${at}: must be ${JSON.stringify(shape.const)}`);
|
|
36
|
+
if (Array.isArray(shape.enum) && !shape.enum.includes(node)) errors.push(`${at}: ${JSON.stringify(node)} is outside [${shape.enum.join(', ')}]`);
|
|
37
|
+
if (typeof node === 'string') validateString(node, shape, at);
|
|
38
|
+
if (typeof node === 'number') validateNumber(node, shape, at);
|
|
39
|
+
};
|
|
40
|
+
const validateArray = (node, shape, at, walk) => {
|
|
41
|
+
if (shape.minItems !== undefined && node.length < shape.minItems) errors.push(`${at}: needs at least ${shape.minItems} item(s)`);
|
|
42
|
+
if (shape.items) node.forEach((item, i) => walk(item, shape.items, `${at}[${i}]`));
|
|
43
|
+
};
|
|
44
|
+
const validateObject = (node, shape, at, walk) => {
|
|
45
|
+
if (shape.minProperties !== undefined && Object.keys(node).length < shape.minProperties) errors.push(`${at}: needs at least ${shape.minProperties} entr(y|ies)`);
|
|
46
|
+
for (const key of shape.required ?? []) if (!Object.hasOwn(node, key)) errors.push(`${at}: missing ${key}`);
|
|
47
|
+
for (const [key, child] of Object.entries(node)) {
|
|
48
|
+
const where = at === '$' ? `$.${key}` : `${at}.${key}`;
|
|
49
|
+
if (Object.hasOwn(shape.properties ?? {}, key)) walk(child, shape.properties[key], where);
|
|
50
|
+
else if (shape.additionalProperties === false) errors.push(`${where}: unknown key`);
|
|
51
|
+
else if (isPlainObject(shape.additionalProperties)) walk(child, shape.additionalProperties, where);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const validateAlternatives = (node, shape, at, walk) => {
|
|
55
|
+
for (const keyword of ['anyOf', 'oneOf']) {
|
|
56
|
+
if (!Object.hasOwn(shape, keyword)) continue;
|
|
57
|
+
const branches = shape[keyword];
|
|
58
|
+
if (!Array.isArray(branches) || branches.length === 0) { errors.push(`${at}: ${keyword} needs a nonempty schema list`); continue; }
|
|
59
|
+
const outcomes = branches.map((branch) => {
|
|
60
|
+
const start = errors.length;
|
|
61
|
+
walk(node, branch, at);
|
|
62
|
+
return errors.splice(start);
|
|
63
|
+
});
|
|
64
|
+
const matched = outcomes.filter((findings) => findings.length === 0).length;
|
|
65
|
+
if (keyword === 'oneOf' && matched > 1) errors.push(`${at}: oneOf matches more than one alternative`);
|
|
66
|
+
else if (matched === 0) {
|
|
67
|
+
errors.push(`${at}: ${keyword} matches no alternative`);
|
|
68
|
+
// The nearest branch explains a refusal without mixing findings from mutually exclusive shapes.
|
|
69
|
+
const nearest = outcomes.reduce((best, findings) => findings.length < best.length ? findings : best, outcomes[0]);
|
|
70
|
+
errors.push(...nearest);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const walk = (node, shape, at) => {
|
|
75
|
+
if (shape === true) return;
|
|
76
|
+
if (shape === false) { errors.push(`${at}: schema refuses this value`); return; }
|
|
77
|
+
if (!isPlainObject(shape)) { errors.push(`${at}: schema is invalid`); return; }
|
|
78
|
+
if (shape.$ref) {
|
|
79
|
+
const referenced = String(shape.$ref).startsWith('#/') ? resolve(shape.$ref) : undefined;
|
|
80
|
+
if (referenced === undefined) { errors.push(`${at}: schema reference is unresolved`); return; }
|
|
81
|
+
walk(node, referenced, at);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
validateAlternatives(node, shape, at, walk);
|
|
85
|
+
if (!validateType(node, shape, at)) return;
|
|
86
|
+
validateScalar(node, shape, at);
|
|
87
|
+
if (Array.isArray(node)) {
|
|
88
|
+
validateArray(node, shape, at, walk);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!isPlainObject(node)) return;
|
|
92
|
+
validateObject(node, shape, at, walk);
|
|
93
|
+
};
|
|
94
|
+
walk(value, schema, '$');
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The schema of a stack declaration's `operations` block, composed once for its two readers: the provision target owner and the
|
|
2
|
+
// stacks gate. provision-operation.schema.yaml refers to the service credential of application-stacks.schema.yaml; the composition
|
|
3
|
+
// inlines that definition so the self-contained walker of json-schema.mjs resolves every reference.
|
|
4
|
+
// Both schemas are read through `new URL(<literal>, import.meta.url)`: that is the form sync-runtime follows, so the copy
|
|
5
|
+
// bundled in @starci/hfs carries them beside this module (a skillRoot join left provision-operation.schema.yaml out of it).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
8
|
+
|
|
9
|
+
/** The `$ref` application-stacks.schema.yaml uses for its `operations` block. */
|
|
10
|
+
export const PROVISION_OPERATION_SCHEMA_REF = 'provision-operation.schema.yaml';
|
|
11
|
+
|
|
12
|
+
/** The composed `operations` schema, read from this runtime's modules/schemas. */
|
|
13
|
+
export function provisionOperationSchema() {
|
|
14
|
+
const schema = parseYaml(fs.readFileSync(new URL('../../modules/schemas/provision-operation.schema.yaml', import.meta.url), 'utf8'));
|
|
15
|
+
const stacks = parseYaml(fs.readFileSync(new URL('../../modules/schemas/application-stacks.schema.yaml', import.meta.url), 'utf8'));
|
|
16
|
+
return { ...schema, $defs: { ...schema.$defs, serviceCredential: stacks.$defs.serviceCredential } };
|
|
17
|
+
}
|
package/sync/index.mjs
CHANGED
|
@@ -340,7 +340,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
340
340
|
nodeMajor: String(NODE_MAJOR),
|
|
341
341
|
sonarKey: sonarKey ?? app.project,
|
|
342
342
|
sonarHostLine: sonarHostLine(quality.sonar),
|
|
343
|
-
sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**',
|
|
343
|
+
sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
|
|
344
344
|
sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
|
|
345
345
|
lcovReport: LCOV_REPORT,
|
|
346
346
|
...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality, { app }),
|
|
@@ -7,7 +7,7 @@ git diff --cached --name-only --diff-filter=ACMR -z --relative=be -- '*.ts' '*.m
|
|
|
7
7
|
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd be && npx eslint -- "$@"; fi' sh || exit $?
|
|
8
8
|
git diff --cached --name-only --diff-filter=ACMR -z --relative=fe -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' |
|
|
9
9
|
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd fe && npx eslint --max-warnings=0 --no-warn-ignored -- "$@"; fi' sh || exit $?
|
|
10
|
-
styles=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.css' ||
|
|
10
|
+
styles=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.css') || exit $?
|
|
11
11
|
if [ -n "$styles" ]; then
|
|
12
12
|
(cd fe && npx stylelint $styles)
|
|
13
13
|
fi
|
|
@@ -3,7 +3,8 @@ import type { MessageBundle } from "@modules/platform/i18n"
|
|
|
3
3
|
/** Display text of the identity codes, Vietnamese and English. */
|
|
4
4
|
export const IDENTITY_MESSAGES: MessageBundle = {
|
|
5
5
|
vi: {
|
|
6
|
-
"errors.IDENTITY_UNAUTHENTICATED":
|
|
6
|
+
"errors.IDENTITY_UNAUTHENTICATED":
|
|
7
|
+
"B\u1ea1n c\u1ea7n \u0111\u0103ng nh\u1eadp \u0111\u1ec3 th\u1ef1c hi\u1ec7n thao t\u00e1c n\u00e0y.",
|
|
7
8
|
},
|
|
8
9
|
en: {
|
|
9
10
|
"errors.IDENTITY_UNAUTHENTICATED": "You need to sign in to do this.",
|
package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { MessageBundle } from "@modules/platform/i18n"
|
|
|
3
3
|
/** Display text of the http-security codes, Vietnamese and English. */
|
|
4
4
|
export const HTTP_SECURITY_MESSAGES: MessageBundle = {
|
|
5
5
|
vi: {
|
|
6
|
-
"errors.HTTP_SECURITY_REQUIRED_DEPENDENCY_UNAVAILABLE":
|
|
6
|
+
"errors.HTTP_SECURITY_REQUIRED_DEPENDENCY_UNAVAILABLE":
|
|
7
|
+
"C\u01a1 s\u1edf d\u1eef li\u1ec7u b\u1eaft bu\u1ed9c hi\u1ec7n kh\u00f4ng kh\u1ea3 d\u1ee5ng.",
|
|
7
8
|
"errors.HTTP_SECURITY_ORIGIN_REJECTED":
|
|
8
9
|
"Ngu\u1ed3n g\u1eedi y\u00eau c\u1ea7u kh\u00f4ng \u0111\u01b0\u1ee3c ph\u00e9p.",
|
|
9
10
|
"errors.HTTP_SECURITY_RATE_LIMITED":
|