@knowttl/gitea-axi 0.1.1 → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) knowttl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -97,6 +97,12 @@ gitea-axi config path
97
97
  gitea-axi doctor
98
98
  ```
99
99
 
100
+ Raw `api GET` contents reads support root and nested directories, returning directory arrays (including empty arrays) or individual file, symlink and submodule objects under `data`.
101
+ Nullable contents and link fields are accepted while non-null values retain their schema type checks.
102
+ Use literal slashes for nested paths such as `repos/OWNER/NAME/contents/src/main.ts`, `repos/OWNER/NAME/editorconfig/src/main.ts` and Git ref filters such as `repos/OWNER/NAME/git/refs/heads/main`.
103
+ This multi-segment support applies only to those GET routes; other route parameters and raw writes retain single-segment matching.
104
+ Encoded route separators and traversal remain refused.
105
+
100
106
  Anonymous reads require an explicit origin; repository selection follows the rules below.
101
107
  Anonymous HTTP reads require `--allow-insecure-http` for that selected origin.
102
108
  The tool follows no redirects and never imports tea or Git credentials.
@@ -107,10 +113,13 @@ An explicit repository wins over a matching local origin remote; a remote never
107
113
  `-R` aliases `--repo` after an explicit command, with the same duplicate-selector checks.
108
114
  For the default dashboard, use `gitea-axi --repo OWNER/NAME`; root-level `-R` remains unsupported.
109
115
  Hosted repository reads require an explicit repository.
110
- Lists default to 30 returned records, except `run list` and `release list` default to 10; collection scans are bounded to 20 pages or 1000 unique records.
116
+ Lists default to 30 returned records, except `run list` and `release list` default to 10; collection scans stop at 20 pages or at least 1000 unique records and retain at most 1000.
117
+ Automatic page queries apply only to routes whose retained Gitea specification declares a `page` parameter.
118
+ Unpaginated collections, including inline review comments and attachment metadata, are complete unless the server supplies a continuation link or the collection exceeds the record bound; a response of 50 records alone does not imply another page.
111
119
  Issue and PR lists filter before globally sorting the collected records and applying the return limit.
112
120
  Milestone selectors use exact names, including numeric-looking names.
113
- List output distinguishes unknown vendor totals from known totals and reports continuation and truncation.
121
+ List output distinguishes unknown totals from known totals and reports continuation and truncation.
122
+ For an unpaginated collection without a continuation link, a missing server total is inferred from the unique records received.
114
123
  Issue, PR and run lists expose `unfiltered_total` as the server count before client-side filtering; `total` is the filtered count when the bounded read is complete, otherwise null.
115
124
  Truncated output includes guidance to raise `--limit` (maximum 1000), or use raw API pagination when the bounded read is incomplete.
116
125
  The dashboard shows a compact repository projection (ID, full name, visibility, default branch, open issue and PR counts, and update time), plus up to five issues, PRs, labels, releases and task summaries per section.
@@ -253,6 +262,7 @@ gitea-axi label create --repo OWNER/NAME --name bug --color ff0000 --allow-write
253
262
  The preview identifies the exact operations, payload and current preconditions without sending a mutation.
254
263
  Confirmation binds the credential revision, origin, target, operations, canonical payload and preconditions.
255
264
  Changing any binding requires a fresh preview.
265
+ Organization casing must match the preview exactly.
256
266
  For issues that remain present, successful named writes verify identity and the expected final title, body, assignees, labels, milestone and state through fresh reads.
257
267
  Pin changes verify the complete expected pin membership; deletion verifies absence of the selected identity and preservation of all remaining records.
258
268
  A mismatched or failed verification reports an unknown outcome.
@@ -273,7 +283,8 @@ API timeouts, interrupted requests and partial failures return `WRITE_OUTCOME_UN
273
283
  Dispatched local Git failures retain their specific error code and include reconciliation information; stack recovery follows the [metadata and recovery contract](docs/local-stack-contract.md).
274
284
  Reconciliation performs complete metadata reads for inspection and never repeats a mutation.
275
285
  An unresolved dispatched attempt cannot be replayed by reusing its confirmation or obtaining another preview for the same effect.
276
- Replay guards also treat repository owner and repository name case variants as the same identity.
286
+ Replay guards also treat repository owner, repository name and organization name case variants as the same identity for named and raw writes.
287
+ Unresolved organization attempts from older versions conservatively block all writes to the same organization at the same origin, including different names or payloads, and return the original attempt's reconciliation command.
277
288
  Keep its private receipts until the outcome has been investigated.
278
289
  Native Windows write protection and live write commissioning remain unverified; Windows writes are refused.
279
290
 
@@ -301,6 +312,7 @@ See [the credential schema](axi-credential.schema.json) for profile fields and e
301
312
  Inline tokens and secret arguments are refused.
302
313
  Resolved credentials are redacted from response and error string values before TOON serialization, including their JSON-escaped and URL-encoded forms.
303
314
  Redaction also masks recognized secret fields, secret query values, URL credentials, JWTs, private keys and account keys.
315
+ Generated confirmation IDs and fixed confirmation/reconciliation instructions remain intact when they overlap a protected value; payloads and upstream response text remain subject to redaction.
304
316
  The complete environment profile uses `GITEA_AXI_ORIGIN`, `GITEA_AXI_TOKEN_ORIGIN` and a JSON SecretRef in `GITEA_AXI_TOKEN`.
305
317
  Set `GITEA_AXI_ALLOW_INSECURE_HTTP=true` to permit HTTP for that environment profile; only the exact values `true` and `false` are accepted.
306
318
  HTTP requires `allowInsecureHttp: true` on that exact profile; a runtime origin override cannot reuse the token for another origin.
@@ -412,4 +424,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules.
412
424
 
413
425
  ## License
414
426
 
415
- No license file is published in this repository yet; licensing is pending.
427
+ This project is licensed under the MIT License. See [LICENSE](LICENSE).
package/dist/api.js CHANGED
@@ -30,7 +30,11 @@ for (const definition of Object.values(vendor.definitions)) {
30
30
  }
31
31
  }
32
32
  const requestDefinitions = structuredClone(vendor.definitions);
33
- for (const [name, fields] of Object.entries({ PullRequest: ['merge_commit_sha'], ContentsResponse: ['submodule_git_url', 'target'] })) {
33
+ for (const [name, fields] of Object.entries({
34
+ PullRequest: ['merge_commit_sha'],
35
+ ContentsResponse: ['submodule_git_url', 'target', 'content', 'encoding', 'download_url', 'html_url', 'git_url'],
36
+ FileLinksResponse: ['git', 'html'],
37
+ })) {
34
38
  for (const field of fields) {
35
39
  const properties = vendor.definitions[name].properties;
36
40
  properties[field] = { anyOf: [properties[field], { type: 'null' }] };
@@ -45,7 +49,11 @@ export function object(value) {
45
49
  }
46
50
  export function effectFor(method, path) {
47
51
  path = canonicalPath(path);
48
- return routes.find(route => route.method === method && new RegExp('^' + route.path.split(/(\{[^}]+\})/).map(part => /^\{[^}]+\}$/.test(part) ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') + '$').test(path.split('?')[0]));
52
+ return routes.find(route => route.method === method && new RegExp('^' + route.path.split(/(\{[^}]+\})/).map(part => {
53
+ if (/^\{[^}]+\}$/.test(part))
54
+ return route.multiSegmentParameters?.includes(part.slice(1, -1)) ? '[^/]+(?:/[^/]+)*' : '[^/]+';
55
+ return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ }).join('') + '$').test(path.split('?')[0]));
49
57
  }
50
58
  export function canonicalPath(path) {
51
59
  const pathname = path.split('?')[0];
@@ -96,7 +104,8 @@ export function validate(path, value) {
96
104
  if (route.schema) {
97
105
  let validator = validators.get(route.path);
98
106
  if (!validator) {
99
- validator = ajv.compile({ ...route.schema, definitions: vendor.definitions });
107
+ const schema = route.operation === 'repoGetContents' ? { anyOf: [route.schema, { type: 'array', items: route.schema }] } : route.schema;
108
+ validator = ajv.compile({ ...schema, definitions: vendor.definitions });
100
109
  validators.set(route.path, validator);
101
110
  }
102
111
  if (!validator(value))
@@ -158,7 +167,8 @@ export async function rawRead(client, path) {
158
167
  return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
159
168
  }
160
169
  export async function collect(client, path, envelope) {
161
- let next = path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1';
170
+ const paginated = routeFor(path).paginated;
171
+ let next = paginated ? path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1' : path;
162
172
  const seenPages = new Set();
163
173
  const items = new Map();
164
174
  let total = null;
@@ -185,11 +195,11 @@ export async function collect(client, path, envelope) {
185
195
  total = object(data).total_count;
186
196
  const link = response.headers.get('link');
187
197
  next = nextPage(response.headers, path, client.origin);
188
- if (!next && !link && rows.length >= 50) {
198
+ if (paginated && !next && !link && rows.length >= 50) {
189
199
  const url = new URL([...seenPages].at(-1), client.origin);
190
200
  url.searchParams.set('page', String(Number(url.searchParams.get('page')) + 1));
191
201
  next = url.pathname + url.search;
192
202
  }
193
203
  }
194
- return { items: [...items.values()], total, next, truncated: next !== null };
204
+ return { items: [...items.values()].slice(0, 1000), total: total ?? (!paginated && !next ? items.size : null), next, truncated: next !== null || items.size > 1000 };
195
205
  }
@@ -35,6 +35,11 @@ function validateProfile(profile) {
35
35
  const secrets = [];
36
36
  export function registerSecret(value) { if (value)
37
37
  secrets.push(value); }
38
+ const controls = new Set();
39
+ export function controlText(value) {
40
+ controls.add(value);
41
+ return value;
42
+ }
38
43
  export function redact(value) {
39
44
  if (Array.isArray(value))
40
45
  return value.map(redact);
@@ -43,6 +48,10 @@ export function redact(value) {
43
48
  /^(?:token|access_token|refresh_token|password|secret|authorization|private_key)$/i.test(key) ? '[REDACTED]' : redact(item)]));
44
49
  if (typeof value !== 'string')
45
50
  return value;
51
+ // Callers register only generated control text, never payloads or upstream response text.
52
+ const preserved = [...controls].filter(text => value.includes(text)).sort((a, b) => b.length - a.length);
53
+ if (preserved.length)
54
+ return value.split(new RegExp('(' + preserved.map(text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')')).map(part => controls.has(part) ? part : redact(part)).join('');
46
55
  let output = value;
47
56
  for (const secret of secrets) {
48
57
  for (const form of [secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret)]) {
package/dist/writes.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
2
2
  import { constants, existsSync } from 'node:fs';
3
- import { mkdir, open, rm } from 'node:fs/promises';
3
+ import { mkdir, open, readdir, rm } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, isAbsolute, join } from 'node:path';
6
6
  import { canonicalPath, collect, effectFor, object, read, validate, validatePayload } from './api.js';
@@ -10,6 +10,7 @@ import { verifyPull } from './pulls.js';
10
10
  import { verifyWorkflow } from './repo-workflows.js';
11
11
  import { actionValues, valuePlan, verifyValue } from './action-values.js';
12
12
  import { verifyUploads } from './attachments.js';
13
+ import { controlText } from './credentials.js';
13
14
  function canonical(value) {
14
15
  if (Array.isArray(value))
15
16
  return '[' + value.map(canonical).join(',') + ']';
@@ -21,9 +22,9 @@ const digest = (value) => createHash('sha256').update(canonical(value)).digest('
21
22
  const usage = (message) => { throw new CliError('USAGE', message, 2); };
22
23
  const logPath = () => process.env.GITEA_AXI_WRITE_LOG ?? join(process.platform === 'win32' ? process.env.LOCALAPPDATA ?? homedir() : process.env.XDG_STATE_HOME ?? join(homedir(), '.local/state'), 'gitea-axi', 'writes.log');
23
24
  const directory = () => join(dirname(logPath()), 'write-receipts');
24
- const command = (id, options) => 'gitea-axi write reconcile ' + id +
25
- (options.config ? ' --config ' + shell(String(options.config)) : '') + (options.profile ? ' --profile ' + shell(String(options.profile)) : '') +
26
- (options.origin ? ' --origin ' + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? ' --allow-insecure-http' : '');
25
+ const command = (id, options) => controlText('gitea-axi write reconcile ' + id) +
26
+ (options.config ? controlText(' --config ') + shell(String(options.config)) : '') + (options.profile ? controlText(' --profile ') + shell(String(options.profile)) : '') +
27
+ (options.origin ? controlText(' --origin ') + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? controlText(' --allow-insecure-http') : '');
27
28
  function shell(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
28
29
  async function key(root) {
29
30
  await mkdir(root, { recursive: true, mode: 0o700 });
@@ -51,6 +52,7 @@ export async function reconcile(client, id) {
51
52
  if (receipt.origin !== client.origin)
52
53
  throw new CliError('CONFIRMATION_MISMATCH', 'Reconciliation origin differs from the attempted write');
53
54
  const observations = await observe(client, receipt.reads);
55
+ controlText(id);
54
56
  return { confirmation: id, operations: receipt.operations, states: receipt.states, observations,
55
57
  help: 'Inspect the exact identity and requested fields in these reads before authorizing any new attempt' };
56
58
  }
@@ -225,11 +227,9 @@ export async function write(client, identity, plan, options, executeLocal) {
225
227
  return { method: step.method, path: step.path, payload: step.payload, local: localOperation(step).operation };
226
228
  const route = validatePayload(step.method, step.path, step.payload);
227
229
  const segments = canonicalPath(step.path).split('/');
228
- if (/\{(?:template_)?repo\}/.test(route.path)) {
229
- for (const [index, segment] of route.path.split('/').entries()) {
230
- if (/^\{(?:owner|repo|org|template_owner|template_repo)\}$/.test(segment)) {
231
- segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
232
- }
230
+ for (const [index, segment] of route.path.split('/').entries()) {
231
+ if (/^\{(?:owner|repo|org|template_owner|template_repo)\}$/.test(segment)) {
232
+ segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
233
233
  }
234
234
  }
235
235
  return { method: step.method, path: segments.join('/'), ...(step.payload === undefined ? {} : { payload: step.payload }), ...(step.upload ? { upload: step.upload } : {}) };
@@ -238,6 +238,25 @@ export async function write(client, identity, plan, options, executeLocal) {
238
238
  const effectDigest = (value) => plan.sensitive ? createHmac('sha256', secret).update(canonical(value)).digest('hex') : digest(value);
239
239
  const attempt = effectDigest({ origin: client.origin, steps: effectSteps });
240
240
  const guards = [...new Set(effectSteps.filter((_, index) => !plan.steps[index].createdPull).map(step => join(root, effectDigest({ origin: client.origin, step }) + '.attempt')))];
241
+ const organization = (path) => /^\/api\/v1\/orgs\/([^/?]+)/.exec(canonicalPath(path))?.[1];
242
+ const organizations = new Set(effectSteps.map(step => organization(step.path)).filter(value => value !== undefined));
243
+ if (organizations.size) {
244
+ for (const name of await readdir(root)) {
245
+ if (!/^[a-f0-9]{64}\.attempt$/.test(name))
246
+ continue;
247
+ const previous = await readPrivateFile(join(root, name), true);
248
+ if (!/^[a-f0-9-]{36}$/.test(previous))
249
+ usage('Invalid persisted confirmation ID');
250
+ const receipt = JSON.parse(await readPrivateFile(join(root, previous + '.json'), true));
251
+ if (receipt.guardVersion !== 2 && receipt.origin === client.origin &&
252
+ receipt.reads.some(path => {
253
+ const org = organization(path);
254
+ return org !== undefined && organizations.has(encodeURIComponent(decodeURIComponent(org).toLowerCase()));
255
+ })) {
256
+ throw new CliError('WRITE_OUTCOME_UNKNOWN', 'An attempt already exists; reconcile with ' + command(previous, options));
257
+ }
258
+ }
259
+ }
241
260
  for (const guard of guards) {
242
261
  if (existsSync(guard)) {
243
262
  const previous = await readPrivateFile(guard, true);
@@ -245,18 +264,19 @@ export async function write(client, identity, plan, options, executeLocal) {
245
264
  }
246
265
  }
247
266
  if (options.preview) {
248
- const id = randomUUID();
249
- const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt,
267
+ const id = controlText(randomUUID());
268
+ const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt, guardVersion: 2,
250
269
  ...(plan.steps.every(step => step.local) ? { localTarget: plan.target, localGuards: guards.map(path => path.slice(root.length + 1, -8)) } : {}) };
251
270
  await writePrivateFile(join(root, id + '.json'), JSON.stringify(receipt));
252
271
  return { confirmation: id, origin: client.origin, target: plan.target, operations, steps: plan.steps.map(({ upload, ...step }) => ({ ...step, ...(plan.sensitive && step.payload !== undefined ? { payload: '[REDACTED]' } : {}), ...(upload ? { upload: { name: upload.name, type: upload.type, size: upload.size, sha256: upload.sha256 } } : {}) })), preconditions: plan.preconditions,
253
- ...(plan.notes ? { notes: plan.notes } : {}), help: 'Repeat the same command with --confirm ' + id + ' instead of --preview' };
272
+ ...(plan.notes ? { notes: plan.notes } : {}), help: controlText('Repeat the same command with --confirm ' + id + ' instead of --preview') };
254
273
  }
255
274
  const id = String(options.confirm);
256
275
  if (!/^[a-f0-9-]{36}$/.test(id))
257
276
  usage('Use the confirmation ID returned by --preview');
258
277
  const path = join(root, id + '.json');
259
278
  const receipt = JSON.parse(await readPrivateFile(path, true));
279
+ controlText(id);
260
280
  if (receipt.states.some(state => state !== 'pending'))
261
281
  throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Confirmation was already dispatched; reconcile with ' + command(id, options));
262
282
  if (receipt.binding !== binding)
@@ -285,6 +305,7 @@ export async function write(client, identity, plan, options, executeLocal) {
285
305
  throw error;
286
306
  }
287
307
  }
308
+ receipt.guardVersion = 2;
288
309
  const results = [];
289
310
  const outcome = () => 'steps=' + receipt.states.join(',') + (receipt.createdPull ? '; created PR id=' + receipt.createdPull.id + ' number=' + receipt.createdPull.number : '') + (receipt.createdParent ? '; existing parent id=' + receipt.createdParent.id + (receipt.createdParent.number ? ' number=' + receipt.createdParent.number : '') : '') + '; reconcile with ' + command(id, options);
290
311
  for (const [index, step] of plan.steps.entries()) {