@giveitsmaller/sdk 0.19.0 → 0.21.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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +8 -15
  3. package/dist/_audit.js +14 -4
  4. package/dist/builder.d.ts +5 -4
  5. package/dist/builder.js +16 -16
  6. package/dist/client.d.ts +17 -7
  7. package/dist/client.js +116 -62
  8. package/dist/ergonomic/image_output_routes.d.ts +30 -0
  9. package/dist/ergonomic/image_output_routes.js +82 -11
  10. package/dist/ergonomic/option_types.d.ts +34 -1
  11. package/dist/ergonomic/option_types.js +5 -1
  12. package/dist/ergonomic/option_validation.d.ts +19 -1
  13. package/dist/ergonomic/option_validation.js +34 -1
  14. package/dist/ergonomic/preset_resolver.d.ts +2 -2
  15. package/dist/ergonomic/preset_resolver.js +6 -10
  16. package/dist/ergonomic/presets/index.d.ts +9 -8
  17. package/dist/ergonomic/presets/index.js +1 -9
  18. package/dist/errors.d.ts +105 -3
  19. package/dist/errors.js +136 -2
  20. package/dist/file-first.d.ts +234 -16
  21. package/dist/file-first.js +692 -175
  22. package/dist/generated/sdk_spec/enums.d.ts +0 -26
  23. package/dist/generated/sdk_spec/enums.js +0 -16
  24. package/dist/generated/sdk_spec/errors.d.ts +1 -1
  25. package/dist/generated/sdk_spec/errors.js +12 -0
  26. package/dist/generated/sdk_spec/presets.js +0 -14
  27. package/dist/generated/sdk_spec/version.d.ts +2 -2
  28. package/dist/generated/sdk_spec/version.js +2 -2
  29. package/dist/gisl.d.ts +93 -5
  30. package/dist/gisl.js +72 -2
  31. package/dist/handle.d.ts +6 -1
  32. package/dist/handle.js +42 -13
  33. package/dist/index.core.d.ts +10 -8
  34. package/dist/index.core.js +11 -3
  35. package/dist/merge.d.ts +12 -0
  36. package/dist/merge.js +14 -2
  37. package/dist/retry-metadata.d.ts +37 -0
  38. package/dist/retry-metadata.js +86 -0
  39. package/dist/sse.d.ts +2 -1
  40. package/dist/sse.js +26 -6
  41. package/dist/types.d.ts +54 -4
  42. package/dist/types.js +1 -0
  43. package/package.json +3 -3
  44. package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
  45. package/dist/ergonomic/presets/document_pdf_compress.js +0 -33
package/dist/client.js CHANGED
@@ -4,8 +4,12 @@
4
4
  // blobByteSource, which never touches these). Kept as a STATIC import (not a
5
5
  // dynamic one) so `vi.mock('node:fs/promises')` still intercepts it in tests.
6
6
  import { open, stat, basename } from './node-fs.js';
7
- import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
8
- import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
7
+ import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, LongFormConcurrencyLimitResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
8
+ import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislLongFormConcurrencyError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
9
+ // The `Retry-After` millisecond parser lives in the shared retry-metadata
10
+ // module (extracted to break the client ↔ errors circular import); re-imported
11
+ // here so the retry-loop timing stays byte-identical.
12
+ import { parseRetryAfterMs } from './retry-metadata.js';
9
13
  import { parseSseStream } from './sse.js';
10
14
  const DEFAULT_TIMEOUT_MS = 30_000;
11
15
  // SDK-internal aliases derived from the contract-pinned UploadThresholds enums
@@ -55,7 +59,25 @@ const S3_MAX_MULTIPART_PARTS = 10_000;
55
59
  // exists to prevent). codex review (high).
56
60
  const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
57
61
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
58
- const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
62
+ const DEFAULT_POLL_TIMEOUT_MS = 600_000; // 10 min
63
+ /**
64
+ * Re-tag a bare per-request transport {@link GislTimeoutError} with the workflow
65
+ * it was scoped to, so a timed-out workflow read (status / downloads) stays
66
+ * recoverable. No-op for any other error and for a timeout that already carries
67
+ * an id. See `oYumKo6y`. (PHP transport failures surface as `GislNetworkError`,
68
+ * so this enrichment is TS-only.)
69
+ */
70
+ function withWorkflowIdOnTimeout(err, workflowId) {
71
+ if (!(err instanceof GislTimeoutError) || err.workflowId !== undefined)
72
+ return err;
73
+ const enriched = new GislTimeoutError(err.message, workflowId);
74
+ // Preserve the original throw site + chain the cause — the enriched error is
75
+ // a re-tag, not a new failure, so diagnostics should still point at the
76
+ // transport timeout.
77
+ enriched.stack = err.stack;
78
+ enriched.cause = err;
79
+ return enriched;
80
+ }
59
81
  // Anonymous-read capability header. An anonymous (null-owner) workflow create
60
82
  // returns a one-time `cap` token (WorkflowCreateResponse.cap); the session-less
61
83
  // caller passes it back on status/downloads/events reads via this header so the
@@ -94,6 +116,18 @@ function headersToRecord(headers) {
94
116
  });
95
117
  return r;
96
118
  }
119
+ // Canonical human-readable fallback when a failure envelope carries NO `message`
120
+ // field. Kept IDENTICAL to the PHP SDK (`GislClient::fallbackErrorMessage`) so a
121
+ // message-absent error surfaces byte-identical `.message` text across SDKs
122
+ // (card U7MACpOj). Used by BOTH error-construction paths (`handleResponse` and
123
+ // the `getSchema` raw-response path) so they cannot drift. The machine code
124
+ // stays on `.errorCode` — this string is human DISPLAY text only; never parse
125
+ // it. `error` absent → `unknown_error` (PHP parity). Historically TS leaked the
126
+ // SCREAMING_SNAKE `error` code as `.message`, diverging from PHP's synthetic
127
+ // sentence; this unifies them on the human-shaped form.
128
+ function fallbackErrorMessage(status, errorCode) {
129
+ return `Request failed with status ${status} (${errorCode ?? 'unknown_error'}).`;
130
+ }
97
131
  function isValidationDetails(value) {
98
132
  return (Array.isArray(value) &&
99
133
  value.length > 0 &&
@@ -109,12 +143,14 @@ function isAbortError(err) {
109
143
  typeof err === 'object' &&
110
144
  err.name === 'AbortError');
111
145
  }
112
- // Retryable S3 PUT response statuses: 429 throttling, 503 slow-down, and any
113
- // other 5xx (502/504 are common transients behind CloudFront/S3). 4xx other
114
- // than 429 (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are
115
- // configuration / authority issues retrying just delays the real failure.
146
+ // Retryable S3 PUT response statuses: 408 request timeout, 429 throttling, 503
147
+ // slow-down, and any other 5xx (502/504 are common transients behind
148
+ // CloudFront/S3). 408 is a transient timeout on the PUT itself, so it is retried
149
+ // (matching the PHP SDK's S3-PUT predicate qz7MjNTy cross-SDK alignment). Other
150
+ // 4xx (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are configuration
151
+ // / authority issues — retrying just delays the real failure.
116
152
  function isRetryableStatus(status) {
117
- return status === 429 || (status >= 500 && status <= 599);
153
+ return status === 408 || status === 429 || (status >= 500 && status <= 599);
118
154
  }
119
155
  // fetch surfaces network failures (DNS, TLS, TCP reset, mid-body disconnect)
120
156
  // as TypeError. Abort surfaces as a DOMException with name='AbortError', not
@@ -123,31 +159,6 @@ function isRetryableStatus(status) {
123
159
  function isRetryableNetworkError(err) {
124
160
  return err instanceof TypeError;
125
161
  }
126
- // Parse an HTTP `Retry-After` header into milliseconds. Accepts the two RFC
127
- // 9110 forms: delta-seconds (e.g. "5") or an HTTP-date. Returns `undefined`
128
- // for an absent / unparseable / negative value (caller falls back to its own
129
- // backoff). A past HTTP-date clamps to 0.
130
- function parseRetryAfterMs(headerValue) {
131
- if (headerValue === undefined)
132
- return undefined;
133
- const trimmed = headerValue.trim();
134
- if (trimmed === '')
135
- return undefined;
136
- let ms;
137
- if (/^\d+$/.test(trimmed)) {
138
- ms = Number(trimmed) * 1000;
139
- }
140
- else {
141
- const when = Date.parse(trimmed);
142
- if (Number.isNaN(when))
143
- return undefined;
144
- ms = when - Date.now();
145
- }
146
- // A non-positive Retry-After (e.g. "0" or a past HTTP-date) must NOT short-
147
- // circuit the backoff to zero — treat it as absent so the caller falls back
148
- // to jitter and the loop can't busy-poll until timeout.
149
- return ms > 0 ? ms : undefined;
150
- }
151
162
  // Cancellable sleep for poll loops. Resolves after `ms`, or rejects with
152
163
  // `GislAbortError` if `signal` aborts. Resolves immediately for ms <= 0.
153
164
  function cancellableSleep(ms, signal) {
@@ -493,18 +504,26 @@ export class GislClient {
493
504
  messageKey: json.message_key,
494
505
  locale: json.locale,
495
506
  messageParams: json.message_params,
507
+ // The wire-stable machine code (SCREAMING_SNAKE `error`), surfaced as
508
+ // `error.errorCode` on every dispatched error (PHP parity). Threaded via
509
+ // this single options object → base GislApiError, GislValidationError,
510
+ // and every structured subclass (passed through as `extra`).
511
+ errorCode: typeof json.error === 'string' ? json.error : undefined,
496
512
  responseHeaders,
497
513
  contentLanguage,
498
514
  };
499
515
  // Human-readable text comes from `message` (the I26 localised field).
500
516
  // `error` is the stable, never-localised SCREAMING_SNAKE machine code —
501
- // NOT display text. Surfacing `error` as the thrown error's `.message`
502
- // regressed consumers that render the human string (x9Lbf6uy). Fall back
503
- // to `error` when `message` is absent (deployed contract guarantees
504
- // `message` on conforming error envelopes). Machine dispatch keys off
505
- // `error_type` (below), unchanged.
517
+ // NOT display text (surfacing it as `.message` regressed consumers that
518
+ // render the human string, x9Lbf6uy). When `message` is absent (the
519
+ // deployed contract guarantees it on conforming error envelopes), fall
520
+ // back to the canonical synthetic sentence byte-identical to the PHP
521
+ // SDK — instead of leaking the machine code (card U7MACpOj). The code is
522
+ // still carried on `.errorCode`. Machine dispatch keys off `error_type`
523
+ // (below), unchanged.
506
524
  const status = response.status;
507
- const errorMessage = json.message ?? json.error ?? 'Unknown error';
525
+ const errorMessage = json.message ??
526
+ fallbackErrorMessage(status, typeof json.error === 'string' ? json.error : undefined);
508
527
  // Validation-details branch first — preserve existing shape so callers
509
528
  // matching on `instanceof GislValidationError` keep working.
510
529
  if (isValidationDetails(json.details)) {
@@ -609,6 +628,15 @@ export class GislClient {
609
628
  tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
610
629
  typeof p.maxDurationSeconds === 'number');
611
630
  }
631
+ // Long-form concurrency limit (429) — a TIER quota, DISTINCT from a
632
+ // generic infra rate-limit 429. Dispatched on the machine `error` CODE
633
+ // (this envelope carries NO `error_type`); a generic rate-limit 429 has a
634
+ // different/absent code, so it falls through to the base GislApiError
635
+ // where `retryAfterSeconds` applies. The validator re-asserts the code so
636
+ // a malformed envelope falls through rather than mis-typing.
637
+ if (status === 429 && json.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED') {
638
+ tryThrowStructured(LongFormConcurrencyLimitResponseFromJSON, GislLongFormConcurrencyError, (p) => p.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED');
639
+ }
612
640
  // 413 = the absolute across-tier cap. The contract models 413 as a
613
641
  // plain `ErrorEnvelope` (no `error_type` discriminator, no typed
614
642
  // payload — api.yaml), so dispatch purely on status with no FromJSON
@@ -1617,10 +1645,15 @@ export class GislClient {
1617
1645
  * authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
1618
1646
  */
1619
1647
  async getWorkflowStatus(workflowId, opts = {}) {
1620
- return this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
1621
- deserialize: WorkflowStatusResponseFromJSON,
1622
- headers: workflowCapabilityHeaders(opts.capability),
1623
- });
1648
+ try {
1649
+ return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
1650
+ deserialize: WorkflowStatusResponseFromJSON,
1651
+ headers: workflowCapabilityHeaders(opts.capability),
1652
+ });
1653
+ }
1654
+ catch (err) {
1655
+ throw withWorkflowIdOnTimeout(err, workflowId);
1656
+ }
1624
1657
  }
1625
1658
  /**
1626
1659
  * Poll until the workflow reaches a terminal status.
@@ -1638,7 +1671,7 @@ export class GislClient {
1638
1671
  return status;
1639
1672
  }
1640
1673
  if (Date.now() + intervalMs > deadline) {
1641
- throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms`);
1674
+ throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms`, workflowId);
1642
1675
  }
1643
1676
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
1644
1677
  }
@@ -1695,10 +1728,15 @@ export class GislClient {
1695
1728
  * authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
1696
1729
  */
1697
1730
  async getWorkflowDownloads(workflowId, opts = {}) {
1698
- return this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/downloads`, {
1699
- deserialize: WorkflowDownloadResponseFromJSON,
1700
- headers: workflowCapabilityHeaders(opts.capability),
1701
- });
1731
+ try {
1732
+ return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/downloads`, {
1733
+ deserialize: WorkflowDownloadResponseFromJSON,
1734
+ headers: workflowCapabilityHeaders(opts.capability),
1735
+ });
1736
+ }
1737
+ catch (err) {
1738
+ throw withWorkflowIdOnTimeout(err, workflowId);
1739
+ }
1702
1740
  }
1703
1741
  /**
1704
1742
  * Stream SSE events for a workflow. Returns an async iterable.
@@ -1747,7 +1785,10 @@ export class GislClient {
1747
1785
  releaseConsumerSignal();
1748
1786
  }
1749
1787
  }
1750
- const inner = parseSseStream(response, { signal: controller.signal });
1788
+ const inner = parseSseStream(response, {
1789
+ signal: controller.signal,
1790
+ ...(opts.onParseError !== undefined ? { onParseError: opts.onParseError } : {}),
1791
+ });
1751
1792
  let started = false;
1752
1793
  let settled = false;
1753
1794
  // Idempotent teardown. `abort` only on consumer-driven early
@@ -1873,21 +1914,31 @@ export class GislClient {
1873
1914
  return { notModified: true, etag, lastModified };
1874
1915
  }
1875
1916
  if (!response.ok) {
1876
- let errorMessage = 'Unknown error';
1917
+ let errorCode;
1918
+ // Seed with the canonical synthetic sentence (unknown_error) so the
1919
+ // message-absent JSON branch below reuses the SAME helper as
1920
+ // handleResponse + PHP — that message-absent JSON envelope is the
1921
+ // U7MACpOj parity target. A non-JSON body is a rare edge that keeps
1922
+ // this seed (it does NOT claim byte-parity with PHP's non-JSON path,
1923
+ // which throws a distinct GislError).
1924
+ let errorMessage = fallbackErrorMessage(response.status, undefined);
1877
1925
  try {
1878
1926
  const errJson = (await response.json());
1879
- // Prefer the human `message`; `error` is the machine code (x9Lbf6uy).
1880
- if (errJson.message)
1881
- errorMessage = errJson.message;
1882
- else if (errJson.error)
1883
- errorMessage = errJson.error;
1927
+ // Surface the machine code as errorCode too (parity with handleResponse
1928
+ // + PHP), even when `message` supplied the human text.
1929
+ if (typeof errJson.error === 'string')
1930
+ errorCode = errJson.error;
1931
+ // Prefer the human `message`; else the canonical synthetic sentence —
1932
+ // NOT the raw machine code (x9Lbf6uy / U7MACpOj).
1933
+ errorMessage = errJson.message ?? fallbackErrorMessage(response.status, errorCode);
1884
1934
  }
1885
1935
  catch {
1886
- // Non-JSON body — keep generic message.
1936
+ // Non-JSON body — keep the generic synthetic message, no machine code.
1887
1937
  }
1888
1938
  // This throw is OUTSIDE handleResponse (rawResponse:true / 304 path), so
1889
1939
  // build the response-header surface from the in-scope `response` here.
1890
1940
  throw new GislApiError(response.status, errorMessage, path, undefined, {
1941
+ errorCode,
1891
1942
  responseHeaders: headersToRecord(response.headers),
1892
1943
  contentLanguage: response.headers.get('content-language') ?? undefined,
1893
1944
  });
@@ -1954,15 +2005,18 @@ export class GislClient {
1954
2005
  * via that cookie when the client is configured with
1955
2006
  * `useSessionCookie: true`.
1956
2007
  *
1957
- * Failure modes per ticket FX6mbTJD:
1958
- * - **401** `invalid_credentials` (collapsed with unverified
1959
- * accounts for anti-enumeration) `GislAuthError`.
1960
- * - **403** account-state failures (`account_locked`,
1961
- * `account_disabled`, `account_deleted`,
1962
- * `account_deletion_expired`) → `GislAuthError`.
2008
+ * Failure modes per ticket FX6mbTJD (login narrowed at contracts
2009
+ * v2.166.0 authsec no 403 account-state branch on login):
2010
+ * - **401** `invalid_credentials` (wrong password, unverified, OR
2011
+ * unknown account all collapsed for anti-enumeration)
2012
+ * `GislAuthError`.
1963
2013
  * - **429** infrastructure rate-limit → `GislApiError` with
1964
2014
  * the `Retry-After` header echoed on the response.
1965
2015
  *
2016
+ * The account-status error types (`account_locked` / `account_disabled`
2017
+ * / `account_deleted` / `account_deletion_expired`) still exist but are
2018
+ * emitted on the API-key path + live-session enforcement, not on login.
2019
+ *
1966
2020
  * Node session persistence (cookie-jar across processes) is out of
1967
2021
  * scope — this method only touches the request side.
1968
2022
  */
@@ -87,4 +87,34 @@ export declare function resolveOutputRoute(inputToken: string, outputFormat: str
87
87
  * option / value / group is unknown (no gate).
88
88
  */
89
89
  export declare function isPlannedValue(inputToken: string, optionKey: string, value: unknown): boolean;
90
+ /**
91
+ * Compress-route enum members per image mime-group, mirroring the shipped
92
+ * `availability/availability.json` `operations.compress.mime_groups.<group>.
93
+ * options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
94
+ * availability sidecar) so the enum-membership gate stays browser-safe, exactly
95
+ * like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
96
+ * dependency on a contracts version that carries the enum in a compact form (a
97
+ * generated-metadata `values` field would fail open on an older published
98
+ * `@giveitsmaller/contracts`). PINNED to `availability.json` by
99
+ * `output-route-conformance.test.ts`; a contract regen that adds/changes an
100
+ * enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
101
+ *
102
+ * `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
103
+ * `keep`) — the reason a value gate that consulted only the generic `image`
104
+ * group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
105
+ * those bases (rtkzl9gr). `output_format` is listed for a faithful projection
106
+ * mirror but is never gated here (the Output lowering owns it positionally).
107
+ */
108
+ export declare const COMPRESS_OPTION_VALUES: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
109
+ /**
110
+ * Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
111
+ * input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
112
+ * {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
113
+ * enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
114
+ * is never gated. Meaningful only on the same_format (compress) route, where
115
+ * the compress option enums definitionally apply. Membership is STRICT: a value
116
+ * whose type differs from the string enum members (e.g. numeric `420`) is
117
+ * treated as unknown rather than coerced to a match.
118
+ */
119
+ export declare function isUnknownEnumValue(inputToken: string, optionKey: string, value: unknown): boolean;
90
120
  export {};
@@ -29,8 +29,18 @@
29
29
  import { compressMetadata } from '@giveitsmaller/contracts/operations';
30
30
  /** The resize option keys — input-keyed, raster-only (see module doc). */
31
31
  export const RESIZE_KEYS = ['width', 'height', 'fit'];
32
- /** Set form of {@link RESIZE_KEYS} for `string`-keyed membership tests. */
33
- const RESIZE_KEY_SET = new Set(RESIZE_KEYS);
32
+ /**
33
+ * Options whose availability follows the INPUT format's raster capability, not
34
+ * the output format — resize (`width`/`height`/`fit`) plus `auto_orient`. The
35
+ * projection lists them on every `format_change` cell (keyed by OUTPUT), so on
36
+ * a format change they must be re-gated against the INPUT's `same_format` cell:
37
+ * a raster input carries them, an SVG (vector) input does not. Before rtkzl9gr
38
+ * only the resize keys were input-gated, so `auto_orient` leaked onto the
39
+ * `svg → raster` route and was rejected server-side.
40
+ */
41
+ const INPUT_GATED_KEYS = [...RESIZE_KEYS, 'auto_orient'];
42
+ /** Set form of {@link INPUT_GATED_KEYS} for `string`-keyed membership tests. */
43
+ const INPUT_GATED_KEY_SET = new Set(INPUT_GATED_KEYS);
34
44
  /** Image area cap shared by every resizable route (projection `max_output_pixels`). */
35
45
  export const MAX_OUTPUT_PIXELS = 16_000_000;
36
46
  /**
@@ -117,21 +127,22 @@ export function resolveOutputRoute(inputToken, outputFormat) {
117
127
  const cell = IMAGE_OUTPUT_ROUTES.format_change[outToken];
118
128
  if (cell === undefined)
119
129
  return undefined;
120
- // Resize is INPUT-gated. Since v2.103.0 convert is the resize engine, so the
121
- // projection lists width/height/fit on EVERY format_change cell but an SVG
122
- // INPUT cannot be raster-resized (the convert worker rejects it). So strip the
123
- // cell's resize keys and re-add only those the INPUT's same_format cell honors:
124
- // raster inputs carry them, svg does not. The transcoder options (output_format/
125
- // quality/background) ride the cell directly.
126
- const transcoderHonored = cell.honored.filter((k) => !RESIZE_KEY_SET.has(k));
130
+ // Resize + auto_orient are INPUT-gated (see {@link INPUT_GATED_KEYS}). Since
131
+ // v2.103.0 convert is the resize engine, so the projection lists width/height/
132
+ // fit AND auto_orient on EVERY format_change cell but an SVG INPUT cannot be
133
+ // raster-resized or auto-oriented (the convert worker rejects it). So strip
134
+ // the cell's input-gated keys and re-add only those the INPUT's same_format
135
+ // cell honors: raster inputs carry them, svg does not. The transcoder options
136
+ // (output_format/quality/background/color_profile) ride the cell directly.
137
+ const transcoderHonored = cell.honored.filter((k) => !INPUT_GATED_KEY_SET.has(k));
127
138
  const inCell = IMAGE_OUTPUT_ROUTES.same_format[inputToken];
128
- const resize = inCell ? RESIZE_KEYS.filter((k) => inCell.honored.includes(k)) : [];
139
+ const inputGated = inCell ? INPUT_GATED_KEYS.filter((k) => inCell.honored.includes(k)) : [];
129
140
  return {
130
141
  route: 'format_change',
131
142
  sourceOp: 'convert',
132
143
  outputFormatWire: outToken,
133
144
  inputToken,
134
- honored: new Set([...transcoderHonored, ...resize]),
145
+ honored: new Set([...transcoderHonored, ...inputGated]),
135
146
  planned: new Set(cell.planned),
136
147
  };
137
148
  }
@@ -161,3 +172,63 @@ export function isPlannedValue(inputToken, optionKey, value) {
161
172
  const entry = opt.per_value_availability[String(value)];
162
173
  return entry?.availability === 'planned';
163
174
  }
175
+ /**
176
+ * Compress-route enum members per image mime-group, mirroring the shipped
177
+ * `availability/availability.json` `operations.compress.mime_groups.<group>.
178
+ * options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
179
+ * availability sidecar) so the enum-membership gate stays browser-safe, exactly
180
+ * like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
181
+ * dependency on a contracts version that carries the enum in a compact form (a
182
+ * generated-metadata `values` field would fail open on an older published
183
+ * `@giveitsmaller/contracts`). PINNED to `availability.json` by
184
+ * `output-route-conformance.test.ts`; a contract regen that adds/changes an
185
+ * enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
186
+ *
187
+ * `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
188
+ * `keep`) — the reason a value gate that consulted only the generic `image`
189
+ * group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
190
+ * those bases (rtkzl9gr). `output_format` is listed for a faithful projection
191
+ * mirror but is never gated here (the Output lowering owns it positionally).
192
+ */
193
+ export const COMPRESS_OPTION_VALUES = {
194
+ image: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
195
+ image_jpeg: { chroma_subsampling: ['420', '422', '444'], color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
196
+ image_png: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
197
+ image_avif: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
198
+ image_svg: { metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
199
+ image_webp: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
200
+ };
201
+ /**
202
+ * The compress mime-group whose enum members are authoritative for an image
203
+ * token's SAME_FORMAT route — the exact `image_<token>` group when
204
+ * {@link COMPRESS_OPTION_VALUES} carries one, else the generic `image` group
205
+ * (gif/tiff).
206
+ *
207
+ * Deliberately DISTINCT from {@link compressGroupForToken} (which the planned
208
+ * gate uses). The planned gate routes webp/gif/svg/tiff through the generic
209
+ * `image` group, where cross-format `planned` markers live (e.g. `srgb`).
210
+ * Enum MEMBERSHIP is the opposite: it needs the format-specific enum, because
211
+ * `image_svg`'s `metadata` enum is the narrow `[strip, all]` while the generic
212
+ * group's is `[strip, keep, all]` — so only the specific group rejects
213
+ * `metadata: 'keep'` on SVG (and AVIF, which already maps specifically).
214
+ */
215
+ function enumGroupForToken(token) {
216
+ const specific = `image_${token}`;
217
+ return COMPRESS_OPTION_VALUES[specific] !== undefined ? specific : 'image';
218
+ }
219
+ /**
220
+ * Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
221
+ * input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
222
+ * {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
223
+ * enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
224
+ * is never gated. Meaningful only on the same_format (compress) route, where
225
+ * the compress option enums definitionally apply. Membership is STRICT: a value
226
+ * whose type differs from the string enum members (e.g. numeric `420`) is
227
+ * treated as unknown rather than coerced to a match.
228
+ */
229
+ export function isUnknownEnumValue(inputToken, optionKey, value) {
230
+ const members = COMPRESS_OPTION_VALUES[enumGroupForToken(inputToken)]?.[optionKey];
231
+ if (members === undefined)
232
+ return false;
233
+ return !(typeof value === 'string' && members.includes(value));
234
+ }
@@ -74,6 +74,12 @@ export interface ThumbnailOptions {
74
74
  /** 1-based page index for document input. */
75
75
  page?: number;
76
76
  }
77
+ export interface TransformOptions {
78
+ /** Clockwise rotation in degrees. document_pdf honors `rotate` only. */
79
+ rotate?: 0 | 90 | 180 | 270;
80
+ /** Mirror axis (applied after `rotate`). Not honored on document_pdf input. */
81
+ flip?: 'none' | 'horizontal' | 'vertical' | 'both';
82
+ }
77
83
  export interface TextWatermarkOptions {
78
84
  /** Font size in pixels (8-512). */
79
85
  font_size?: number;
@@ -96,6 +102,24 @@ export interface TextWatermarkOptions {
96
102
  /** Overlay opacity (0-1). */
97
103
  opacity?: number;
98
104
  }
105
+ /**
106
+ * One entry in the multi-overlay stack (contract `overlays[]` items, v2.152.0).
107
+ * Index-aligned to the overlay-role sources — `overlays[i]` places overlay
108
+ * source `i` — and mirrors the flat single-overlay option shape. Matches the
109
+ * generated `ImageWatermarkImageOverlaysItem`.
110
+ */
111
+ export interface WatermarkOverlay {
112
+ /** 9-grid anchor position for this overlay. */
113
+ anchor?: WatermarkAnchor;
114
+ /** Horizontal offset from the anchor (e.g. '40px' or '5%'). */
115
+ margin_x?: string;
116
+ /** Vertical offset from the anchor. */
117
+ margin_y?: string;
118
+ /** Overlay opacity (0-1). */
119
+ opacity?: number;
120
+ /** Overlay width (e.g. '120px' or '20%'). */
121
+ overlay_width?: string;
122
+ }
99
123
  export interface WatermarkOptions {
100
124
  /** 9-grid anchor position. */
101
125
  anchor?: WatermarkAnchor;
@@ -107,6 +131,14 @@ export interface WatermarkOptions {
107
131
  opacity?: number;
108
132
  /** Overlay width (e.g. '120px' or '20%'). */
109
133
  overlay_width?: string;
134
+ /**
135
+ * Per-overlay placement for the multi-overlay stack (contract `overlays[]`,
136
+ * v2.152.0) — one entry per overlay source, index-aligned; stacks up to 8
137
+ * overlays on one base image (z-order = array index). MUTUALLY EXCLUSIVE with
138
+ * the flat single-overlay options above; the server rejects mixing the two as
139
+ * `invalid_options`. image_watermark jpeg/png/webp bases only.
140
+ */
141
+ overlays?: WatermarkOverlay[];
110
142
  }
111
143
  /** Resize mode (contract `fit` enum, v2.97.0). */
112
144
  export type OutputFit = 'max' | 'crop' | 'scale';
@@ -182,7 +214,8 @@ export interface OutputOptions {
182
214
  export declare const VERB_OPTION_KEYS: {
183
215
  readonly convert: readonly ["quality", "background", "crf", "trim_start", "trim_end", "fps", "width", "height", "fit", "metadata", "color_profile", "auto_orient", "max_colors", "loop", "dither", "bitrate", "pages", "dpi"];
184
216
  readonly thumbnail: readonly ["width", "height", "fit", "format", "quality", "background", "timestamp", "source", "page"];
217
+ readonly transform: readonly ["rotate", "flip"];
185
218
  readonly textWatermark: readonly ["font_size", "color", "font_family", "rotation", "watermark_mode", "tile_spacing", "anchor", "margin_x", "margin_y", "opacity"];
186
- readonly watermark: readonly ["anchor", "margin_x", "margin_y", "opacity", "overlay_width"];
219
+ readonly watermark: readonly ["anchor", "margin_x", "margin_y", "opacity", "overlay_width", "overlays"];
187
220
  readonly output: readonly ["quality", "quality_preset", "encoding_mode", "target_size_bytes", "chroma_subsampling", "width", "height", "fit", "background", "progressive", "optimization_level", "avif_speed", "metadata", "color_profile", "auto_orient", "lossless"];
188
221
  };
@@ -21,12 +21,13 @@ const CONVERT_OPTION_KEYS = [
21
21
  const THUMBNAIL_OPTION_KEYS = [
22
22
  'width', 'height', 'fit', 'format', 'quality', 'background', 'timestamp', 'source', 'page',
23
23
  ];
24
+ const TRANSFORM_OPTION_KEYS = ['rotate', 'flip'];
24
25
  const TEXT_WATERMARK_OPTION_KEYS = [
25
26
  'font_size', 'color', 'font_family', 'rotation', 'watermark_mode',
26
27
  'tile_spacing', 'anchor', 'margin_x', 'margin_y', 'opacity',
27
28
  ];
28
29
  const WATERMARK_OPTION_KEYS = [
29
- 'anchor', 'margin_x', 'margin_y', 'opacity', 'overlay_width',
30
+ 'anchor', 'margin_x', 'margin_y', 'opacity', 'overlay_width', 'overlays',
30
31
  ];
31
32
  const OUTPUT_OPTION_KEYS = [
32
33
  'quality', 'quality_preset', 'encoding_mode', 'target_size_bytes', 'chroma_subsampling', 'width', 'height', 'fit',
@@ -35,12 +36,14 @@ const OUTPUT_OPTION_KEYS = [
35
36
  ];
36
37
  const _convertKeysMatch = true;
37
38
  const _thumbnailKeysMatch = true;
39
+ const _transformKeysMatch = true;
38
40
  const _textWatermarkKeysMatch = true;
39
41
  const _watermarkKeysMatch = true;
40
42
  const _outputKeysMatch = true;
41
43
  // Reference the assertions so `noUnusedLocals` doesn't strip them.
42
44
  void _convertKeysMatch;
43
45
  void _thumbnailKeysMatch;
46
+ void _transformKeysMatch;
44
47
  void _textWatermarkKeysMatch;
45
48
  void _watermarkKeysMatch;
46
49
  void _outputKeysMatch;
@@ -52,6 +55,7 @@ void _outputKeysMatch;
52
55
  export const VERB_OPTION_KEYS = {
53
56
  convert: CONVERT_OPTION_KEYS,
54
57
  thumbnail: THUMBNAIL_OPTION_KEYS,
58
+ transform: TRANSFORM_OPTION_KEYS,
55
59
  textWatermark: TEXT_WATERMARK_OPTION_KEYS,
56
60
  watermark: WATERMARK_OPTION_KEYS,
57
61
  output: OUTPUT_OPTION_KEYS,
@@ -25,7 +25,7 @@ import { type OperationMetadata } from '@giveitsmaller/contracts/operations';
25
25
  */
26
26
  export declare function operationOptionKeys(metadata: OperationMetadata): ReadonlySet<string>;
27
27
  /** The ergonomic verbs whose option bags this module key-validates. */
28
- export type ValidatedVerb = 'convert' | 'thumbnail' | 'textWatermark' | 'watermark' | 'output';
28
+ export type ValidatedVerb = 'convert' | 'thumbnail' | 'transform' | 'textWatermark' | 'watermark' | 'output';
29
29
  /** Accessor for the conformance guard (pins these sets to the contract metadata). */
30
30
  export declare function allowedKeysFor(verb: ValidatedVerb): ReadonlySet<string>;
31
31
  /**
@@ -39,6 +39,24 @@ export declare function allowedKeysFor(verb: ValidatedVerb): ReadonlySet<string>
39
39
  * option set.
40
40
  */
41
41
  export declare function validateVerbOptions(verb: ValidatedVerb, options: object | null | undefined): void;
42
+ /**
43
+ * Validate the option bag for the SINGLE-OP builder `gisl().convert(input, options)`
44
+ * (ExVcchMz). DISTINCT from `validateVerbOptions('convert', ...)`, which is for the
45
+ * file-first `Recipe.convert(format, options)` where `output_format` is set by the
46
+ * positional `format` arg and is therefore positional-owned (rejected in the bag).
47
+ * The single-op builder has NO positional format — its target is carried in the bag
48
+ * as the wire key `output_format` — so this guard ALLOWS `output_format` (and only
49
+ * that; the SDK alias `format` is NOT accepted, the single-op bag lowers verbatim to
50
+ * the wire) while still rejecting any other unknown key, AND requires `output_format`
51
+ * to be present (a convert with no target is a guaranteed server 422). `format`, the
52
+ * SDK alias, is intentionally excluded so a caller using it gets a clear unknown-key
53
+ * error rather than a silent wire `format` the server 422s.
54
+ *
55
+ * @throws {GislConfigError} reason `unknown_field` for a key outside the convert
56
+ * contract set ∪ {output_format}; reason `missing_required_field` when
57
+ * `output_format` is absent/nullish.
58
+ */
59
+ export declare function validateSingleOpConvertOptions(options: object | null | undefined): void;
42
60
  /**
43
61
  * Assert thumbnail `width` AND `height` are both present and non-nullish (the
44
62
  * contract marks both `required` for image/video/document). The typed signature
@@ -1,4 +1,4 @@
1
- import { convertMetadata, thumbnailMetadata, textWatermarkMetadata, imageWatermarkMetadata, videoWatermarkMetadata, } from '@giveitsmaller/contracts/operations';
1
+ import { convertMetadata, thumbnailMetadata, transformMetadata, textWatermarkMetadata, imageWatermarkMetadata, videoWatermarkMetadata, } from '@giveitsmaller/contracts/operations';
2
2
  import { GislConfigError } from '../errors.js';
3
3
  import { VERB_OPTION_KEYS } from './option_types.js';
4
4
  /**
@@ -51,6 +51,9 @@ function union(...sets) {
51
51
  const ALLOWED_KEYS = {
52
52
  convert: operationOptionKeys(convertMetadata),
53
53
  thumbnail: operationOptionKeys(thumbnailMetadata),
54
+ // transform is a passthrough verb (rotate/flip). The generic allowed set is
55
+ // the op-wide union {rotate, flip}; `flip`-on-PDF is narrowed server-side.
56
+ transform: operationOptionKeys(transformMetadata),
54
57
  textWatermark: operationOptionKeys(textWatermarkMetadata),
55
58
  watermark: union(operationOptionKeys(imageWatermarkMetadata), operationOptionKeys(videoWatermarkMetadata)),
56
59
  // `output` is the image Output facade — its allowed keys are the UNION of every
@@ -115,6 +118,36 @@ export function validateVerbOptions(verb, options) {
115
118
  }
116
119
  }
117
120
  }
121
+ /**
122
+ * Validate the option bag for the SINGLE-OP builder `gisl().convert(input, options)`
123
+ * (ExVcchMz). DISTINCT from `validateVerbOptions('convert', ...)`, which is for the
124
+ * file-first `Recipe.convert(format, options)` where `output_format` is set by the
125
+ * positional `format` arg and is therefore positional-owned (rejected in the bag).
126
+ * The single-op builder has NO positional format — its target is carried in the bag
127
+ * as the wire key `output_format` — so this guard ALLOWS `output_format` (and only
128
+ * that; the SDK alias `format` is NOT accepted, the single-op bag lowers verbatim to
129
+ * the wire) while still rejecting any other unknown key, AND requires `output_format`
130
+ * to be present (a convert with no target is a guaranteed server 422). `format`, the
131
+ * SDK alias, is intentionally excluded so a caller using it gets a clear unknown-key
132
+ * error rather than a silent wire `format` the server 422s.
133
+ *
134
+ * @throws {GislConfigError} reason `unknown_field` for a key outside the convert
135
+ * contract set ∪ {output_format}; reason `missing_required_field` when
136
+ * `output_format` is absent/nullish.
137
+ */
138
+ export function validateSingleOpConvertOptions(options) {
139
+ const o = (options ?? {});
140
+ const allowed = new Set([...ALLOWED_KEYS.convert, 'output_format']);
141
+ for (const key of Object.keys(o)) {
142
+ if (!allowed.has(key)) {
143
+ throw new GislConfigError(`convert: unknown option '${key}'. Valid options: ${[...allowed].sort().join(', ')}.`, { reason: 'unknown_field', conflictingFields: [key] });
144
+ }
145
+ }
146
+ if (o.output_format === undefined || o.output_format === null) {
147
+ throw new GislConfigError(`convert requires 'output_format' (the target format) in the options bag; ` +
148
+ `e.g. gisl().convert(input, { output_format: 'webp' }).`, { reason: 'missing_required_field', conflictingFields: ['output_format'] });
149
+ }
150
+ }
118
151
  /**
119
152
  * Assert thumbnail `width` AND `height` are both present and non-nullish (the
120
153
  * contract marks both `required` for image/video/document). The typed signature