You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat!: align types and client with latest API spec (#14)
## Summary
- Update all types to match current OpenAPI spec (breaking changes)
- Create endpoint moved from `/v1/qurl` to `/v1/qurls`
- `CreateInput.description` renamed to `label`; `metadata` removed
- `QURL` response: removed `one_time_use`, `max_sessions`, `qurl_link`;
added `tags`, `custom_domain`, `qurl_count`; `qurls` wire field mapped
to `access_tokens` (see note below); `status` narrowed to `"active" |
"revoked"` (see note — TS consumers pattern-matching on `"consumed"` /
`"expired"` get a **compile** error)
- `UpdateInput`: removed `access_policy`; added `tags`
- `MintInput` expanded with full per-QURL settings
- New `batchCreate()` method for `POST /v1/qurls/batch`
- `AccessPolicy` now includes `ai_agent_policy`
- `Quota.rate_limits` adds `max_expiry_seconds`; `active_qurls_percent`
now nullable
## Migration notes
No live consumers exist yet (the SDK is still pre-1.0 /
pre-first-release), so this is a heads-up rather than an upgrade-on-fire
migration guide. For the first consumers coming online after this lands:
**⚠️ `batchCreate()` does not throw on partial/total failure**
The new batch endpoint uses an HTTP 400 "passthrough" pattern: when
every item in a batch fails validation, the API returns 400 with a
populated `BatchCreateOutput` body, and the SDK surfaces that body as a
normal return value rather than throwing. This is a deliberate design
choice — throwing would swallow the per-item error details callers
actually need to fix a bad batch.
**Consumers who only use `try/catch` for error handling will silently
miss partial failures.** You must also inspect `result.failed` and
iterate `result.results`:
```ts
const result = await client.batchCreate({ items: [...] });
if (result.failed > 0) {
for (const r of result.results) {
if (!r.success) {
console.error(`items[${r.index}]: ${r.error.code} - ${r.error.message}`);
}
}
}
```
Non-400 error statuses (401, 403, 429, 5xx, and unexpected 400 body
shapes) still throw the appropriate `QURLError` subclass.
**`CreateInput.description` → `label`, no absolute-expiry on create**
```ts
// Before
await client.create({
target_url: "https://example.com",
description: "Alice from Acme",
expires_at: "2026-04-01T00:00:00Z",
});
// After
await client.create({
target_url: "https://example.com",
label: "Alice from Acme",
expires_in: "24h",
});
```
Per the OpenAPI spec (`CreateQurlRequest`), the create endpoint only
accepts relative `expires_in`, not absolute `expires_at`. To set an
absolute expiry at creation time, use the minimum practical initial
expiry and immediately update:
```ts
// Minimum practical initial expiry ("1m") minimizes the window
// during which the QURL is live with the wrong expiry.
const qurl = await client.create({
target_url: "https://example.com",
expires_in: "1m",
});
await client.update(qurl.resource_id, { expires_at: "2026-04-01T00:00:00Z" });
```
There's an unavoidable ~1-second window between `create()` and
`update()` where the QURL is live with the initial 1-minute expiry. For
hard-synchronous absolute-expiry guarantees, gate access behind your own
authorization layer until after the update completes.
**`UpdateInput.access_policy` removed**
Access policy is now set only at create time. Per the OpenAPI
`UpdateQurlRequest` schema, the update surface only accepts `extend_by`,
`expires_at`, `tags`, `description`. Move any `access_policy` usage from
`update()` to `create()`.
**`QURL.custom_domain` — deliberate `string | null` vs `string |
undefined` asymmetry**
Read-side is `string | null` (matching the OpenAPI `nullable: true`
declaration on `ResourceData.custom_domain`). Write-side on
`CreateInput.custom_domain` is `string | undefined` (absent = "don't
set"). The asymmetry is intentional: JSON `null` and an absent field
have different semantics, and the API surface uses the convention across
reads and writes.
**`Quota.active_qurls_percent` is now `number | null`**
Previously always a number; now returns `null` when the plan has
unlimited active QURLs. Callers doing arithmetic on this field will get
a runtime error if they don't handle `null`:
```ts
// Before
const pct = quota.usage.active_qurls_percent; // always number
// After
const pct = quota.usage.active_qurls_percent ?? 0; // null when unlimited
```
**`QURL.status` type narrowed to resource-level values**
`status` on the `QURL` type was narrowed from `"active" | "consumed" |
"revoked" | "expired"` to `"active" | "revoked"`. The SDK's `QURL` type
maps to the API's `QurlData` schema, which is the resource-level
response envelope (groups access tokens under a protected URL). The API
spec defines `status: enum [active, revoked]` on this schema —
`"consumed"` and `"expired"` are per-token statuses that appear on
individual access tokens within the `qurls` array, not on the parent
container. Any TypeScript consumer pattern-matching on `"consumed"` or
`"expired"` at this level will get a compile error.
**`CreateInput.metadata` removed**
The `metadata` field was removed from `CreateInput` — the current API
spec does not include it on `CreateQurlRequest`. If you were passing
`metadata`, the field is no longer accepted. There is no direct
replacement in the current spec.
**`list()` `limit: 0` now rejected client-side**
Previously `limit: 0` was passed through to the server. The new
client-side validation enforces the OpenAPI spec's `minimum: 1, maximum:
100` range and throws `ValidationError` before the request. Callers
using `limit: 0` to mean "use server default" should omit `limit`
entirely instead.
**`qurls` → `access_tokens` field mapping (load-bearing)**
The API currently returns nested access tokens under the wire field name
`qurls`. The SDK's `QURL` type uses `access_tokens` as the
consumer-facing property name — the rename is performed by
`mapQurlsField` on every `get()`, `list()`, and `update()` response.
This mapping is **load-bearing** (the API has not yet migrated the wire
name), not cosmetic back-compat. The method also handles the future case
where the API sends both fields during a migration: `access_tokens`
wins, `qurls` is dropped, and a debug log fires with both counts so
operators can track the transition.
## Test plan
- [x] `npm run build` compiles with no type errors
- [x] `npm run lint` passes clean
- [x] `npm run format:check` passes clean
- [x] `npm test` — 171 tests passing
- [x] New tests for batch create (207 Multi-Status, shape guards,
collect-all validation, partial-failure 400 path, request_id
propagation, index-attribution), client-side validation boundaries,
untyped-JS edge cases, and retry policy per HTTP method
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Justin <[email protected]>
@@ -49,7 +49,7 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
49
49
|`apiKey`| Yes | — |
50
50
|`baseUrl`| No |`https://api.layerv.ai`|
51
51
|`maxRetries`| No |`3`|
52
-
|`timeout`| No |`30000` (ms) |
52
+
|`timeout`| No |`30000` (ms) — *per attempt*, not total |
53
53
|`fetch`| No |`globalThis.fetch`|
54
54
|`userAgent`| No |`qurl-typescript/<version>`|
55
55
|`debug`| No |`false`|
@@ -59,6 +59,7 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
59
59
| Method | Description |
60
60
|--------|-------------|
61
61
|`create(input)`| Create a protected link |
62
+
|`batchCreate(input)`| Create up to 100 protected links in one request |
62
63
|`get(id)`| Get qURL details |
63
64
|`list(input?)`| List qURLs (single page) |
64
65
|`listAll(input?)`| Iterate all qURLs (auto-paginating) |
@@ -69,6 +70,35 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
69
70
|`resolve(input)`| Resolve token + open firewall |
70
71
|`getQuota()`| Get quota/usage info |
71
72
73
+
### `batchCreate(input)`
74
+
75
+
Create up to 100 qURLs in a single request. **Does not throw on partial or total failure** — per-item errors are returned in the `results` array, so `try/catch` alone won't surface them. Always inspect `result.failed` and iterate `result.results`:
Non-400 errors (401, 403, 429, 5xx, and unexpected 400 body shapes) still throw the appropriate `QURLError` subclass.
95
+
96
+
**Slimmer per-item shape** — `BatchItemSuccess` returns `{ resource_id, qurl_link, qurl_site, expires_at? }` per item. Unlike single `client.create()`, the batch response intentionally **omits `qurl_id` and `label`** to keep the payload compact. If you migrate a per-item `create()` loop to `batchCreate` and rely on `qurl_id` for downstream addressing, fetch each via `client.get(resource_id)` after the batch (or stay on the single-create path).
97
+
98
+
**Result ordering** — `result.results` is **not** guaranteed to be sorted by `index`. Each entry's `index` field carries the position in the original `items` array, so build per-input-position state by keying on `r.index` (e.g., `for (const r of result.results) { byInputIndex[r.index] = r; }`) rather than relying on iteration order.
99
+
100
+
**Out-of-range or duplicate `index` values** — the SDK throws `QURLError` (`code: "unexpected_response"`) on either condition, since both indicate server misbehavior that would silently break per-item attribution (a `Map` keyed on `r.index` would last-write-wins, an out-of-range index would attribute to a non-existent slot).
101
+
72
102
## Error Handling
73
103
74
104
All API errors throw typed error subclasses, so you can catch specific failure modes:
@@ -144,10 +174,18 @@ The client automatically retries failed requests with exponential backoff:
144
174
-**GET/DELETE**: Retries on 429, 502, 503, 504
145
175
-**POST/PATCH**: Retries only on 429 (to avoid duplicate side effects)
146
176
-**Network errors**: Always retried
147
-
-**429 responses**: Honors `Retry-After` header
177
+
-**`Retry-After` header**: Honored on 429 and 503 responses (RFC 7231 §7.1.3). Currently the SDK only parses **delta-seconds** values (e.g. `Retry-After: 30`); HTTP-date values (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`) silently fall back to exponential backoff. Tracked in [#61](https://github.com/layervai/qurl-typescript/issues/61).
148
178
149
179
Configure with `maxRetries` (default: 3). Set to `0` to disable.
150
180
181
+
> **Worst-case latency**: `timeout` is enforced per *attempt*, not for the whole request. Total worst-case latency is roughly `timeout × (maxRetries + 1) + sum(retry delays)`. Operators tuning `timeout` should account for this when sizing health-check budgets.
182
+
183
+
## Versioning & breaking changes
184
+
185
+
This SDK is pre-1.0; breaking changes between minor versions are possible until the API surface stabilizes. Significant changes are called out in [`CHANGELOG.md`](CHANGELOG.md) and in the corresponding GitHub release notes.
186
+
187
+
When upgrading, check the release notes for migration guidance — recent breaking changes have included field renames (`description` → `label` on create), removed fields (`metadata`), narrowed type unions (`QURL.status`), and endpoint relocations (`/v1/qurl` → `/v1/qurls`).
0 commit comments