package-registry-manager 0.26.0 → 0.27.1
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 +10 -0
- package/README.md +8 -0
- package/package.json +2 -2
- package/src/central-gpg.mjs +278 -0
- package/src/central-namespace.mjs +258 -0
- package/src/central-packagist-steps.mjs +454 -0
- package/src/central-page.mjs +156 -0
- package/src/central-publication.mjs +231 -0
- package/src/central-signing.mjs +143 -0
- package/src/central-token-page.mjs +66 -0
- package/src/credential-cycle.mjs +7 -0
- package/src/discovery.mjs +21 -36
- package/src/extra-flows.mjs +22 -13
- package/src/gradle-distribution.mjs +410 -0
- package/src/model.mjs +4 -0
- package/src/nuget-page.mjs +102 -19
- package/src/nuget-policy.mjs +68 -17
- package/src/nuget-setup.mjs +14 -6
- package/src/organization-scan.mjs +34 -2
- package/src/plan.mjs +14 -0
- package/src/publishers.mjs +14 -5
- package/src/scan-sidecars.mjs +132 -0
- package/src/setup.mjs +15 -1
- package/src/swift-distribution.mjs +249 -0
- package/src/swift-release.mjs +180 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { ensureSigningKey } from "./central-signing.mjs";
|
|
2
|
+
import readCentralPair from "./central-token-page.mjs";
|
|
3
|
+
import { verifyNamespace } from "./central-namespace.mjs";
|
|
4
|
+
import { verifyCentralPublication } from "./central-publication.mjs";
|
|
5
|
+
import { githubServices } from "./github.mjs";
|
|
6
|
+
import { AUTH_FAILURE_PATTERNS } from "./publishing-policy.mjs";
|
|
7
|
+
import { repositorySlug } from "./repository-identity.mjs";
|
|
8
|
+
import { verifySwiftConsumer, verifySwiftRelease } from "./swift-release.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generated Central, Packagist and SwiftPM steps and their handlers. None of
|
|
12
|
+
* them is ever routed to the crates.io session.
|
|
13
|
+
*/
|
|
14
|
+
export const CENTRAL_PACKAGIST_STEPS = new Set([
|
|
15
|
+
"ensure-central-credential-pair",
|
|
16
|
+
"verify-namespace",
|
|
17
|
+
"ensure-signing-key",
|
|
18
|
+
"verify-central-publication",
|
|
19
|
+
"verify-packagist-package",
|
|
20
|
+
"packagist-github-hook",
|
|
21
|
+
"packagist-update-api",
|
|
22
|
+
"verify-swift-release",
|
|
23
|
+
"verify-swift-consumer",
|
|
24
|
+
"verify-gradle-toolchain",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const MISSING_SECRET =
|
|
28
|
+
/secrets? (?:are |is )?missing|missing (?:secrets?|credentials?)|(?:CENTRAL_USERNAME|CENTRAL_TOKEN|GPG_PRIVATE_KEY|GPG_PASSPHRASE)[^\n]*(?:not set|missing|empty)/i;
|
|
29
|
+
|
|
30
|
+
/** Runs one generated Central/Packagist/SwiftPM step. */
|
|
31
|
+
export async function runCentralPackagistStep(session, step) {
|
|
32
|
+
const handlers = {
|
|
33
|
+
"ensure-central-credential-pair": ensureCentralPair,
|
|
34
|
+
"verify-namespace": verifyNamespace,
|
|
35
|
+
"ensure-signing-key": ensureSigningKey,
|
|
36
|
+
"verify-central-publication": verifyCentralPublication,
|
|
37
|
+
"verify-packagist-package": verifyPackagistPackage,
|
|
38
|
+
"packagist-github-hook": packagistHook,
|
|
39
|
+
"packagist-update-api": packagistUpdateApi,
|
|
40
|
+
"verify-swift-release": verifySwiftRelease,
|
|
41
|
+
"verify-swift-consumer": verifySwiftConsumer,
|
|
42
|
+
"verify-gradle-toolchain": manualPrerequisite,
|
|
43
|
+
};
|
|
44
|
+
const result = await handlers[step.id](session, step);
|
|
45
|
+
record(session, step, result);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Collects the step result. Anything short of fresh evidence keeps the
|
|
51
|
+
* setup incomplete; `stop` ends the plan before later browsers or commands.
|
|
52
|
+
*/
|
|
53
|
+
function record(session, step, result) {
|
|
54
|
+
session.stepResults ??= [];
|
|
55
|
+
session.stepResults.push({ step: step.id, ...result });
|
|
56
|
+
if (
|
|
57
|
+
!["verified", "present", "configured", "not-used"].includes(result.status)
|
|
58
|
+
) {
|
|
59
|
+
session.incomplete ??= [];
|
|
60
|
+
session.incomplete.push({ step: step.id, ...result });
|
|
61
|
+
}
|
|
62
|
+
if (result.stop) {
|
|
63
|
+
session.outcome = {
|
|
64
|
+
status: result.status,
|
|
65
|
+
incomplete: session.incomplete ?? [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
session.log(
|
|
69
|
+
`${step.id}: ${result.status}${result.reason ? ` (${result.reason})` : ""}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function services(session) {
|
|
74
|
+
return session.options.github ?? githubServices(session.options);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Selected secret presence at repository, then organization scope. */
|
|
78
|
+
async function secretPresence(session, names) {
|
|
79
|
+
const slug = repositorySlug(session.plan.repository);
|
|
80
|
+
const github = services(session);
|
|
81
|
+
const owner = slug.split("/")[0];
|
|
82
|
+
const present = [];
|
|
83
|
+
const missing = [];
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
let metadata = await github.secrets({ repo: slug }).getMetadata(name);
|
|
86
|
+
if (!metadata) {
|
|
87
|
+
try {
|
|
88
|
+
metadata = await github.secrets({ org: owner }).getMetadata(name);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (![403, 404, 422].includes(error.status)) {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
(metadata ? present : missing).push(name);
|
|
96
|
+
}
|
|
97
|
+
return { slug, present, missing };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Keeps an existing Central user-token pair; otherwise acquires one new
|
|
102
|
+
* pair in the dedicated browser, holds both fields in memory and stores
|
|
103
|
+
* them as encrypted GitHub secrets through gh-manager.
|
|
104
|
+
*/
|
|
105
|
+
async function ensureCentralPair(session, step) {
|
|
106
|
+
const names = (step.secrets ?? []).map((entry) => entry.secret);
|
|
107
|
+
if (names.length !== 2 || !session.plan.repository?.github_owner) {
|
|
108
|
+
return {
|
|
109
|
+
status: "unsupported",
|
|
110
|
+
reason:
|
|
111
|
+
"the Central pair needs GitHub repository coordinates and exactly two selected secret names",
|
|
112
|
+
stop: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const { slug, present, missing } = await secretPresence(session, names);
|
|
116
|
+
if (!missing.length) {
|
|
117
|
+
return {
|
|
118
|
+
status: "present",
|
|
119
|
+
secrets: present,
|
|
120
|
+
reason:
|
|
121
|
+
"existing Central secrets kept; values are not readable and validity is not inferred from names",
|
|
122
|
+
expiry: await centralExpiry(services(session), slug, names),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const { options } = session;
|
|
126
|
+
if (
|
|
127
|
+
options.noBrowser ||
|
|
128
|
+
options.browserOptions?.attach ||
|
|
129
|
+
options.browserOptions?.importScope === "full"
|
|
130
|
+
) {
|
|
131
|
+
return {
|
|
132
|
+
status: "manual-prerequisite",
|
|
133
|
+
secrets: missing,
|
|
134
|
+
reason: `generate a Central Portal user token at ${step.url} and store ${missing.join(" + ")} as encrypted secrets; automation needs a dedicated profile with domain-scoped sign-in import`,
|
|
135
|
+
stop: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const replaces = present.length
|
|
139
|
+
? ` A Central token is a matched pair, so ${present.join(" + ")} is replaced too.`
|
|
140
|
+
: "";
|
|
141
|
+
if (
|
|
142
|
+
!options.yes &&
|
|
143
|
+
!/^y(?:es)?$/i.test(
|
|
144
|
+
await session.prompt(
|
|
145
|
+
`Generate one Central Portal user token and store ${names.join(" + ")} for ${slug}?${replaces} [y/N] `,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
) {
|
|
149
|
+
return {
|
|
150
|
+
status: "interrupted",
|
|
151
|
+
secrets: missing,
|
|
152
|
+
reason: "Central user-token creation declined",
|
|
153
|
+
stop: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const page = await session.automatedPage(
|
|
157
|
+
{
|
|
158
|
+
...options.browserOptions,
|
|
159
|
+
import: options.browserOptions?.import ?? {
|
|
160
|
+
browser: "auto",
|
|
161
|
+
profile: null,
|
|
162
|
+
},
|
|
163
|
+
importScope: "domains",
|
|
164
|
+
},
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
await page.goto(step.url);
|
|
168
|
+
await session.prompt(
|
|
169
|
+
"Generate the user token in this browser and leave its username and password dialog open, then press Enter (never paste the values)...",
|
|
170
|
+
);
|
|
171
|
+
const pair = await page.evaluate(READ_CENTRAL_PAIR);
|
|
172
|
+
if (!pair?.username || !pair?.password) {
|
|
173
|
+
return {
|
|
174
|
+
status: "manual-prerequisite",
|
|
175
|
+
secrets: missing,
|
|
176
|
+
reason:
|
|
177
|
+
"the Central token dialog was not readable; nothing was stored, so the pair is still missing",
|
|
178
|
+
stop: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
pair.revoked ||
|
|
183
|
+
(pair.expires_at && Date.parse(pair.expires_at) <= Date.now())
|
|
184
|
+
) {
|
|
185
|
+
return {
|
|
186
|
+
status: "unverified",
|
|
187
|
+
reason:
|
|
188
|
+
"the displayed Central token is revoked or expired; nothing was stored",
|
|
189
|
+
stop: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const github = services(session);
|
|
193
|
+
const values = [pair.username, pair.password];
|
|
194
|
+
// The pair is matched: both fields are written together after the
|
|
195
|
+
// explicit confirmation above, never one silently.
|
|
196
|
+
for (const [index, name] of names.entries()) {
|
|
197
|
+
await github
|
|
198
|
+
.secrets({ repo: slug })
|
|
199
|
+
.set(name, values[index], { expiresAt: pair.expires_at ?? undefined });
|
|
200
|
+
}
|
|
201
|
+
const check = await secretPresence(session, names);
|
|
202
|
+
if (check.missing.length) {
|
|
203
|
+
return {
|
|
204
|
+
status: "unverified",
|
|
205
|
+
secrets: check.missing,
|
|
206
|
+
reason: "gh-manager did not read the stored secrets back",
|
|
207
|
+
stop: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (pair.token_id && github.variables) {
|
|
211
|
+
await github.variables({ repo: slug }).ensure(
|
|
212
|
+
"CENTRAL_TOKEN_IDENTITY",
|
|
213
|
+
JSON.stringify({
|
|
214
|
+
id: pair.token_id,
|
|
215
|
+
name: pair.token_name,
|
|
216
|
+
expires_at: pair.expires_at ?? null,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
session.conditions.add("central-pair-stored");
|
|
221
|
+
return {
|
|
222
|
+
status: "configured",
|
|
223
|
+
secrets: names,
|
|
224
|
+
expiry: {
|
|
225
|
+
status: pair.expires_at ? "server-reported" : "unknown",
|
|
226
|
+
expires_at: pair.expires_at ?? null,
|
|
227
|
+
token_name: pair.token_name ?? null,
|
|
228
|
+
},
|
|
229
|
+
retry: await retryCausalJobs(session, slug, names),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Settings-only repair: restart only the failed main jobs whose logs show
|
|
235
|
+
* the missing Central secrets, then follow them through gh-manager.
|
|
236
|
+
*/
|
|
237
|
+
async function retryCausalJobs(session, slug, names) {
|
|
238
|
+
const workflow = session.plan.package.workflow;
|
|
239
|
+
if (!workflow || session.options.noRetry) {
|
|
240
|
+
return { status: "not-run", reason: "no publishing workflow to retry" };
|
|
241
|
+
}
|
|
242
|
+
const github = services(session);
|
|
243
|
+
const repo = await github.rest.request(`/repos/${slug}`);
|
|
244
|
+
const runs = await github.runs.list(slug, {
|
|
245
|
+
workflow,
|
|
246
|
+
branch: repo.default_branch,
|
|
247
|
+
limit: 20,
|
|
248
|
+
});
|
|
249
|
+
const failed = runs
|
|
250
|
+
.filter((run) => run.conclusion === "failure")
|
|
251
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
252
|
+
if (!failed) {
|
|
253
|
+
return { status: "not-needed", reason: "no failed main run" };
|
|
254
|
+
}
|
|
255
|
+
const auth = new RegExp(
|
|
256
|
+
[MISSING_SECRET.source, ...(AUTH_FAILURE_PATTERNS["maven-central"] ?? [])]
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.join("|"),
|
|
259
|
+
"i",
|
|
260
|
+
);
|
|
261
|
+
const causal = [];
|
|
262
|
+
for (const job of await github.runs.jobs(slug, failed.id)) {
|
|
263
|
+
if (job.conclusion !== "failure") {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const log = await github.runs.jobLog(slug, job.id).catch(() => null);
|
|
267
|
+
if (
|
|
268
|
+
typeof log === "string" &&
|
|
269
|
+
auth.test(log) &&
|
|
270
|
+
names.some((name) => log.includes(name))
|
|
271
|
+
) {
|
|
272
|
+
causal.push(job.id);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!causal.length) {
|
|
276
|
+
return {
|
|
277
|
+
status: "unknown",
|
|
278
|
+
reason:
|
|
279
|
+
"no failed job log names the stored Central secrets; unrelated CI was not restarted",
|
|
280
|
+
run: failed.html_url,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const result = await github.repair.verify({
|
|
284
|
+
repo: slug,
|
|
285
|
+
workflow,
|
|
286
|
+
runId: failed.id,
|
|
287
|
+
jobIds: causal,
|
|
288
|
+
changes: [{ kind: "setting", changed: true, verified: true }],
|
|
289
|
+
timeout: session.options.waitTimeoutMs ?? 600000,
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
status: result.status,
|
|
293
|
+
run: result.runUrl ?? failed.html_url,
|
|
294
|
+
jobs: causal,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Reads both displayed fields of the Central token dialog; never logged. */
|
|
299
|
+
export const READ_CENTRAL_PAIR = `(${readCentralPair})()`;
|
|
300
|
+
|
|
301
|
+
async function http(session, url, method = "GET") {
|
|
302
|
+
const fetcher = session.options.fetch ?? globalThis.fetch;
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetcher(url, {
|
|
305
|
+
method,
|
|
306
|
+
headers: { accept: "application/json" },
|
|
307
|
+
signal: AbortSignal.timeout(session.options.timeoutMs ?? 10_000),
|
|
308
|
+
});
|
|
309
|
+
if (session.options.verbose) {
|
|
310
|
+
console.error(`${method} ${url} -> ${response.status}`);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
status: response.status,
|
|
314
|
+
json:
|
|
315
|
+
method === "GET" && response.ok
|
|
316
|
+
? await response.json().catch(() => null)
|
|
317
|
+
: null,
|
|
318
|
+
};
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return { status: "transport", reason: error.message };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const STABLE = (version) =>
|
|
325
|
+
!/^dev-|-dev$|[-.](?:alpha|beta|rc|dev)/i.test(version);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Exact stable tag read-back from Packagist's p2 metadata. Package
|
|
329
|
+
* existence or a dev branch never passes.
|
|
330
|
+
*/
|
|
331
|
+
async function verifyPackagistPackage(session) {
|
|
332
|
+
const item = session.plan.package;
|
|
333
|
+
if (item.manifest !== "composer.json") {
|
|
334
|
+
return {
|
|
335
|
+
status: "unsupported",
|
|
336
|
+
reason: `${item.manifest} is not the canonical root Composer descriptor Packagist reads`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const tagged = await http(
|
|
340
|
+
session,
|
|
341
|
+
`https://repo.packagist.org/p2/${item.name}.json`,
|
|
342
|
+
);
|
|
343
|
+
if (tagged.status === 404) {
|
|
344
|
+
return {
|
|
345
|
+
status: "unregistered",
|
|
346
|
+
reason: `${item.name} is not on Packagist`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if ([401, 403].includes(tagged.status)) {
|
|
350
|
+
return { status: "auth-rejected", reason: `HTTP ${tagged.status}` };
|
|
351
|
+
}
|
|
352
|
+
if (tagged.status !== 200 || !tagged.json) {
|
|
353
|
+
return {
|
|
354
|
+
status: "transport-unknown",
|
|
355
|
+
reason: tagged.reason ?? `HTTP ${tagged.status}`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const versions = (tagged.json.packages?.[item.name] ?? [])
|
|
359
|
+
.map((entry) => entry.version)
|
|
360
|
+
.filter((version) => version && STABLE(version));
|
|
361
|
+
if (!versions.length) {
|
|
362
|
+
const dev = await http(
|
|
363
|
+
session,
|
|
364
|
+
`https://repo.packagist.org/p2/${item.name}~dev.json`,
|
|
365
|
+
);
|
|
366
|
+
return {
|
|
367
|
+
status: dev.json?.packages?.[item.name]?.length
|
|
368
|
+
? "dev-only"
|
|
369
|
+
: "stable-version-missing",
|
|
370
|
+
reason: "no stable tagged version",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const wanted = item.version?.replace(/^v/, "");
|
|
374
|
+
if (!wanted) {
|
|
375
|
+
return {
|
|
376
|
+
status: "unknown",
|
|
377
|
+
reason: "the expected version is unresolved",
|
|
378
|
+
versions,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return versions.some((version) => version.replace(/^v/, "") === wanted)
|
|
382
|
+
? { status: "verified", version: wanted }
|
|
383
|
+
: {
|
|
384
|
+
status: "stable-version-missing",
|
|
385
|
+
reason: `${wanted} is not published; latest ${versions[0]}`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The GitHub hook is invisible to this tool: stays a prerequisite. */
|
|
390
|
+
async function packagistHook(session, step) {
|
|
391
|
+
const exact = session.stepResults?.find(
|
|
392
|
+
(entry) => entry.step === "verify-packagist-package",
|
|
393
|
+
);
|
|
394
|
+
return {
|
|
395
|
+
status: "manual-prerequisite",
|
|
396
|
+
reason: `confirm the GitHub integration at ${step.url}${exact?.status === "verified" ? "; the exact tag reached Packagist, but that does not prove the hook" : ""}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function packagistUpdateApi(session, step) {
|
|
401
|
+
if (step.state !== "configured") {
|
|
402
|
+
return {
|
|
403
|
+
status: "not-used",
|
|
404
|
+
reason:
|
|
405
|
+
"the workflow does not call the update-package API; the GitHub hook is the only update path",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const names = (step.secrets ?? []).map((entry) => entry.secret);
|
|
409
|
+
const { present, missing } = await secretPresence(session, names);
|
|
410
|
+
return missing.length
|
|
411
|
+
? {
|
|
412
|
+
status: "manual-prerequisite",
|
|
413
|
+
missing,
|
|
414
|
+
reason: `${missing.join(" + ")} missing; add the Packagist API token as encrypted secrets`,
|
|
415
|
+
}
|
|
416
|
+
: { status: "configured", secrets: present };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Pressing Enter is never verification. */
|
|
420
|
+
async function manualPrerequisite(session, step) {
|
|
421
|
+
console.log(step.description);
|
|
422
|
+
if (step.url) {
|
|
423
|
+
console.log(` ${step.url}`);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
status: "manual-prerequisite",
|
|
427
|
+
reason: "no automated read-back evidence",
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function centralExpiry(github, slug, names) {
|
|
432
|
+
if (!github.variables) {
|
|
433
|
+
return { status: "unknown" };
|
|
434
|
+
}
|
|
435
|
+
const values = await Promise.all(
|
|
436
|
+
names.map(async (name) => {
|
|
437
|
+
const value = await github
|
|
438
|
+
.variables({ repo: slug })
|
|
439
|
+
.get(`${name}_EXPIRES_AT`)
|
|
440
|
+
.catch(() => null);
|
|
441
|
+
return value?.value ?? null;
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
return values.every(
|
|
445
|
+
(value) =>
|
|
446
|
+
value && value === values[0] && Number.isFinite(Date.parse(value)),
|
|
447
|
+
)
|
|
448
|
+
? {
|
|
449
|
+
status: "recorded",
|
|
450
|
+
expires_at: values[0],
|
|
451
|
+
expired: Date.parse(values[0]) <= Date.now(),
|
|
452
|
+
}
|
|
453
|
+
: { status: "unknown" };
|
|
454
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/* global document */
|
|
2
|
+
/** Official Portal UI endpoints, evaluated only in the dedicated signed-in page. */
|
|
3
|
+
export default async function centralPage(expected, action = "read") {
|
|
4
|
+
const request = async (path, method = "GET", body) => {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(path, {
|
|
7
|
+
method,
|
|
8
|
+
credentials: "same-origin",
|
|
9
|
+
cache: "no-store",
|
|
10
|
+
headers: {
|
|
11
|
+
accept: "application/json",
|
|
12
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
13
|
+
},
|
|
14
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
15
|
+
signal: AbortSignal.timeout(10000),
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
status: response.status,
|
|
19
|
+
body: response.ok ? await response.json().catch(() => null) : null,
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
return { status: "transport" };
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const user = await request("/api/auth/me");
|
|
26
|
+
const account = user.body?.sub;
|
|
27
|
+
if (!account) {
|
|
28
|
+
return {
|
|
29
|
+
status: [401, 403].includes(user.status)
|
|
30
|
+
? "auth-required"
|
|
31
|
+
: "transport-unknown",
|
|
32
|
+
reason: `Central account HTTP ${user.status}`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (expected.account && account !== expected.account) {
|
|
36
|
+
return { status: "wrong-account", account };
|
|
37
|
+
}
|
|
38
|
+
if (action === "tokens") {
|
|
39
|
+
const tokens = await request("/api/v2/usertokens/list?page_size=100");
|
|
40
|
+
const entries = tokens.body?.tokens;
|
|
41
|
+
const matches = Array.isArray(entries)
|
|
42
|
+
? entries.filter((token) =>
|
|
43
|
+
expected.token_id
|
|
44
|
+
? token.id === expected.token_id
|
|
45
|
+
: token.name_code === expected.username,
|
|
46
|
+
)
|
|
47
|
+
: [];
|
|
48
|
+
if (matches.length !== 1) {
|
|
49
|
+
return {
|
|
50
|
+
status: "unknown",
|
|
51
|
+
account,
|
|
52
|
+
reason: `Central token lifetime unreadable (HTTP ${tokens.status})`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const token = matches[0];
|
|
56
|
+
const expiry = token.expiration_timestamp;
|
|
57
|
+
return {
|
|
58
|
+
status: "read",
|
|
59
|
+
account,
|
|
60
|
+
token_id: token.id,
|
|
61
|
+
token_name: token.display_name,
|
|
62
|
+
expires_at:
|
|
63
|
+
typeof expiry === "string" &&
|
|
64
|
+
/^\d{4}-\d\d-\d\dT/.test(expiry) &&
|
|
65
|
+
Number.isFinite(Date.parse(expiry))
|
|
66
|
+
? new Date(expiry).toISOString()
|
|
67
|
+
: null,
|
|
68
|
+
revoked: token.revoked === true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const base = "/api/internal/publisher/namespace";
|
|
72
|
+
if (action === "register" || action === "verify") {
|
|
73
|
+
const result = await request(
|
|
74
|
+
action === "register"
|
|
75
|
+
? base
|
|
76
|
+
: `${base}/verify?namespace=${encodeURIComponent(expected.namespace)}`,
|
|
77
|
+
"POST",
|
|
78
|
+
action === "register"
|
|
79
|
+
? { namespace: expected.namespace, state: "UNVERIFIED" }
|
|
80
|
+
: undefined,
|
|
81
|
+
);
|
|
82
|
+
if (![200, 201, 202, 204].includes(result.status)) {
|
|
83
|
+
return {
|
|
84
|
+
status: "unverified",
|
|
85
|
+
account,
|
|
86
|
+
reason: `Central ${action} HTTP ${result.status}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const result = await request(base);
|
|
91
|
+
const entries = Array.isArray(result.body)
|
|
92
|
+
? result.body
|
|
93
|
+
: result.body?.namespaces;
|
|
94
|
+
if (Array.isArray(entries)) {
|
|
95
|
+
const matches = entries.filter(
|
|
96
|
+
(entry) => entry.name === expected.namespace,
|
|
97
|
+
);
|
|
98
|
+
if (matches.length > 1) {
|
|
99
|
+
return { status: "ambiguous", account };
|
|
100
|
+
}
|
|
101
|
+
const entry = matches[0];
|
|
102
|
+
if (
|
|
103
|
+
entry &&
|
|
104
|
+
expected.organization &&
|
|
105
|
+
entry?.owningOrgName !== expected.organization
|
|
106
|
+
) {
|
|
107
|
+
return {
|
|
108
|
+
status: "wrong-organization",
|
|
109
|
+
account,
|
|
110
|
+
namespace: expected.namespace,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
status: !entry
|
|
115
|
+
? "missing"
|
|
116
|
+
: entry.state === "VERIFIED" && !entry.userSuspended
|
|
117
|
+
? "verified"
|
|
118
|
+
: "unverified",
|
|
119
|
+
account,
|
|
120
|
+
namespace: expected.namespace,
|
|
121
|
+
state: entry?.state,
|
|
122
|
+
verification_key: entry?.verificationKey,
|
|
123
|
+
organization: entry?.owningOrgName,
|
|
124
|
+
namespace_id: entry?.id,
|
|
125
|
+
source: "authenticated-portal",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Fallback to the actual signed-in UI. Require a unique exact namespace card.
|
|
129
|
+
const cards = [
|
|
130
|
+
...document.querySelectorAll('[data-test="namespace-item"]'),
|
|
131
|
+
].filter((card) =>
|
|
132
|
+
[...card.querySelectorAll('[data-test="namespace-name"]')].some(
|
|
133
|
+
(node) => node.textContent.trim() === expected.namespace,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
if (
|
|
137
|
+
!expected.organization &&
|
|
138
|
+
cards.length === 1 &&
|
|
139
|
+
/\bVerified\b/.test(cards[0].textContent) &&
|
|
140
|
+
!/Unverified|Verification Pending|Your access suspended/.test(
|
|
141
|
+
cards[0].textContent,
|
|
142
|
+
)
|
|
143
|
+
) {
|
|
144
|
+
return {
|
|
145
|
+
status: "verified",
|
|
146
|
+
account,
|
|
147
|
+
namespace: expected.namespace,
|
|
148
|
+
source: "authenticated-portal-ui",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
status: "transport-unknown",
|
|
153
|
+
account,
|
|
154
|
+
reason: `Central namespaces HTTP ${result.status}; authenticated read-back unavailable`,
|
|
155
|
+
};
|
|
156
|
+
}
|