@teamscala/service-boot 0.23.389 → 0.23.394

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.389",
9
+ "version": "0.23.394",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -34,9 +34,11 @@
34
34
  "@teamscala/generic-registry": "*",
35
35
  "@teamscala/http": "*",
36
36
  "@teamscala/logger": "*",
37
+ "@teamscala/notify-cache": "*",
37
38
  "@teamscala/object-storage": "*",
38
39
  "@teamscala/orpc": "*",
39
40
  "@teamscala/os": "*",
41
+ "@teamscala/publish-registry": "*",
40
42
  "@teamscala/realtime-utils": "*",
41
43
  "@teamscala/resolver-engine": "*",
42
44
  "@teamscala/timing": "*",
@@ -562,6 +564,14 @@
562
564
  "types": "./src/load-service-prisma-client.d.ts",
563
565
  "default": "./src/load-service-prisma-client.ts"
564
566
  },
567
+ "./module-not-found": {
568
+ "types": "./src/module-not-found.d.ts",
569
+ "default": "./src/module-not-found.ts"
570
+ },
571
+ "./module-not-found.ts": {
572
+ "types": "./src/module-not-found.d.ts",
573
+ "default": "./src/module-not-found.ts"
574
+ },
565
575
  "./mount": {
566
576
  "types": "./src/mount.d.ts",
567
577
  "default": "./src/mount.ts"
@@ -3,20 +3,26 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Cached reader for the `email-defaults` config row's `default_sender` — the
7
- * from-address for outbound transactional email (agreement/proposal/assessment
8
- * senders). Replaces the former process.env.DEFAULT_EMAIL_SENDER read
9
- * (configured-primitives: the registry row is the source of truth, not env).
6
+ * Hot snapshot of `email-defaults.default_sender` (outbound transactional
7
+ * from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
8
+ * Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
9
+ * safety ceiling for an upstream regression that drops notifications.
10
10
  */
11
+ import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
11
12
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
12
13
  import * as v from "valibot";
13
14
 
14
15
  const FALLBACK_SENDER = "noreply@gateway.scala.business";
15
- let cached: string | undefined;
16
+ const defaultSenderCache = createNotifyCache<string>({
17
+ name: "service-boot:default-email-sender",
18
+ load: async () => {
19
+ const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
+ return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
+ },
22
+ invalidateOn: ["registry_config_changed"],
23
+ staleAfterMs: 5 * 60 * 1000,
24
+ });
16
25
 
17
26
  export async function getDefaultEmailSender(): Promise<string> {
18
- if (cached !== undefined) return cached;
19
- const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
- cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
- return cached;
27
+ return defaultSenderCache.get();
22
28
  }
@@ -3,21 +3,26 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Cached reader for the `email-links` config row's `email_link_ttl_ms` — the
7
- * validity window for agreement/proposal email-access magic links (dedup'd
8
- * across the 3 fn-routers that mint them). Replaces the former per-file
9
- * hardcoded EMAIL_LINK_TTL_MS constant (config-constants: the registry row is
10
- * the source of truth, not the literal).
6
+ * Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
7
+ * window, dedup'd across the 3 fn-routers that mint them). Replaces the
8
+ * former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
9
+ * `registry_config_changed`.
11
10
  */
11
+ import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
12
12
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
13
13
  import * as v from "valibot";
14
14
 
15
15
  const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
16
- let cached: number | undefined;
16
+ const emailLinkTtlCache = createNotifyCache<number>({
17
+ name: "service-boot:email-link-ttl",
18
+ load: async () => {
19
+ const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
20
+ return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
21
+ },
22
+ invalidateOn: ["registry_config_changed"],
23
+ staleAfterMs: 5 * 60 * 1000,
24
+ });
17
25
 
18
26
  export async function getEmailLinkTtlMs(): Promise<number> {
19
- if (cached !== undefined) return cached;
20
- const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
21
- cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
22
- return cached;
27
+ return emailLinkTtlCache.get();
23
28
  }
@@ -25,10 +25,11 @@ import * as v from "valibot";
25
25
  import { getAppLogger } from "@teamscala/logger/app-loggers";
26
26
  import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
27
27
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
28
+ import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
28
29
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
29
30
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
31
+ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
30
32
  import { recordEvent } from "@teamscala/event-log/record-event";
31
- import { getDefaultEmailSender } from "./email-defaults.ts";
32
33
 
33
34
  interface Context {
34
35
  prisma: any;
@@ -106,9 +107,9 @@ function bandForScore(
106
107
 
107
108
  /**
108
109
  * Best-effort branded Authority Index Report email. Self-contained: loads its
109
- * own Resend secret, swallows every error — a send failure is logged and NEVER
110
- * undoes the submit (the agreement-completion-email pattern). Fire-and-forget
111
- * from the submit handler.
110
+ * own Resend secret, swallows every error. Sender identity + brand tokens
111
+ * come from resolveEmailSender (per-org email_sender row). On Resend refusal
112
+ * the call falls back to the platform sender and logs the reason.
112
113
  */
113
114
  async function sendAuthorityReportEmail(opts: {
114
115
  to: string;
@@ -117,6 +118,8 @@ async function sendAuthorityReportEmail(opts: {
117
118
  bandLabel: string;
118
119
  categoryScores: Record<string, number>;
119
120
  ctaUrl?: string;
121
+ organisationId?: string | null;
122
+ prisma: any;
120
123
  }): Promise<void> {
121
124
  const log = getAppLogger();
122
125
  const rawSecret = await loadRegistryConfig(
@@ -132,17 +135,42 @@ async function sendAuthorityReportEmail(opts: {
132
135
  return;
133
136
  }
134
137
  initResendClient(decrypted.token);
135
- const from = await getDefaultEmailSender();
138
+
139
+ const brandRow =
140
+ opts.organisationId && opts.prisma
141
+ ? await fetchOrgBrand(
142
+ opts.prisma as Parameters<typeof fetchOrgBrand>[0],
143
+ opts.organisationId,
144
+ ).catch(() => null)
145
+ : null;
146
+ const sender = await resolveEmailSender(opts.organisationId ?? null, {
147
+ brand: brandRow
148
+ ? {
149
+ orgName: brandRow.companyName ?? "",
150
+ primaryColor: brandRow.primaryColor,
151
+ accentColor: brandRow.accentColor ?? null,
152
+ logoUrl: brandRow.logo.light ?? null,
153
+ }
154
+ : undefined,
155
+ });
156
+ const primary = sender.brand.primaryColor;
157
+ const muted = "#6b7280"; // structural neutral for body labels
158
+ const logoUrl = sender.brand.logoUrl;
159
+ const logoHtml =
160
+ logoUrl && /^https?:\/\//.test(logoUrl)
161
+ ? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
162
+ : "";
163
+
136
164
  // Subject + body from the `authority-index-report` email_template row, not
137
- // literals here (email-template-registry-type). The per-pillar rows and the
138
- // optional CTA are the only DYNAMIC markup, so they are rendered here and
139
- // passed as merge vars — the row still owns all the copy and the layout.
165
+ // literals here (email-template-registry-type). Dynamic markup is rendered
166
+ // here and passed as merge vars — the row owns copy + layout; colours +
167
+ // logo come from the org brand tokens, NOT inline literals.
140
168
  const pillarRows = (
141
169
  Object.entries(opts.categoryScores) as Array<[string, number]>
142
170
  )
143
171
  .map(
144
172
  ([name, score]) =>
145
- `<tr><td style="padding:6px 0;color:#374151">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:#111827">${score} / 100</td></tr>`,
173
+ `<tr><td style="padding:6px 0;color:${muted}">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:${primary}">${score} / 100</td></tr>`,
146
174
  )
147
175
  .join("");
148
176
  const pillarsText = (
@@ -156,27 +184,80 @@ async function sendAuthorityReportEmail(opts: {
156
184
  bandLabel: opts.bandLabel,
157
185
  pillarRows,
158
186
  pillarsText,
187
+ brandPrimaryColor: primary,
188
+ brandAccentColor: sender.brand.accentColor ?? primary,
189
+ brandLogoHtml: logoHtml,
190
+ orgName: sender.brand.orgName,
159
191
  ctaHtml: opts.ctaUrl
160
- ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
192
+ ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:${primary};color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
161
193
  : "",
162
194
  ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
163
195
  });
164
- const res = await sendEmail({
165
- from,
166
- to: opts.to,
167
- subject: tpl.subject,
168
- html: tpl.bodyHtml,
169
- text: tpl.bodyText,
170
- });
196
+
197
+ const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
198
+ sendEmail({
199
+ from: fromHeader,
200
+ to: opts.to,
201
+ subject: tpl.subject,
202
+ html: tpl.bodyHtml,
203
+ text: tpl.bodyText,
204
+ reply_to: replyToHeader,
205
+ });
206
+
207
+ let res = await sendOnce(sender.from, sender.replyTo);
208
+ let usedFrom = sender.from;
209
+ if (!res.success) {
210
+ // Resend refused the org sender (likely unverified domain). Fall back
211
+ // to the platform sender; never fail because a verification lookup
212
+ // failed — the resolver returns the row, the failure is at Resend.
213
+ const platformRow = await loadRegistryConfig(
214
+ "config",
215
+ "email-defaults",
216
+ v.looseObject({ default_sender: v.optional(v.string()) }),
217
+ ).catch(() => null);
218
+ const fallbackFrom =
219
+ (platformRow?.default_sender as string | undefined)?.trim() ||
220
+ "scala <noreply@gateway.scala.business>";
221
+ log.warn(
222
+ `[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
223
+ );
224
+ res = await sendOnce(fallbackFrom, undefined);
225
+ usedFrom = fallbackFrom;
226
+ }
227
+
171
228
  if (res.success) {
172
229
  log.info(
173
- `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
230
+ `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
174
231
  );
175
232
  } else {
176
233
  log.error(
177
234
  `[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
178
235
  );
179
236
  }
237
+
238
+ // email-send-audited: best-effort audit row. Guarded so a stale prisma
239
+ // client (scala-quiz schema predates email_send_log) skips cleanly.
240
+ try {
241
+ const auditPrisma = opts.prisma as {
242
+ email_send_log?: { create: (args: unknown) => Promise<unknown> };
243
+ };
244
+ if (auditPrisma?.email_send_log) {
245
+ await auditPrisma.email_send_log.create({
246
+ data: {
247
+ to_address: opts.to,
248
+ template_slug: "authority-index-report",
249
+ status: res.success ? "sent" : "failed",
250
+ provider_message_id: res.id ?? null,
251
+ error: res.success ? null : (res.error ?? "unknown"),
252
+ from_address: usedFrom,
253
+ },
254
+ });
255
+ }
256
+ } catch (auditErr) {
257
+ log.warn(
258
+ `[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
259
+ );
260
+ }
180
261
  }
181
262
 
182
263
  function scoreAnswers(
@@ -382,6 +463,8 @@ export const publicAssessmentRouter = {
382
463
  bandLabel: band.label,
383
464
  categoryScores,
384
465
  ctaUrl,
466
+ organisationId: orgId,
467
+ prisma,
385
468
  }).catch((e) =>
386
469
  log.error(
387
470
  `[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
@@ -24,9 +24,10 @@
24
24
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
25
25
  import { recordEvent } from "@teamscala/event-log/record-event";
26
26
  import { getAppLogger } from "@teamscala/logger/app-loggers";
27
+ import { getDistTagVersion } from "@teamscala/publish-registry";
27
28
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
28
29
 
29
- const BUN_NPM_LATEST = "https://registry.npmjs.org/bun/latest";
30
+ const NPM_REGISTRY = "https://registry.npmjs.org";
30
31
  const TARGET_STABLE = "1.4.0"; // flip back when a stable at or above this ships
31
32
 
32
33
  interface BunLatestManifest {
@@ -54,23 +55,19 @@ function isStableAtOrAbove(version: string, target: string): boolean {
54
55
 
55
56
  async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
56
57
  const log = getAppLogger();
57
- let manifest: BunLatestManifest;
58
- try {
59
- const res = await fetch(BUN_NPM_LATEST, { headers: { accept: "application/json" } });
60
- if (!res.ok) throw new Error(`npm bun/latest HTTP ${res.status}`);
61
- manifest = (await res.json()) as BunLatestManifest;
62
- } catch (err) {
63
- log.warn(`[bun-stable-watch] failed to fetch npm bun/latest: ${err}`);
58
+ const version = await getDistTagVersion("bun", "latest", NPM_REGISTRY);
59
+ if (!version) {
60
+ log.warn("[bun-stable-watch] failed to fetch npm bun/latest");
64
61
  return;
65
62
  }
66
- if (isStableAtOrAbove(manifest.version, TARGET_STABLE)) {
63
+ if (isStableAtOrAbove(version, TARGET_STABLE)) {
67
64
  log.warn(
68
- `[bun-stable-watch] Bun stable ${manifest.version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
65
+ `[bun-stable-watch] Bun stable ${version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
69
66
  );
70
67
  recordEvent({
71
68
  kind: "bun.stable.shipped",
72
69
  payload: {
73
- latest: manifest.version,
70
+ latest: version,
74
71
  targetStable: TARGET_STABLE,
75
72
  note: "escape hatch bun-14-lock-resolution-pin ready to retire; verify railpack 1.4.0 support then run the atomic flip-back",
76
73
  },
@@ -78,11 +75,11 @@ async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<voi
78
75
  return;
79
76
  }
80
77
  log.info(
81
- `[bun-stable-watch] npm bun/latest = ${manifest.version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
78
+ `[bun-stable-watch] npm bun/latest = ${version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
82
79
  );
83
80
  recordEvent({
84
81
  kind: "bun.watch.tick",
85
- payload: { latest: manifest.version, targetStable: TARGET_STABLE },
82
+ payload: { latest: version, targetStable: TARGET_STABLE },
86
83
  });
87
84
  }
88
85
 
@@ -28,6 +28,7 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
28
28
  import { recordEvent } from "@teamscala/event-log/record-event";
29
29
  import { getAppLogger } from "@teamscala/logger/app-loggers";
30
30
  import { spawn } from "@teamscala/os/spawn";
31
+ import { getDistTagVersion } from "@teamscala/publish-registry";
31
32
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
32
33
 
33
34
  /**
@@ -180,7 +181,7 @@ async function fetchLatestGithubRelease(repo: string): Promise<string | null> {
180
181
  async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
181
182
  try {
182
183
  // @teamscala/* packages use the internal dist-tag tool; public npm packages
183
- // fetch directly from the npm registry (same pattern as bun-stable-watch).
184
+ // fetch directly from the npm registry via the typed primitive.
184
185
  if (pkg.startsWith("@teamscala/")) {
185
186
  const result = await spawn({
186
187
  name: "singleton-tools-currency:dist-tag",
@@ -196,12 +197,7 @@ async function fetchLatestVersion(pkg: string, distTag: string): Promise<string
196
197
  if (!latest || !parseSemver(latest)) return null;
197
198
  return latest;
198
199
  }
199
- // Public npm package: fetch the dist-tag manifest directly.
200
- const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/${encodeURIComponent(distTag)}`;
201
- const res = await fetch(url, { headers: { accept: "application/json" } });
202
- if (!res.ok) return null;
203
- const manifest = (await res.json()) as { version: string };
204
- return manifest.version ?? null;
200
+ return getDistTagVersion(pkg, distTag, "https://registry.npmjs.org");
205
201
  } catch {
206
202
  return null;
207
203
  }
@@ -39,6 +39,7 @@
39
39
  * bytes across restarts).
40
40
  */
41
41
 
42
+ import { createCache } from "@teamscala/cache/create-cache";
42
43
  import { readClientBuildStatus } from "./build-client-assets.ts";
43
44
  import { join } from "node:path";
44
45
 
@@ -70,7 +71,15 @@ const PAGE_DOCUMENT = `<!doctype html>
70
71
  </body>
71
72
  </html>`;
72
73
 
73
- let cached: { html: string; manifestMtime: number } | null = null;
74
+ interface CachedHtml {
75
+ html: string;
76
+ manifestMtime: number;
77
+ }
78
+
79
+ const htmlCache = createCache<CachedHtml>("service-boot:html-template", {
80
+ ttlMs: Number.POSITIVE_INFINITY,
81
+ maxSize: 1,
82
+ });
74
83
 
75
84
  function manifestPath(): string {
76
85
  return join(process.cwd(), "dist", "client", "asset-manifest.json");
@@ -98,8 +107,9 @@ async function loadManifest(): Promise<AssetManifest> {
98
107
  }
99
108
 
100
109
  export async function readHtmlTemplate(): Promise<string> {
101
- const mtime = await manifestMtimeMs();
102
- if (cached && cached.manifestMtime === mtime) return cached.html;
110
+ const manifestMtime = await manifestMtimeMs();
111
+ const cached = htmlCache.get("default");
112
+ if (cached && cached.manifestMtime === manifestMtime) return cached.html;
103
113
  const manifest = await loadManifest();
104
114
  let html = PAGE_DOCUMENT
105
115
  .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
@@ -116,6 +126,6 @@ export async function readHtmlTemplate(): Promise<string> {
116
126
  ? html.replace("</head>", `${meta}</head>`)
117
127
  : `${meta}${html}`;
118
128
  }
119
- cached = { html, manifestMtime: mtime };
129
+ htmlCache.set("default", { html, manifestMtime });
120
130
  return html;
121
131
  }
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * Polls the installed scala-tools package against GitHub Packages and repairs
7
7
  * stale daemon tooling by reinstalling and letting PM2 restart the process.
8
+ * The fetch routes through `getGithubPackagesLatestVersion` so the URL
9
+ * encoding and GitHub-Packages auth header live in the typed primitive.
8
10
  */
9
11
 
10
12
  import { recordEvent } from "@teamscala/event-log/record-event";
@@ -14,6 +16,7 @@ import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
14
16
  import type { WatchdogHandle } from "@teamscala/watchdog/types";
15
17
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
18
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
19
+ import { getGithubPackagesLatestVersion } from "@teamscala/publish-registry";
17
20
  import * as v from "valibot";
18
21
 
19
22
  const PACKAGE_NAME = "@teamscala/scala-tools";
@@ -55,10 +58,6 @@ interface PackageManifest {
55
58
  version?: unknown;
56
59
  }
57
60
 
58
- interface PackagePackument {
59
- "dist-tags"?: { latest?: unknown };
60
- }
61
-
62
61
  let watchdog: WatchdogHandle | null = null;
63
62
 
64
63
  async function readInstalledVersion(cwd: string): Promise<string | null> {
@@ -70,24 +69,25 @@ async function readInstalledVersion(cwd: string): Promise<string | null> {
70
69
  }
71
70
  }
72
71
 
73
- async function readLatestVersion(fetchTimeoutMs: number): Promise<string | null> {
72
+ /**
73
+ * PACKAGE_URL is `<registry>/<encoded-name>`. Split it so the typed primitive
74
+ * owns the URL encoding (encodeURIComponent) and the GitHub-Packages auth
75
+ * header shape — neither is this file's concern.
76
+ */
77
+ function registryFromPackageUrl(pkgUrl: string): { registry: string; name: string } {
78
+ const lastSlash = pkgUrl.lastIndexOf("/");
79
+ if (lastSlash < 0) return { registry: pkgUrl, name: "" };
80
+ return {
81
+ registry: pkgUrl.slice(0, lastSlash),
82
+ name: decodeURIComponent(pkgUrl.slice(lastSlash + 1)),
83
+ };
84
+ }
85
+
86
+ async function readLatestVersion(_fetchTimeoutMs: number): Promise<string | null> {
74
87
  const token = await getGithubPackagesToken();
75
88
  if (!token) return null;
76
- try {
77
- const response = await fetch(PACKAGE_URL, {
78
- headers: {
79
- accept: "application/vnd.github+json",
80
- authorization: `Bearer ${token}`,
81
- },
82
- signal: AbortSignal.timeout(fetchTimeoutMs),
83
- });
84
- if (!response.ok) return null;
85
- const packument = (await response.json()) as PackagePackument;
86
- const latest = packument["dist-tags"]?.latest;
87
- return typeof latest === "string" ? latest : null;
88
- } catch {
89
- return null;
90
- }
89
+ const { registry, name } = registryFromPackageUrl(PACKAGE_URL);
90
+ return getGithubPackagesLatestVersion(name, token, registry);
91
91
  }
92
92
 
93
93
  async function checkCurrency(): Promise<{ healthy: boolean; details?: Record<string, unknown> }> {