@ultimat3/core 22.9.1 → 22.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "22.9.1",
3
+ "version": "22.10.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,6 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/schema": "22.9.1"
43
+ "@ultimat3/schema": "22.10.0"
44
44
  }
45
45
  }
@@ -19,13 +19,36 @@ export interface SeoRobotsConfig {
19
19
  readonly disallow: readonly string[];
20
20
  }
21
21
 
22
+ /**
23
+ * Where each sitemap `<lastmod>` comes from. `'none'` (the default): no `<lastmod>`, as before.
24
+ * `'git'`: the last commit that touched the route's source file, read with `git log` — and the
25
+ * file's mtime where the process has no work tree (a container image). `'mtime'`: the file's mtime.
26
+ * `'build'`: one timestamp for every URL, the moment the process built its sitemap.
27
+ */
28
+ export type SitemapLastmod = 'none' | 'git' | 'mtime' | 'build';
29
+
30
+ export const SITEMAP_LASTMOD_SOURCES: readonly SitemapLastmod[] = ['none', 'git', 'mtime', 'build'];
31
+
32
+ export interface SeoSitemapConfig {
33
+ /**
34
+ * Public pages OUTSIDE `site/` to list — an `app/` route anyone may open, e.g. `['/verificar']`.
35
+ * Each must match a registered route that declares no policy (checked when the sitemap is built,
36
+ * `X_SITEMAP_EXTRA_INVALID`), and is listed per routed locale with hreflang alternates exactly
37
+ * as a `site/` page is.
38
+ */
39
+ readonly extra: readonly string[];
40
+ readonly lastmod: SitemapLastmod;
41
+ }
42
+
22
43
  export interface SeoConfig {
23
44
  readonly robots: SeoRobotsConfig;
45
+ readonly sitemap: SeoSitemapConfig;
24
46
  }
25
47
 
26
48
  /** `robots` is NESTED for `AiConfigInput`'s reason: `section` applies a patch one level deep. */
27
49
  export interface SeoConfigInput {
28
50
  readonly robots?: Input<SeoRobotsConfig> | undefined;
51
+ readonly sitemap?: Input<SeoSitemapConfig> | undefined;
29
52
  }
30
53
 
31
54
  export interface SiteSections {
@@ -50,6 +73,10 @@ export function mergeSite(layers: readonly SiteSectionsInput[]): SiteSections {
50
73
  { disallow: [] },
51
74
  layers.map((layer) => layer.seo?.robots),
52
75
  ),
76
+ sitemap: layered<SeoSitemapConfig>(
77
+ { extra: [], lastmod: 'none' },
78
+ layers.map((layer) => layer.seo?.sitemap),
79
+ ),
53
80
  },
54
81
  };
55
82
  }
@@ -84,4 +111,18 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
84
111
  for (const path of config.seo.robots.disallow) {
85
112
  if (!path.startsWith('/')) issues.push(`seo.robots.disallow entry "${path}" must start with /`);
86
113
  }
114
+ for (const path of config.seo.sitemap.extra) {
115
+ // A PATH, never a URL: every `<loc>` is built against the one declared origin, and a query or
116
+ // a fragment names a variant of a page, which a sitemap lists by its canonical URL alone.
117
+ if (!path.startsWith('/') || path.startsWith('//') || /[?#]/.test(path)) {
118
+ issues.push(
119
+ `seo.sitemap.extra entry "${path}" must be a path starting with / — no origin, query or fragment`,
120
+ );
121
+ }
122
+ }
123
+ if (!SITEMAP_LASTMOD_SOURCES.includes(config.seo.sitemap.lastmod)) {
124
+ issues.push(
125
+ `seo.sitemap.lastmod "${String(config.seo.sitemap.lastmod)}" must be one of ${SITEMAP_LASTMOD_SOURCES.join(', ')}`,
126
+ );
127
+ }
87
128
  }
package/src/errors.ts CHANGED
@@ -26,6 +26,18 @@ export interface UltimateErrorInit {
26
26
  readonly cause: string;
27
27
  /** The exact command or edit that fixes it. */
28
28
  readonly fix: string;
29
+ /**
30
+ * The fix for a REMOTE caller — an agent over MCP, a client reading a problem document — when
31
+ * `fix` names something only the app's developer can do (`x policy explain`, an edit to a
32
+ * declaration). Omitted, every surface reads `fix`. The developer's `fix` is never replaced: the
33
+ * terminal, the log line and `--json` keep it, and a dev-mode problem document does too.
34
+ */
35
+ readonly callerFix?: string | undefined;
36
+ /**
37
+ * Where the reader goes next. Defaults to the code's registered page. An app may point it at its
38
+ * own guide — `docs://recipes/refund-a-payment` — and `@ultimat3/mcp` then prints it as a fourth
39
+ * `docs:` line in the tool result, the moment the agent is stuck.
40
+ */
29
41
  readonly docs?: string | undefined;
30
42
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
31
43
  /**
@@ -43,6 +55,8 @@ export interface UltimateErrorJSON {
43
55
  readonly title: string;
44
56
  readonly cause: string;
45
57
  readonly fix: string;
58
+ /** Present only when the error declared one. See `UltimateErrorInit.callerFix`. */
59
+ readonly callerFix?: string | undefined;
46
60
  readonly docs: string;
47
61
  /** Whether a client may try again. Always present — a client never has to infer it. */
48
62
  readonly retry: ErrorRetry;
@@ -50,9 +64,26 @@ export interface UltimateErrorJSON {
50
64
  readonly stack?: string | undefined;
51
65
  }
52
66
 
67
+ /**
68
+ * Who reads a rendering. `'developer'` (the default) is the terminal, a log line, `--json`: the
69
+ * `fix` the author wrote. `'caller'` is a remote client that cannot run the app's CLI — an MCP
70
+ * tool result, a production problem document: `callerFix` when the error declared one, else `fix`.
71
+ */
72
+ export type ErrorAudience = 'developer' | 'caller';
73
+
53
74
  export interface FormatErrorOptions {
54
75
  /** Append a 4th `docs:` line. Off by default — the contract's rendering is 3 lines. */
55
76
  readonly docs?: boolean | undefined;
77
+ /** Whose fix the `fix:` line carries. See `ErrorAudience`. */
78
+ readonly audience?: ErrorAudience | undefined;
79
+ }
80
+
81
+ /** The fix line a reader of `audience` is given: `callerFix` for a caller when one exists. */
82
+ export function fixFor(
83
+ error: { readonly fix: string; readonly callerFix?: string | undefined },
84
+ audience: ErrorAudience | undefined,
85
+ ): string {
86
+ return audience === 'caller' && error.callerFix !== undefined ? error.callerFix : error.fix;
56
87
  }
57
88
 
58
89
  export class UltimateError extends Error {
@@ -63,6 +94,7 @@ export class UltimateError extends Error {
63
94
  /** Set by `Error`'s `cause` option; always a human-readable string in Ultimate. */
64
95
  declare readonly cause: string;
65
96
  readonly fix: string;
97
+ readonly callerFix: string | undefined;
66
98
  readonly docs: string;
67
99
  readonly retry: ErrorRetry;
68
100
  readonly meta: Readonly<Record<string, unknown>> | undefined;
@@ -90,6 +122,7 @@ export class UltimateError extends Error {
90
122
  this.code = code;
91
123
  this.title = title;
92
124
  this.fix = singleLine(init.fix);
125
+ this.callerFix = init.callerFix === undefined ? undefined : singleLine(init.callerFix);
93
126
  this.docs = singleLine(init.docs ?? described.docs);
94
127
  this.retry = init.retry ?? retryFor(init.code);
95
128
  this.meta = init.meta;
@@ -110,7 +143,8 @@ export class UltimateError extends Error {
110
143
  // would be a second place that has to be right — and the one that gets forgotten. This method
111
144
  // is line-oriented and stays exactly 3 lines (4 with `docs`) because the fields cannot carry
112
145
  // a line break, not because this joiner removes them.
113
- const lines = [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`];
146
+ const fix = fixFor(this, options?.audience);
147
+ const lines = [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${fix}`];
114
148
  if (options?.docs === true) lines.push(` docs: ${this.docs}`);
115
149
  return lines.join('\n');
116
150
  }
@@ -128,6 +162,7 @@ export class UltimateError extends Error {
128
162
  title: this.title,
129
163
  cause: this.cause,
130
164
  fix: this.fix,
165
+ ...(this.callerFix === undefined ? {} : { callerFix: this.callerFix }),
131
166
  docs: this.docs,
132
167
  retry: this.retry,
133
168
  meta: renderMetaRecord(this.meta),
@@ -147,6 +182,21 @@ export function isUltimateError(value: unknown): value is UltimateError {
147
182
  }
148
183
  }
149
184
 
185
+ /**
186
+ * The `callerFix` of an authorization denial (`X_FORBIDDEN`), ONE sentence for the five packages
187
+ * that refuse one — policy, action, query, auth, http — so a remote caller reads the same
188
+ * instruction whichever layer said no. A permission is granted by whoever administers the account,
189
+ * never by retrying, and saying so is what stops an agent looping on a refusal. `permission`, when
190
+ * the denial names a bare `resource:verb`, is what to ask for.
191
+ */
192
+ export function deniedCallerFix(permission?: string): string {
193
+ const ask =
194
+ permission === undefined
195
+ ? 'this caller is not permitted to do this: ask the account owner or an administrator for access'
196
+ : `this caller lacks the permission ${permission}: ask the account owner or an administrator to grant it`;
197
+ return `${ask}, or call with credentials that have it — retrying the same call is refused the same way`;
198
+ }
199
+
150
200
  /** Init for a subclass that owns its code. */
151
201
  export type CodedErrorInit = Omit<UltimateErrorInit, 'code'>;
152
202
 
@@ -46,14 +46,17 @@ export {
46
46
  } from '../error-retry';
47
47
  export type {
48
48
  CodedErrorInit,
49
+ ErrorAudience,
49
50
  FormatErrorOptions,
50
51
  UltimateErrorInit,
51
52
  UltimateErrorJSON,
52
53
  } from '../errors';
53
54
  export {
54
55
  ConfigInvalidError,
56
+ deniedCallerFix,
55
57
  EnvMissingError,
56
58
  errorRetry,
59
+ fixFor,
57
60
  formatError,
58
61
  InternalError,
59
62
  isUltimateError,
package/src/index.ts CHANGED
@@ -135,10 +135,13 @@ export type {
135
135
  SeoConfig,
136
136
  SeoConfigInput,
137
137
  SeoRobotsConfig,
138
+ SeoSitemapConfig,
138
139
  SiteConfig,
140
+ SitemapLastmod,
139
141
  SiteSections,
140
142
  SiteSectionsInput,
141
143
  } from './config-site';
144
+ export { SITEMAP_LASTMOD_SOURCES } from './config-site';
142
145
  export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-policy';
143
146
  export { resolveConflict } from './conflict-policy';
144
147
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
@@ -206,6 +209,7 @@ export {
206
209
  export type {
207
210
  CodedErrorInit,
208
211
  CoreErrorCode,
212
+ ErrorAudience,
209
213
  ErrorCodeDeclaration,
210
214
  ErrorCodeDescriptor,
211
215
  ErrorCodeEntry,
@@ -220,6 +224,7 @@ export {
220
224
  classifyThrown,
221
225
  DEFAULT_ERROR_RETRY,
222
226
  declaredErrorRetry,
227
+ deniedCallerFix,
223
228
  describeErrorCode,
224
229
  describeValue,
225
230
  EnvMissingError,
@@ -227,6 +232,7 @@ export {
227
232
  ERROR_RETRY_KINDS,
228
233
  errorCodeSnapshot,
229
234
  errorRetry,
235
+ fixFor,
230
236
  formatError,
231
237
  hasErrorCode,
232
238
  InternalError,