@nasebanal/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,16 +32,13 @@ small summary (counts, coverage-free metrics, the first failures, and for JUnit
32
32
  the per-test cases) and reports it to a NASEBANAL Assurance project. The same line works in a pipeline and on a
33
33
  laptop; in CI it needs only a token:
34
34
 
35
- Only these tool / kind pairs are accepted; anything else is refused before
36
- anything is read or sent (the API enforces the same list):
37
-
38
- | `--kind` | `--tool` | Report format |
39
- |---|---|---|
40
- | `unit` | `vitest`, `pytest` | JUnit XML |
41
- | `e2e` | `playwright` | JUnit XML |
42
- | `contract` | `specmatic` | JUnit XML |
43
- | `security` | `zap` | JUnit XML |
44
- | `load` | `locust` | Locust CSV |
35
+ The **kind** is one of a fixed set of perspectives (`unit`, `e2e`, `contract`,
36
+ `security`, `load`); the **tool** is a free label (`vitest`, `pytest`, `playwright`,
37
+ `specmatic`, `zap`, `locust`, or `trivy`, `k6`, `cypress`...). What a result
38
+ means is carried by its kind and its numbers, so a scanner or runner can be swapped
39
+ without a change in Assurance. The CLI reads **JUnit XML** (any tool) and
40
+ **Locust CSV**; for a tool that writes something else, convert it to JUnit or give the
41
+ numbers with `--metric` (for a scanner: `critical`, `high`, `medium`, `low`, `info`).
45
42
 
46
43
  ```bash
47
44
  export NB_TOKEN=nbpat_… # a PAT or service-account token (no browser needed)
@@ -50,6 +47,17 @@ export NB_TOKEN=nbpat_… # a PAT or service-account token (no browser ne
50
47
  nb assurance report upload --file report/junit.xml --kind unit --tool pytest
51
48
  nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --suite web
52
49
 
50
+ # Specmatic: the JUnit XML, plus its HTML report for the API coverage (coverage_pct = operations exercised / operations in the spec)
51
+ nb assurance report upload --file specmatic/junit/TEST-junit-jupiter.xml --kind contract --tool specmatic \
52
+ --coverage-report specmatic/report/test/html/index.html
53
+
54
+ # One repository, several API versions: label the result (the version is part of the suite's identity, so v1 and v2 of the same name sit side by side)
55
+ nb assurance report upload --file specmatic/v2/junit.xml --kind contract --tool specmatic --api-version v2
56
+ # unit tests hold for every version: leave --api-version out
57
+
58
+ # Extra numbers for the suite (repeatable), e.g. ZAP's findings by severity - what the Assurance security gate reads
59
+ nb assurance report upload --file zap/report/baseline-junit.xml --kind security --tool zap --metric high=0 --metric medium=3 --metric low=2
60
+
53
61
  # Locust (--csv <prefix>): the *_stats.csv, with its *_failures / *_exceptions / *_stats_history siblings
54
62
  nb assurance report upload --dir locust/logs/20261001_090000
55
63
  ```
@@ -108,27 +116,67 @@ release bundles a fixed set of API contract versions, recorded in
108
116
  ### Releasing (maintainers)
109
117
 
110
118
  Published to npmjs.org through npm **trusted publishing** (OIDC): no npm token is
111
- stored anywhere. The `Release` workflow (`.github/workflows/release.yml`) is
112
- registered as this package's Trusted Publisher on npmjs.com (repo
113
- `nasebanal/nb-cli`, workflow `release.yml`, no environment).
114
-
115
- 1. Bump `version` in `package.json` (PR to `main`, squash-merge). If the pinned
116
- contracts changed, update `spec-versions.json` in the same PR — the specs host
117
- serves only the latest version, so a stale pin fails `sync-specs` with a 404.
118
- Check https://api-specs.nasebanal.com/specs/released.json
119
- 2. Tag the merge commit: `git tag vX.Y.Z && git push origin vX.Y.Z`. The tag must
120
- equal `package.json`'s version or the workflow fails.
121
- 3. The workflow type-checks, tests and runs `npm publish --provenance`.
119
+ stored anywhere, and none is needed to run the release script - what gates a release is
120
+ **write access to this repository** (the script pushes a branch and a tag; the `Release`
121
+ workflow then publishes, and npm accepts that only from this repo's `release.yml`). It does
122
+ not work from a fork.
123
+
124
+ Two commands, split by the PR merge (which stays yours; the script never merges):
125
+
126
+ ```bash
127
+ bin/release.sh prepare 0.3.0 # before the merge: checks, version bump, tests, opens the release PR
128
+ # ... review and merge the PR ...
129
+ git checkout main && git pull
130
+ bin/release.sh publish # after the merge: tags v0.3.0 (asks first), waits for the workflow, checks npm
131
+ ```
132
+
133
+ Add `--dry-run` to either to do every check and print what it would do, writing nothing.
134
+
135
+ What it checks, and why:
136
+
137
+ - **Pinned contracts equal `released.json`.** The specs host serves only the latest
138
+ version, so a stale `spec-versions.json` fails `sync-specs` with a 404 (it happened for
139
+ `account` 1.4.0 -> 1.5.0). `prepare` stops and lists the differences;
140
+ `prepare <version> --sync-pins` adopts the released versions in the release PR.
141
+ Check https://api-specs.nasebanal.com/specs/released.json
142
+ - **Tag equals `package.json`'s version**, or the workflow fails.
143
+ - **The version is newer than `main`'s and not on npm yet**, and the tag does not exist.
144
+ - If the tag is pushed but the version is not on npm (the workflow failed), `publish` says
145
+ so and prints the `gh run rerun <id> --failed` to carry on - **do not tag again**.
146
+
147
+ By hand (what the script does): bump `version` in `package.json` and `package-lock.json`
148
+ (PR to `main`, squash-merge), tag the merge commit (`git tag vX.Y.Z && git push origin vX.Y.Z`),
149
+ and the workflow type-checks, tests and runs `npm publish --provenance`.
150
+
151
+ #### One-time setup: the Trusted Publisher on npmjs.com
152
+
153
+ Trusted publishing must be switched on by hand, once per package, by a maintainer of
154
+ `@nasebanal/cli` (npm only allows it for a package that already exists, so the first version,
155
+ 0.1.0, was published by hand). On https://www.npmjs.com/package/@nasebanal/cli/access ->
156
+ **Trusted Publisher** -> **GitHub Actions**:
157
+
158
+ | Field | Value |
159
+ |---|---|
160
+ | Organization or user | `nasebanal` |
161
+ | Repository | `nb-cli` |
162
+ | Workflow filename | `release.yml` (the file name only, exact case) |
163
+ | Environment name | empty (the workflow uses none) |
164
+ | Allowed actions | **tick "Allow `npm publish`"**; leave "Allow `npm dist-tag`" unticked |
165
+
166
+ Without that tick the publish fails with `npm error 404 ... could not be found or you do not
167
+ have permission` on the `PUT` - npm answers 404 for "not allowed", which reads like a missing
168
+ package. (This is how the first run of the workflow, for 0.2.0, failed: the setup was not done.)
122
169
 
123
170
  Notes:
124
171
 
125
172
  - A manual `npm publish` needs `--@nasebanal:registry=https://registry.npmjs.org/`
126
173
  if your `~/.npmrc` maps the `@nasebanal` scope to GitHub Packages (that mapping
127
174
  outranks `publishConfig.registry`; `publishConfig` also pins the scoped key as a
128
- safeguard). npm also requires a passkey (WebAuthn) 2FA for interactive publishes.
175
+ safeguard). npm also requires a passkey (WebAuthn) 2FA for interactive publishes. The
176
+ same override is needed for `npm view @nasebanal/cli` against the public registry.
129
177
  - A freshly published version can 404 on the package document for a few minutes
130
178
  while npm's cache catches up; `npm view` with `--prefer-online` or a retry is
131
- enough.
179
+ enough (the script retries for you).
132
180
 
133
181
  ### From source (contributors)
134
182
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Specmatic's API coverage -> the `coverage_pct` metric of a `contract` suite.
3
+ *
4
+ * `specmatic test` writes JUnit XML (the pass/fail counts) and an HTML report. The coverage - how many of
5
+ * the spec's operations a test actually exercised - is only in that report, as the JSON the page renders
6
+ * from (`const report = {...}`, a CTRF-style document: `results.summary.extra.executionDetails[]` with a
7
+ * `coverageMetrics` per spec). This reads the number out of either that HTML file or a plain JSON copy of
8
+ * the same document, so the spec-derived coverage reaches Assurance without the spec itself being uploaded.
9
+ *
10
+ * The HTML embedding is Specmatic's own, not a documented interface: when it changes the parse fails with
11
+ * a message naming the file, and the upload (without coverage) is still one flag away.
12
+ */
13
+ /** The end index (exclusive) of the JSON object that starts at `start`, honouring strings and escapes. */
14
+ function objectEnd(text, start) {
15
+ let depth = 0;
16
+ let inString = false;
17
+ for (let i = start; i < text.length; i++) {
18
+ const c = text[i];
19
+ if (inString) {
20
+ if (c === "\\")
21
+ i++;
22
+ else if (c === '"')
23
+ inString = false;
24
+ }
25
+ else if (c === '"')
26
+ inString = true;
27
+ else if (c === "{")
28
+ depth++;
29
+ else if (c === "}" && --depth === 0)
30
+ return i + 1;
31
+ }
32
+ throw new Error("unterminated JSON object");
33
+ }
34
+ function reportJson(text) {
35
+ const trimmed = text.trimStart();
36
+ if (trimmed.startsWith("{"))
37
+ return JSON.parse(trimmed);
38
+ const marker = "const report = ";
39
+ const at = text.indexOf(marker);
40
+ if (at < 0)
41
+ throw new Error("no embedded report found");
42
+ const start = at + marker.length;
43
+ return JSON.parse(text.slice(start, objectEnd(text, start)));
44
+ }
45
+ export function parseSpecmaticCoverage(text) {
46
+ let doc;
47
+ try {
48
+ doc = reportJson(text);
49
+ }
50
+ catch (err) {
51
+ throw new Error(`Cannot read the Specmatic report (${err instanceof Error ? err.message : String(err)})`);
52
+ }
53
+ const details = doc?.results?.summary?.extra?.executionDetails;
54
+ if (!Array.isArray(details) || details.length === 0) {
55
+ throw new Error("The Specmatic report has no executionDetails (was it written by `specmatic test`?)");
56
+ }
57
+ let covered = 0;
58
+ let total = 0;
59
+ for (const d of details) {
60
+ const m = d.coverageMetrics;
61
+ if (!m)
62
+ continue;
63
+ covered += m.coveredOperations ?? 0;
64
+ total += m.totalOperationsWithFilters ?? m.totalOperations ?? 0;
65
+ }
66
+ if (total <= 0)
67
+ throw new Error("The Specmatic report lists no operations to cover");
68
+ return { coverage_pct: Math.round((covered / total) * 1000) / 10, covered, total };
69
+ }
@@ -21,37 +21,61 @@ import { printJson } from "../output.js";
21
21
  import { detectCi } from "./ci.js";
22
22
  import { parseJunit } from "./junit.js";
23
23
  import { summariseLocust } from "./locust.js";
24
+ import { parseSpecmaticCoverage } from "./specmatic.js";
24
25
  export const SUITE_KINDS = ["unit", "e2e", "load", "contract", "security"];
25
26
  /**
26
- * The tools Assurance accepts, per kind of suite. The CLI filters here, on the CI
27
- * runner, so only reports that match are ever sent; the API enforces the same list
28
- * (nb-assurance-api `ALLOWED_TOOLS`) because the CLI is not the only possible client.
29
- * Keep the two in step with the `tool` enum in nb-api-specs' assurance package.
27
+ * The tool is a label (nb-assurance-api `TOOL_PATTERN`): what a result means is carried by its kind and its
28
+ * numbers, so any scanner or runner can be reported without a change here, as long as its report is
29
+ * JUnit XML (or the numbers are given with --metric). Only the formats this CLI can read matter.
30
30
  */
31
- export const ALLOWED_TOOLS = {
32
- unit: ["vitest", "pytest"],
33
- e2e: ["playwright"],
34
- contract: ["specmatic"],
35
- security: ["zap"],
36
- load: ["locust"],
37
- };
38
- /** Tools whose report is not JUnit XML. Every other allowed tool writes JUnit. */
31
+ const TOOL_PATTERN = /^[a-z0-9][a-z0-9._-]{0,49}$/;
32
+ /** Tools whose report is not JUnit XML; every other tool is read as JUnit. */
39
33
  const NON_JUNIT_TOOLS = { locust: "locust" };
40
34
  /**
41
35
  * Check a kind / tool / format combination before anything is parsed or sent.
42
- * Returns the normalised (lower-case) tool name; throws a message naming what is supported.
36
+ * Returns the normalised (lower-case) tool label; throws a message naming what is wrong.
43
37
  */
44
38
  export function checkSuite(kind, tool, format) {
45
39
  const name = tool.trim().toLowerCase();
46
- if (!ALLOWED_TOOLS[kind].includes(name)) {
47
- throw new Error(`Tool '${tool}' is not supported for kind '${kind}'. Supported: ${ALLOWED_TOOLS[kind].join(", ")}`);
40
+ if (!TOOL_PATTERN.test(name)) {
41
+ throw new Error(`Tool '${tool}' is not a label: use lower-case letters, digits, '.', '_' and '-' (up to 50), e.g. vitest or trivy`);
48
42
  }
49
43
  const expected = NON_JUNIT_TOOLS[name] ?? "junit";
50
44
  if (format !== expected) {
51
45
  throw new Error(`Tool '${name}' reports in ${expected} format, but --format is ${format}`);
52
46
  }
47
+ if (kind === "load" && name !== "locust" && format !== "junit")
48
+ throw new Error("Only locust reports are read as CSV");
53
49
  return name;
54
50
  }
51
+ /** The same label rule as the API (`utils/apiVersion.ts` in nb-assurance-api): checked here so a typo fails before anything is sent. */
52
+ const API_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
53
+ const METRIC_KEY = /^[a-z][a-z0-9_]{0,39}$/;
54
+ const MAX_METRICS = 20;
55
+ /** `--metric high=0 --metric medium=3`: extra numbers for the suite (the API keeps at most 20). */
56
+ export function parseMetrics(pairs) {
57
+ const out = {};
58
+ for (const pair of pairs) {
59
+ const eq = pair.indexOf("=");
60
+ const key = eq < 0 ? pair : pair.slice(0, eq).trim();
61
+ const raw = eq < 0 ? "" : pair.slice(eq + 1).trim();
62
+ const value = Number(raw);
63
+ if (!METRIC_KEY.test(key) || raw === "" || !Number.isFinite(value)) {
64
+ throw new Error(`--metric '${pair}' must be <key>=<number> with a snake_case key (e.g. high=0)`);
65
+ }
66
+ out[key] = value;
67
+ }
68
+ if (Object.keys(out).length > MAX_METRICS)
69
+ throw new Error(`At most ${MAX_METRICS} --metric values per suite`);
70
+ return out;
71
+ }
72
+ export function checkApiVersion(label) {
73
+ const v = label.trim();
74
+ if (!API_VERSION_PATTERN.test(v)) {
75
+ throw new Error(`--api-version '${label}' is not a version label: use letters, digits, '.', '_' and '-' (up to 32), e.g. v1 or 2026-10`);
76
+ }
77
+ return v;
78
+ }
55
79
  const MAX_FAILURES = 20;
56
80
  /** Per-test cases: the API accepts this many (MAX_CASES), each message up to 2000 characters. */
57
81
  export const MAX_CASES = 5000;
@@ -207,10 +231,13 @@ export function buildReportUploadCommand() {
207
231
  .description("Report a test result file (JUnit XML or Locust CSV) to an Assurance project")
208
232
  .option("--file <path>", "the report file (JUnit XML, or Locust *_stats.csv)")
209
233
  .option("--dir <dir>", "a Locust output directory (uses its *_stats.csv)")
234
+ .option("--coverage-report <path>", "specmatic: its report (the HTML or a JSON copy); adds the API coverage as the coverage_pct metric")
210
235
  .option("--format <format>", "junit | locust (default: from the file name)")
211
236
  .option("--kind <kind>", `what the suite proves: ${SUITE_KINDS.join(" | ")} (locust: load)`)
212
- .option("--tool <name>", "the producing tool, e.g. vitest, pytest, playwright, specmatic (locust: locust)")
237
+ .option("--tool <label>", "the producing tool, a free label, e.g. vitest, pytest, playwright, specmatic, zap, trivy (locust: locust)")
213
238
  .option("--suite <name>", "tells apart several suites of one kind in a run (e.g. api, web)")
239
+ .option("--metric <key=value>", "an extra number for the suite, repeatable (e.g. --metric high=0 --metric medium=3; for security: critical, high, medium, low, info)", (v, all = []) => [...all, v])
240
+ .option("--api-version <label>", "the API version the result is for (e.g. v1, v2), part of the suite's identity next to its kind and name; leave it out for results that hold for every version, such as unit tests")
214
241
  .option("--project <name>", "project name (CI: the repository); found, or created if missing")
215
242
  .option("--repo <repo>", "repository recorded on a newly created project (CI: detected)")
216
243
  .option("--run-key <key>", "identity of the pipeline run (CI: detected); all jobs of a run share it")
@@ -240,6 +267,7 @@ export function buildReportUploadCommand() {
240
267
  const maxFailRatio = Number(ratioRaw);
241
268
  if (!Number.isFinite(maxFailRatio) || maxFailRatio < 0 || maxFailRatio > 1)
242
269
  fail("--max-fail-ratio must be a number between 0 and 1");
270
+ const apiVersion = opts.apiVersion === undefined ? undefined : checkApiVersion(opts.apiVersion);
243
271
  const parsed = parseReport({
244
272
  format,
245
273
  kind,
@@ -249,6 +277,25 @@ export function buildReportUploadCommand() {
249
277
  maxFailRatio,
250
278
  file,
251
279
  });
280
+ if (opts.coverageReport !== undefined) {
281
+ if (kind !== "contract" || checkedTool !== "specmatic")
282
+ fail("--coverage-report is only for --kind contract --tool specmatic");
283
+ if (!existsSync(opts.coverageReport))
284
+ fail(`File not found: ${opts.coverageReport}`);
285
+ const cov = parseSpecmaticCoverage(readFileSync(opts.coverageReport, "utf8"));
286
+ parsed.suite.metrics = { ...parsed.suite.metrics, coverage_pct: cov.coverage_pct };
287
+ parsed.note += `, API coverage ${cov.coverage_pct}% (${cov.covered}/${cov.total} operations)`;
288
+ }
289
+ if (apiVersion !== undefined)
290
+ parsed.suite.api_version = apiVersion;
291
+ if (opts.metric !== undefined && opts.metric.length > 0) {
292
+ const extra = parseMetrics(opts.metric);
293
+ const merged = { ...parsed.suite.metrics, ...extra };
294
+ if (Object.keys(merged).length > MAX_METRICS)
295
+ fail(`At most ${MAX_METRICS} metrics per suite`);
296
+ parsed.suite.metrics = merged;
297
+ parsed.note += `, metrics ${Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(" ")}`;
298
+ }
252
299
  const ci = detectCi(process.env);
253
300
  const runKey = opts.runKey ?? ci.runKey ?? fail("Cannot determine the run key; pass --run-key");
254
301
  const project = opts.project ?? ci.project ?? fail("Cannot determine the project name; pass --project <name>");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nasebanal/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "gcloud-style command-line interface for NASEBANAL APIs, generated from nb-api-specs OpenAPI contracts.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,7 @@ openapi: 3.0.3
2
2
  info:
3
3
  title: NASEBANAL Account API
4
4
  description: Centralized user and billing management API for NASEBANAL applications. Manages user profiles, per-service subscriptions, Stripe billing, and Personal Access Tokens (PAT).
5
- version: 1.5.0
5
+ version: 1.5.1
6
6
  contact:
7
7
  name: NASEBANAL
8
8
  url: https://www.nasebanal.com
@@ -149,7 +149,14 @@ paths:
149
149
  application/json:
150
150
  schema:
151
151
  type: object
152
+ required:
153
+ - success
154
+ - data
152
155
  properties:
156
+ success:
157
+ type: boolean
158
+ enum:
159
+ - true
153
160
  data:
154
161
  type: object
155
162
  properties:
@@ -171,7 +178,14 @@ paths:
171
178
  application/json:
172
179
  schema:
173
180
  type: object
181
+ required:
182
+ - success
183
+ - data
174
184
  properties:
185
+ success:
186
+ type: boolean
187
+ enum:
188
+ - true
175
189
  data:
176
190
  type: array
177
191
  items:
@@ -197,7 +211,14 @@ paths:
197
211
  application/json:
198
212
  schema:
199
213
  type: object
214
+ required:
215
+ - success
216
+ - data
200
217
  properties:
218
+ success:
219
+ type: boolean
220
+ enum:
221
+ - true
201
222
  data:
202
223
  $ref: "#/components/schemas/ServiceSubscription"
203
224
  "401":
@@ -217,7 +238,14 @@ paths:
217
238
  application/json:
218
239
  schema:
219
240
  type: object
241
+ required:
242
+ - success
243
+ - data
220
244
  properties:
245
+ success:
246
+ type: boolean
247
+ enum:
248
+ - true
221
249
  data:
222
250
  type: object
223
251
  properties:
@@ -247,7 +275,14 @@ paths:
247
275
  application/json:
248
276
  schema:
249
277
  type: object
278
+ required:
279
+ - success
280
+ - data
250
281
  properties:
282
+ success:
283
+ type: boolean
284
+ enum:
285
+ - true
251
286
  data:
252
287
  allOf:
253
288
  - $ref: "#/components/schemas/ApiToken"
@@ -278,7 +313,14 @@ paths:
278
313
  application/json:
279
314
  schema:
280
315
  type: object
316
+ required:
317
+ - success
318
+ - data
281
319
  properties:
320
+ success:
321
+ type: boolean
322
+ enum:
323
+ - true
282
324
  data:
283
325
  type: object
284
326
  properties:
@@ -319,7 +361,14 @@ paths:
319
361
  application/json:
320
362
  schema:
321
363
  type: object
364
+ required:
365
+ - success
366
+ - data
322
367
  properties:
368
+ success:
369
+ type: boolean
370
+ enum:
371
+ - true
323
372
  data:
324
373
  $ref: "#/components/schemas/NotificationInbox"
325
374
  "401":
@@ -337,7 +386,14 @@ paths:
337
386
  application/json:
338
387
  schema:
339
388
  type: object
389
+ required:
390
+ - success
391
+ - data
340
392
  properties:
393
+ success:
394
+ type: boolean
395
+ enum:
396
+ - true
341
397
  data:
342
398
  type: object
343
399
  properties:
@@ -366,7 +422,14 @@ paths:
366
422
  application/json:
367
423
  schema:
368
424
  type: object
425
+ required:
426
+ - success
427
+ - data
369
428
  properties:
429
+ success:
430
+ type: boolean
431
+ enum:
432
+ - true
370
433
  data:
371
434
  type: object
372
435
  properties:
@@ -394,7 +457,14 @@ paths:
394
457
  application/json:
395
458
  schema:
396
459
  type: object
460
+ required:
461
+ - success
462
+ - data
397
463
  properties:
464
+ success:
465
+ type: boolean
466
+ enum:
467
+ - true
398
468
  data:
399
469
  type: object
400
470
  properties:
@@ -427,7 +497,14 @@ paths:
427
497
  application/json:
428
498
  schema:
429
499
  type: object
500
+ required:
501
+ - success
502
+ - data
430
503
  properties:
504
+ success:
505
+ type: boolean
506
+ enum:
507
+ - true
431
508
  data:
432
509
  type: object
433
510
  properties:
@@ -455,7 +532,14 @@ paths:
455
532
  application/json:
456
533
  schema:
457
534
  type: object
535
+ required:
536
+ - success
537
+ - data
458
538
  properties:
539
+ success:
540
+ type: boolean
541
+ enum:
542
+ - true
459
543
  data:
460
544
  type: object
461
545
  properties:
@@ -495,7 +579,14 @@ paths:
495
579
  application/json:
496
580
  schema:
497
581
  type: object
582
+ required:
583
+ - success
584
+ - data
498
585
  properties:
586
+ success:
587
+ type: boolean
588
+ enum:
589
+ - true
499
590
  data:
500
591
  $ref: "#/components/schemas/ServiceAccount"
501
592
  "401":
@@ -531,7 +622,14 @@ paths:
531
622
  application/json:
532
623
  schema:
533
624
  type: object
625
+ required:
626
+ - success
627
+ - data
534
628
  properties:
629
+ success:
630
+ type: boolean
631
+ enum:
632
+ - true
535
633
  data:
536
634
  $ref: "#/components/schemas/ServiceAccount"
537
635
  "401":
@@ -563,7 +661,14 @@ paths:
563
661
  application/json:
564
662
  schema:
565
663
  type: object
664
+ required:
665
+ - success
666
+ - data
566
667
  properties:
668
+ success:
669
+ type: boolean
670
+ enum:
671
+ - true
567
672
  data:
568
673
  type: object
569
674
  properties:
@@ -600,7 +705,14 @@ paths:
600
705
  application/json:
601
706
  schema:
602
707
  type: object
708
+ required:
709
+ - success
710
+ - data
603
711
  properties:
712
+ success:
713
+ type: boolean
714
+ enum:
715
+ - true
604
716
  data:
605
717
  type: object
606
718
  properties:
@@ -645,7 +757,14 @@ paths:
645
757
  application/json:
646
758
  schema:
647
759
  type: object
760
+ required:
761
+ - success
762
+ - data
648
763
  properties:
764
+ success:
765
+ type: boolean
766
+ enum:
767
+ - true
649
768
  data:
650
769
  allOf:
651
770
  - $ref: "#/components/schemas/ApiToken"
@@ -689,7 +808,14 @@ paths:
689
808
  application/json:
690
809
  schema:
691
810
  type: object
811
+ required:
812
+ - success
813
+ - data
692
814
  properties:
815
+ success:
816
+ type: boolean
817
+ enum:
818
+ - true
693
819
  data:
694
820
  type: object
695
821
  properties:
@@ -761,7 +887,14 @@ components:
761
887
  format: date-time
762
888
  UserResponse:
763
889
  type: object
890
+ required:
891
+ - success
892
+ - data
764
893
  properties:
894
+ success:
895
+ type: boolean
896
+ enum:
897
+ - true
765
898
  data:
766
899
  $ref: "#/components/schemas/User"
767
900
  UpdateProfileInput:
@@ -30,7 +30,7 @@ info:
30
30
 
31
31
  Publicly shared graphs can be embedded in external sites (blogs, portfolios, etc.) via iframe without authentication.
32
32
 
33
- **Endpoint:** `GET /shared/{lang}/{id}/embed`
33
+ **Endpoint:** `GET https://growth.nasebanal.com/{lang}/recorder/shared/{id}/embed` (served by NASEBANAL Growth, not by this API)
34
34
 
35
35
  | Parameter | Type | Required | Description |
36
36
  |-----------|------|----------|-------------|
@@ -43,11 +43,11 @@ info:
43
43
 
44
44
  **iframe example:**
45
45
  ```html
46
- <iframe src=\"https://recorder.nasebanal.com/shared/ja/{id}/embed\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>
46
+ <iframe src=\"https://growth.nasebanal.com/ja/recorder/shared/{id}/embed\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>
47
47
  ```
48
48
 
49
49
  **Note:** The embed displays a snapshot of the data at the time the SNS share was created. To update the data, recreate the SNS share.
50
- version: 1.5.0
50
+ version: 1.7.0
51
51
  license:
52
52
  name: MIT
53
53
  servers:
@@ -124,9 +124,19 @@ paths:
124
124
  parameters:
125
125
  - name: name
126
126
  in: query
127
- description: Filter by record name
127
+ description: "Filter by record name. A name is unique only per owner: a tag shared with you can have the same name as one of your own, and this then matches both. Use `tag_id` to pick exactly one tag."
128
128
  schema:
129
129
  type: string
130
+ - name: tag_id
131
+ in: query
132
+ description: Filter by tag id; repeat the parameter for several tags. An id names exactly one tag, so unlike `name` it cannot mix in a same-named tag of another owner. When `tag_id` is given, `name` is ignored. An id of a tag you cannot read (neither yours nor shared with you) matches nothing.
133
+ style: form
134
+ explode: true
135
+ schema:
136
+ type: array
137
+ items:
138
+ type: integer
139
+ minimum: 1
130
140
  - name: start_date
131
141
  in: query
132
142
  description: Filter by start date (ISO 8601)
@@ -1450,17 +1460,22 @@ paths:
1450
1460
  /records/stats:
1451
1461
  get:
1452
1462
  summary: Get record statistics
1453
- description: Get aggregated statistics for records with a specific name
1463
+ description: Get aggregated statistics for the records of one tag, picked by `name` or, unambiguously, by `tag_id`. Send one of the two.
1454
1464
  operationId: getRecordStats
1455
1465
  tags:
1456
1466
  - Statistics
1457
1467
  parameters:
1458
1468
  - name: name
1459
1469
  in: query
1460
- required: true
1461
- description: Record name to get statistics for
1470
+ description: Record name to get statistics for. A name is unique only per owner, so a tag shared with you can share it with one of your own; prefer `tag_id`.
1462
1471
  schema:
1463
1472
  type: string
1473
+ - name: tag_id
1474
+ in: query
1475
+ description: Id of the tag to get statistics for. When given, `name` is ignored. An id of a tag you cannot read matches nothing.
1476
+ schema:
1477
+ type: integer
1478
+ minimum: 1
1464
1479
  responses:
1465
1480
  "200":
1466
1481
  description: Statistics for the specified record name
@@ -2184,6 +2199,45 @@ components:
2184
2199
  - imported
2185
2200
  - created
2186
2201
  - updated
2202
+ SeriesKind:
2203
+ type: string
2204
+ enum:
2205
+ - raw
2206
+ - diff
2207
+ - diff_pct
2208
+ - sum
2209
+ - bmi
2210
+ - annual_income
2211
+ description: "Stable code of a derived series — not the display suffix (`/Diff` etc.). `diff` difference from the previous record, `diff_pct` percent change from the previous record, `sum` running total, `bmi` BMI (body-weight category), `annual_income` annual income (currency category). The raw tag is `raw`: it is the default on the referencing side and never appears in a `derived_series` list."
2212
+ DerivedSeries:
2213
+ type: object
2214
+ description: "A series derived from a tag. No row of its own: it is derived from the tag's settings (`derived_config`, `category`). Identified by the source tag's `id` plus `kind` — no name in it, so a rename cannot break a reference."
2215
+ properties:
2216
+ kind:
2217
+ $ref: "#/components/schemas/SeriesKind"
2218
+ label:
2219
+ type: string
2220
+ description: Display name (e.g. `Body Weight/BMI`). Not an identifier.
2221
+ unit:
2222
+ type: string
2223
+ nullable: true
2224
+ data_type:
2225
+ type: string
2226
+ enum:
2227
+ - decimal
2228
+ - integer
2229
+ - time
2230
+ chart_type:
2231
+ type: string
2232
+ enum:
2233
+ - line
2234
+ - smooth
2235
+ - step
2236
+ required:
2237
+ - kind
2238
+ - label
2239
+ - data_type
2240
+ - chart_type
2187
2241
  Tag:
2188
2242
  type: object
2189
2243
  properties:
@@ -2213,6 +2267,11 @@ components:
2213
2267
  is_hidden:
2214
2268
  type: integer
2215
2269
  description: 0 = visible, 1 = hidden
2270
+ derived_series:
2271
+ type: array
2272
+ description: Derived series this tag can produce. Excludes the raw series (`raw`).
2273
+ items:
2274
+ $ref: "#/components/schemas/DerivedSeries"
2216
2275
  data_type_options:
2217
2276
  type: array
2218
2277
  description: "Which `data_type` this tag may be changed to, and why not for the rest. A change is safe exactly when no stored value changes meaning, which depends on the direction and on what is already stored — so this is server-computed rather than re-derived per client, and it carries a reason per option because the reason differs per option. A tag shared in from another user lists only its current type, not selectable: the recipient does not own the master data."
@@ -2487,6 +2546,10 @@ components:
2487
2546
  virtual_tag_key:
2488
2547
  type: string
2489
2548
  nullable: true
2549
+ tag_kind:
2550
+ allOf:
2551
+ - $ref: "#/components/schemas/SeriesKind"
2552
+ description: Which series of the `tag_id` tag the goal targets. Absent or `raw` means the raw tag. Successor to `virtual_tag_key`, with no name in it.
2490
2553
  is_hidden:
2491
2554
  type: integer
2492
2555
  description: 0 = visible, 1 = hidden
@@ -2542,6 +2605,10 @@ components:
2542
2605
  virtual_tag_key:
2543
2606
  type: string
2544
2607
  description: Virtual tag key
2608
+ tag_kind:
2609
+ allOf:
2610
+ - $ref: "#/components/schemas/SeriesKind"
2611
+ description: Which series of the `tag_id` tag the goal targets. Absent or `raw` means the raw tag. Successor to `virtual_tag_key`, with no name in it.
2545
2612
  required:
2546
2613
  - tag_id
2547
2614
  - goal_type
@@ -2569,6 +2636,10 @@ components:
2569
2636
  type: string
2570
2637
  virtual_tag_key:
2571
2638
  type: string
2639
+ tag_kind:
2640
+ allOf:
2641
+ - $ref: "#/components/schemas/SeriesKind"
2642
+ description: Which series of the `tag_id` tag the goal targets. Absent or `raw` means the raw tag. Successor to `virtual_tag_key`, with no name in it.
2572
2643
  ErrorResponse:
2573
2644
  type: object
2574
2645
  properties:
package/specs/target.yaml CHANGED
@@ -2,7 +2,7 @@ openapi: 3.0.3
2
2
  info:
3
3
  title: NASEBANAL - Target API
4
4
  description: "Target API for NASEBANAL. Provides hierarchical target management: users define goals and organize them in a tree structure. Parent-child links are persisted via `targets.parent_target_id` (authoritative since migration 0017); a legacy cell-based storage layer is kept in sync best-effort for backward compatibility. Targets can be shared with other users (read/write, optionally including the subtree and linked Recorder data), and ownership of a personal target subtree can be transferred to a recipient via an email-approved migration flow. Supports user authentication via Auth0 JWT and Personal Access Tokens (PAT)."
5
- version: 1.4.1
5
+ version: 1.5.0
6
6
  contact:
7
7
  name: NASEBANAL
8
8
  url: https://www.nasebanal.com
@@ -1019,7 +1019,11 @@ components:
1019
1019
  recorder_tag:
1020
1020
  type: string
1021
1021
  nullable: true
1022
- description: Optional tag linking to the Recorder app
1022
+ description: Name of the linked Recorder tag, or a derived series key such as `Body Weight/BMI~` (a name, not a tag row, so it has no id). Kept alongside `recorder_tag_id` for derived keys and for clients that predate it.
1023
+ recorder_tag_id:
1024
+ type: integer
1025
+ nullable: true
1026
+ description: Id of the linked Recorder tag, for a real tag (never for a derived series key). Unlike the name it stays valid when the tag is renamed or its ownership is transferred, and it tells apart same-named tags of different owners. NULL for a target linked only by name; clients prefer the id when it is set and fall back to `recorder_tag`.
1023
1027
  recorder_goal_id:
1024
1028
  type: integer
1025
1029
  nullable: true
@@ -1362,7 +1366,11 @@ components:
1362
1366
  recorder_tag:
1363
1367
  type: string
1364
1368
  nullable: true
1365
- description: Tag to link with Recorder app
1369
+ description: Tag to link with Recorder app, by name. Send the name together with `recorder_tag_id` for a real tag; for a derived series key (`Body Weight/BMI~`) this is the only field.
1370
+ recorder_tag_id:
1371
+ type: integer
1372
+ nullable: true
1373
+ description: Id of the Recorder tag to link, for a real tag. Send it together with `recorder_tag`; send `null` for both to unlink.
1366
1374
  recorder_goal_id:
1367
1375
  type: integer
1368
1376
  nullable: true