@cronvello/sdk 0.2.1 → 0.4.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/dist/index.js CHANGED
@@ -543,8 +543,13 @@ function createDispatcher(state) {
543
543
  if (req.method.toUpperCase() !== "POST") {
544
544
  return resp(405, { ok: false, error: "Method not allowed" });
545
545
  }
546
+ const secret = state.dispatchSecret;
547
+ if (!secret) {
548
+ log.error?.("[cronvello] dispatch refused: no `dispatchSecret` configured on this app");
549
+ return resp(500, { ok: false, error: "Dispatch is not configured" });
550
+ }
546
551
  const token = parseBearer(req.authorization);
547
- if (!token || !timingSafeEqual(token, state.dispatchSecret)) {
552
+ if (!token || !timingSafeEqual(token, secret)) {
548
553
  log.warn?.("[cronvello] dispatch rejected: bad or missing bearer token");
549
554
  return resp(401, { ok: false, error: "Unauthorized" });
550
555
  }
@@ -575,7 +580,7 @@ function createDispatcher(state) {
575
580
  const isAsync = callback !== null;
576
581
  const ctx = buildContext(key, job, req, body, isAsync, "dispatch");
577
582
  if (callback) {
578
- const work = runAndReportCallback(job, ctx, callback, state.dispatchSecret, log, invokeHandler);
583
+ const work = runAndReportCallback(job, ctx, callback, secret, log, invokeHandler);
579
584
  if (req.waitUntil) req.waitUntil(work);
580
585
  return resp(202, { ok: true, job: key, accepted: true });
581
586
  }
@@ -1187,6 +1192,7 @@ var LocalEngine = class {
1187
1192
  started = false;
1188
1193
  stopped = false;
1189
1194
  signalCleanup = null;
1195
+ startedAtMs = null;
1190
1196
  constructor(jobs, runner, options = {}) {
1191
1197
  this.clock = options.clock ?? realClock;
1192
1198
  if (options.onEvent) this.listeners.add(options.onEvent);
@@ -1235,7 +1241,8 @@ var LocalEngine = class {
1235
1241
  if (this.started) return this;
1236
1242
  this.started = true;
1237
1243
  this.stopped = false;
1238
- this.emit({ type: "engine-start", jobs: this.states.length, at: this.clock.now() });
1244
+ this.startedAtMs = this.clock.now();
1245
+ this.emit({ type: "engine-start", jobs: this.states.length, at: this.startedAtMs });
1239
1246
  for (const state of this.states) {
1240
1247
  if (state.isReboot) {
1241
1248
  this.launch(state);
@@ -1299,6 +1306,14 @@ var LocalEngine = class {
1299
1306
  get activeRuns() {
1300
1307
  return this.inFlight.size;
1301
1308
  }
1309
+ /** Epoch ms of the `start()` call, or null while the engine has never been started. */
1310
+ get startedAt() {
1311
+ return this.startedAtMs;
1312
+ }
1313
+ /** True between `start()` and `stop()`. */
1314
+ get running() {
1315
+ return this.started && !this.stopped;
1316
+ }
1302
1317
  // ── Scheduling ─────────────────────────────────────────────────────────────
1303
1318
  scheduleNext(state) {
1304
1319
  if (this.stopped) return;
@@ -1493,14 +1508,20 @@ function defineCronvello(config) {
1493
1508
  }
1494
1509
  const jobs = normalizeJobs(config.jobs, validate);
1495
1510
  const dispatchPath = normalizePath(config.dispatchPath ?? DEFAULT_DISPATCH_PATH);
1496
- const dispatchUrl = joinUrl(config.appUrl, dispatchPath);
1497
- const client = new CronvelloClient({
1498
- apiKey: config.apiKey,
1499
- baseUrl: config.baseUrl ?? CRONVELLO_DEFAULT_BASE_URL,
1500
- ...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
1501
- ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
1502
- ...config.fetch ? { fetch: config.fetch } : {}
1503
- });
1511
+ let clientInstance;
1512
+ const getClient = () => {
1513
+ if (!clientInstance) {
1514
+ requireCloud(config, ["apiKey"], "Talking to the Cronvello API");
1515
+ clientInstance = new CronvelloClient({
1516
+ apiKey: config.apiKey,
1517
+ baseUrl: config.baseUrl ?? CRONVELLO_DEFAULT_BASE_URL,
1518
+ ...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
1519
+ ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
1520
+ ...config.fetch ? { fetch: config.fetch } : {}
1521
+ });
1522
+ }
1523
+ return clientInstance;
1524
+ };
1504
1525
  const dispatcher = createDispatcher({
1505
1526
  jobs,
1506
1527
  dispatchSecret: config.dispatchSecret,
@@ -1509,17 +1530,29 @@ function defineCronvello(config) {
1509
1530
  ...config.maxBodyBytes !== void 0 ? { maxBodyBytes: config.maxBodyBytes } : {}
1510
1531
  });
1511
1532
  const app = {
1512
- client,
1533
+ get client() {
1534
+ return getClient();
1535
+ },
1513
1536
  jobs,
1514
1537
  appName: config.appName,
1515
1538
  dispatchPath,
1516
- dispatchUrl,
1539
+ get dispatchUrl() {
1540
+ requireCloud(config, ["appUrl"], "Building the dispatch URL");
1541
+ return joinUrl(config.appUrl, dispatchPath);
1542
+ },
1543
+ get isCloudConfigured() {
1544
+ return missingCloudFields(config, CLOUD_FIELDS).length === 0;
1545
+ },
1517
1546
  async sync(options) {
1547
+ requireCloud(config, CLOUD_FIELDS, "Syncing your jobs to Cronvello");
1548
+ const client = getClient();
1549
+ const dispatchUrl = joinUrl(config.appUrl, dispatchPath);
1550
+ const dispatchSecret = config.dispatchSecret;
1518
1551
  const opts = options ?? {};
1519
1552
  if (!opts.dryRun) {
1520
1553
  try {
1521
1554
  const res = await client.reconcileRegistry(
1522
- buildRegistryRequest(config, dispatchUrl, defaultTimeZone, [...jobs.values()], opts)
1555
+ buildRegistryRequest(config, dispatchSecret, dispatchUrl, defaultTimeZone, [...jobs.values()], opts)
1523
1556
  );
1524
1557
  return {
1525
1558
  jobId: res.job.id,
@@ -1547,7 +1580,7 @@ function defineCronvello(config) {
1547
1580
  client,
1548
1581
  appName: config.appName,
1549
1582
  dispatchUrl,
1550
- dispatchSecret: config.dispatchSecret,
1583
+ dispatchSecret,
1551
1584
  defaultTimeZone,
1552
1585
  jobs: [...jobs.values()]
1553
1586
  },
@@ -1558,6 +1591,7 @@ function defineCronvello(config) {
1558
1591
  if (!jobs.has(key)) {
1559
1592
  throw new CronvelloConfigError(`Unknown job '${key}'. Known: ${[...jobs.keys()].join(", ") || "(none)"}`);
1560
1593
  }
1594
+ const client = getClient();
1561
1595
  const containers = await client.jobs.list();
1562
1596
  const container = containers.find((j) => j.name === config.appName);
1563
1597
  if (!container) {
@@ -1572,8 +1606,16 @@ function defineCronvello(config) {
1572
1606
  },
1573
1607
  trigger: (key, payload) => dispatcher.runLocal(key, payload),
1574
1608
  handle: dispatcher.handle,
1575
- expressHandler: () => expressHandler(app),
1576
- nextHandler: () => nextHandler(app),
1609
+ // Mounting a dispatch route that could only ever answer "not configured" hides the real
1610
+ // mistake behind a runtime 500, so both adapters fail at mount time instead.
1611
+ expressHandler: () => {
1612
+ requireCloud(config, ["dispatchSecret"], "Mounting the dispatch handler");
1613
+ return expressHandler(app);
1614
+ },
1615
+ nextHandler: () => {
1616
+ requireCloud(config, ["dispatchSecret"], "Mounting the dispatch handler");
1617
+ return nextHandler(app);
1618
+ },
1577
1619
  dev(options) {
1578
1620
  const { autoStart = true, dashboard, ...engineOptions } = options ?? {};
1579
1621
  const engineJobs = buildEngineJobs([...jobs.values()], defaultTimeZone);
@@ -1623,7 +1665,9 @@ function buildEngineJobs(jobs, defaultTimeZone) {
1623
1665
  !appUrl && "CRONVELLO_APP_URL (or PUBLIC_URL)"
1624
1666
  ].filter(Boolean);
1625
1667
  if (missing.length) {
1626
- throw new CronvelloConfigError(`defineCronvello.fromEnv() is missing required env: ${missing.join(", ")}.`);
1668
+ throw new CronvelloConfigError(
1669
+ `defineCronvello.fromEnv() is missing required env: ${missing.join(", ")}. To run locally with no account, use defineCronvello({ appName, jobs }) instead \u2014 it needs none of these.`
1670
+ );
1627
1671
  }
1628
1672
  return defineCronvello2({
1629
1673
  ...config,
@@ -1635,17 +1679,32 @@ function buildEngineJobs(jobs, defaultTimeZone) {
1635
1679
  }
1636
1680
  defineCronvello2.fromEnv = fromEnv;
1637
1681
  })(defineCronvello || (defineCronvello = {}));
1682
+ var CLOUD_FIELDS = ["apiKey", "appUrl", "dispatchSecret"];
1683
+ var CLOUD_FIELD_HINT = {
1684
+ apiKey: "`apiKey` (crn_live_\u2026, from your Cronvello account)",
1685
+ appUrl: "`appUrl` (the public https URL of THIS app, where Cronvello delivers callbacks)",
1686
+ dispatchSecret: "`dispatchSecret` (a random 32-byte value: run `npx cronvello secret`)"
1687
+ };
1688
+ function missingCloudFields(config, fields) {
1689
+ return fields.filter((f) => !config[f]);
1690
+ }
1691
+ function requireCloud(config, fields, purpose) {
1692
+ const missing = missingCloudFields(config, fields);
1693
+ if (!missing.length) return;
1694
+ throw new CronvelloConfigError(
1695
+ `${purpose} needs config this app doesn't have: ${missing.map((f) => CLOUD_FIELD_HINT[f]).join(", ")}. Local runs (\`cronvello dev\`, \`trigger()\`) work without any of it.`
1696
+ );
1697
+ }
1638
1698
  function validateConfig(config) {
1639
1699
  if (!config) throw new CronvelloConfigError("defineCronvello requires a config object.");
1640
1700
  if (!config.appName || !config.appName.trim()) throw new CronvelloConfigError("`appName` is required.");
1641
- if (!config.appUrl || !/^https?:\/\//i.test(config.appUrl)) {
1701
+ if (!config.jobs) throw new CronvelloConfigError("`jobs` is required.");
1702
+ if (config.appUrl !== void 0 && !/^https?:\/\//i.test(config.appUrl)) {
1642
1703
  throw new CronvelloConfigError("`appUrl` must be an absolute http(s) URL (the public URL of THIS app).");
1643
1704
  }
1644
- if (!config.apiKey) throw new CronvelloConfigError("`apiKey` (crn_live_\u2026) is required.");
1645
- if (!config.dispatchSecret || config.dispatchSecret.length < 16) {
1646
- throw new CronvelloConfigError("`dispatchSecret` is required and must be at least 16 chars (use a random 32-byte value).");
1705
+ if (config.dispatchSecret !== void 0 && config.dispatchSecret.length < 16) {
1706
+ throw new CronvelloConfigError("`dispatchSecret` must be at least 16 chars (use a random 32-byte value).");
1647
1707
  }
1648
- if (!config.jobs) throw new CronvelloConfigError("`jobs` is required.");
1649
1708
  }
1650
1709
  function normalizeJobs(input, validate) {
1651
1710
  const map = /* @__PURE__ */ new Map();
@@ -1682,14 +1741,14 @@ function normalizePath(path) {
1682
1741
  function joinUrl(base, path) {
1683
1742
  return `${base.replace(/\/+$/, "")}${path}`;
1684
1743
  }
1685
- function buildRegistryRequest(config, dispatchUrl, defaultTimeZone, jobs, opts) {
1744
+ function buildRegistryRequest(config, dispatchSecret, dispatchUrl, defaultTimeZone, jobs, opts) {
1686
1745
  const tasks = jobs.map((j) => {
1687
1746
  const cfg = j.config;
1688
1747
  const task = {
1689
1748
  key: j.key,
1690
1749
  schedule: cfg.schedule,
1691
1750
  targetUrl: dispatchUrl,
1692
- targetToken: config.dispatchSecret,
1751
+ targetToken: dispatchSecret,
1693
1752
  method: "POST",
1694
1753
  timeZone: cfg.timeZone ?? defaultTimeZone,
1695
1754
  requestBody: JSON.stringify({ job: j.key, ...cfg.payload ?? {} })
@@ -1855,6 +1914,112 @@ function field(value, min, max, label) {
1855
1914
  return value;
1856
1915
  }
1857
1916
 
1917
+ // src/client/admin-client.ts
1918
+ var CronvelloAdminClient = class {
1919
+ transport;
1920
+ /** The resolved base URL in use. */
1921
+ baseUrl;
1922
+ /** Registration, status, key rotation and removal of external apps. */
1923
+ externalApps;
1924
+ constructor(options) {
1925
+ if (!options || !options.serviceKey) {
1926
+ throw new CronvelloConfigError(
1927
+ "CronvelloAdminClient requires a `serviceKey` (Cronvello's backend-to-backend key, not an account apiKey)."
1928
+ );
1929
+ }
1930
+ this.baseUrl = (options.baseUrl ?? CRONVELLO_DEFAULT_BASE_URL).replace(/\/+$/, "");
1931
+ this.transport = new Transport({
1932
+ baseUrl: this.baseUrl,
1933
+ apiKey: options.serviceKey,
1934
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
1935
+ ...options.maxRetries !== void 0 ? { maxRetries: options.maxRetries } : {},
1936
+ ...options.fetch ? { fetch: options.fetch } : {},
1937
+ ...options.onRequest ? { onRequest: options.onRequest } : {}
1938
+ });
1939
+ this.externalApps = new ExternalAppsResource(this.transport);
1940
+ }
1941
+ };
1942
+ var ExternalAppsResource = class {
1943
+ constructor(t) {
1944
+ this.t = t;
1945
+ }
1946
+ t;
1947
+ /**
1948
+ * Idempotent upsert, keyed on the string `appId`: creates when unknown, updates credentials
1949
+ * and URL when it already exists. Safe to retry and to re-run on every provisioning pass.
1950
+ *
1951
+ * When the server mints the token (`generateApiKey: true` on a *new* app) it comes back as
1952
+ * `generatedApiKey` — plaintext, exactly once. On an update it is `null`.
1953
+ */
1954
+ register(input) {
1955
+ assertRegisterInput(input);
1956
+ return this.t.request({ method: "POST", path: "/external-apps/service/register", body: input });
1957
+ }
1958
+ /** Registration status, last-sync info and job count for one app, by its string `appId`. */
1959
+ status(appId) {
1960
+ assertAppId(appId, "status");
1961
+ return this.t.request({ method: "GET", path: `/external-apps/service/status/${enc2(appId)}` });
1962
+ }
1963
+ /**
1964
+ * Mint a fresh per-app token, by string `appId`. The new token is returned once as `newApiKey`
1965
+ * and must be written into the app's environment — the previous one stops working.
1966
+ *
1967
+ * Use this for drift recovery when the current token is no longer known.
1968
+ */
1969
+ rotateKey(appId) {
1970
+ assertAppId(appId, "rotateKey");
1971
+ return this.t.request({ method: "POST", path: `/external-apps/service/rotate-key/${enc2(appId)}` });
1972
+ }
1973
+ /**
1974
+ * Delete a registration, cascading its jobs and tasks.
1975
+ *
1976
+ * ⚠ This one takes the **numeric** {@link ExternalApp.id}, not the string `appId` the other
1977
+ * three methods take — an asymmetry in the server's route contract. Read the id off a
1978
+ * `register()` result (or a prior lookup); passing a string `appId` here is rejected locally.
1979
+ */
1980
+ delete(id) {
1981
+ if (!Number.isInteger(id) || id <= 0) {
1982
+ throw new CronvelloConfigError(
1983
+ `externalApps.delete() takes the numeric app id (ExternalApp.id), not the string appId \u2014 received ${JSON.stringify(id)}.`
1984
+ );
1985
+ }
1986
+ return this.t.request({ method: "DELETE", path: `/external-apps/service/delete/${id}` });
1987
+ }
1988
+ };
1989
+ function assertRegisterInput(input) {
1990
+ if (!input || typeof input !== "object") {
1991
+ throw new CronvelloConfigError("externalApps.register() requires an input object.");
1992
+ }
1993
+ if (!input.appId || !input.appId.trim()) {
1994
+ throw new CronvelloConfigError("externalApps.register() requires a non-empty `appId`.");
1995
+ }
1996
+ if (!input.name || !input.name.trim()) {
1997
+ throw new CronvelloConfigError("externalApps.register() requires a non-empty `name`.");
1998
+ }
1999
+ if (!input.base_url && input.targetUrl === void 0) {
2000
+ throw new CronvelloConfigError("externalApps.register() requires either `base_url` or `targetUrl`.");
2001
+ }
2002
+ const authMethod = input.authMethod ?? (input.oauthClientId || input.oauthClientSecret ? "oauth" : "api_key");
2003
+ if (authMethod === "api_key" && !input.apiKey && !input.generateApiKey) {
2004
+ throw new CronvelloConfigError(
2005
+ "externalApps.register() with api_key auth requires either `apiKey` or `generateApiKey: true`."
2006
+ );
2007
+ }
2008
+ if (authMethod === "oauth" && (!input.oauthClientId || !input.oauthClientSecret)) {
2009
+ throw new CronvelloConfigError(
2010
+ "externalApps.register() with oauth auth requires both `oauthClientId` and `oauthClientSecret`."
2011
+ );
2012
+ }
2013
+ }
2014
+ function assertAppId(appId, method) {
2015
+ if (typeof appId !== "string" || !appId.trim()) {
2016
+ throw new CronvelloConfigError(`externalApps.${method}() requires a non-empty string appId.`);
2017
+ }
2018
+ }
2019
+ function enc2(segment) {
2020
+ return encodeURIComponent(segment);
2021
+ }
2022
+
1858
2023
  // src/index.ts
1859
2024
  function generateDispatchSecret(bytes = 32) {
1860
2025
  const buf = new Uint8Array(bytes);
@@ -1862,6 +2027,6 @@ function generateDispatchSecret(bytes = 32) {
1862
2027
  return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
1863
2028
  }
1864
2029
 
1865
- export { CRONVELLO_DEFAULT_BASE_URL, CronvelloApiError, CronvelloClient, CronvelloConfigError, CronvelloError, CronvelloNetworkError, cron, daily, defineCronvello, every, everyHours, everyMinutes, formatSyncResult, generateDispatchSecret, hourly, isValidTimeZone, monthly, nextOccurrence, previewSchedule, schedule, validateCron, weekdays, weekends, weekly };
2030
+ export { CRONVELLO_DEFAULT_BASE_URL, CronvelloAdminClient, CronvelloApiError, CronvelloClient, CronvelloConfigError, CronvelloError, CronvelloNetworkError, cron, daily, defineCronvello, every, everyHours, everyMinutes, formatSyncResult, generateDispatchSecret, hourly, isValidTimeZone, monthly, nextOccurrence, previewSchedule, schedule, validateCron, weekdays, weekends, weekly };
1866
2031
  //# sourceMappingURL=index.js.map
1867
2032
  //# sourceMappingURL=index.js.map