@k9o/core 1.1.1 → 1.3.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/src/projects.ts CHANGED
@@ -5,51 +5,87 @@
5
5
  * `discovery.ts`):
6
6
  * - `client.projects.list()` → GET /projects
7
7
  * - `client.projects.create({...})` → POST /projects
8
- * - `client.projects.get(slug)` → GET /@<projectSlug>/project
9
- * - `client.projects.update(slug,...)` → PATCH /@<projectSlug>/project
8
+ * - `client.projects.get(slug)` → GET /projects/<projectSlug>
9
+ * - `client.projects.update(slug,...)` → PATCH /projects/<projectSlug>
10
10
  *
11
- * The `list` + `create` operations operate org-plane and require
12
- * `orgSlug` to be bound on the client (or the bearer token to carry
13
- * an org context). The `get` + `update` operations
14
- * operate member-plane and use the project slug.
11
+ * All four operations are org-plane: the client needs an org context
12
+ * (an `orgSlug` or a bearer token that carries one). Input property
13
+ * names are the gateway's body property names.
15
14
  *
16
- * Per: all methods throw KError on non-2xx.
15
+ * All methods throw KError on non-2xx.
17
16
  */
18
17
 
19
18
  import { request, type HttpClientConfig } from "./http.ts";
20
- import { orgPath, projectPath, validateSlug } from "./url-grammar.ts";
19
+ import { orgPath, validateSlug } from "./url-grammar.ts";
21
20
  import type { Envelope } from "./envelope.ts";
22
21
 
23
22
  /**
24
- * Project shape. Fields are typed per the canonical shape;
25
- * the gateway may return additional fields that this type doesn't
26
- * narrow — those are accessible via `as unknown as` widening.
23
+ * Project record, as the gateway returns it. Field names are passed through unchanged.
24
+ * Lists and creates also carry `capabilities`; lists add `accessLevel`.
27
25
  */
28
26
  export type Project = {
29
27
  /** `proj_<base62>`. */
30
28
  id: string;
31
29
  slug: string;
32
- name: string;
33
- custodyMode: "managed" | "byok" | "hyok";
34
- createdAt: string;
35
- updatedAt: string | null;
36
30
  orgId: string;
31
+ displayName: string;
32
+ description: string | null;
33
+ productScope: string;
34
+ /** Region code of the project's data. */
35
+ region: string;
36
+ custodyMode: "PLATFORM_MANAGED" | "SKR" | "BYOK" | "HYOK";
37
+ /** `provisioning` while the project is being set up, then `active`; other values may appear. */
38
+ status: string;
39
+ /** The caller's role on the project, or `null` when the caller is not a member. */
40
+ role: string | null;
41
+ deletedAt: string | null;
42
+ createdAt: string;
43
+ /** Soft-delete retention window in days. */
44
+ softDeleteRetentionDays: number;
45
+ /** Regions the project's data may live in; `[region]` for a single-region project. */
46
+ residencySet: ReadonlyArray<string>;
47
+ /** `"strict"`, or `{ fallbackRegion }` when a catch-all region is set. */
48
+ residencyPolicy: "strict" | { fallbackRegion: string };
49
+ processingContexts: ReadonlyArray<string>;
50
+ /** Present on list and create responses: what the caller may write. */
51
+ capabilities?: { authFlows: { write: boolean }; authProviders: { write: boolean } } | undefined;
52
+ /** Present on list responses: `member` or `locked`. */
53
+ accessLevel?: "member" | "locked" | undefined;
37
54
  };
38
55
 
39
56
  /**
40
57
  * Project-creation input.
41
58
  */
42
59
  export type CreateProjectInput = {
43
- slug: string;
44
- name: string;
45
- custodyMode?: "managed" | "byok" | "hyok" | undefined;
60
+ /** Display name (1 to 128 characters). Required. */
61
+ displayName: string;
62
+ /** URL slug (lowercase); the gateway derives one from `displayName` when omitted. */
63
+ slug?: string | undefined;
64
+ productScope?: "core" | "kibo" | undefined;
65
+ /** Region code of the project's data. */
66
+ region?: string | undefined;
67
+ defaultProjectRegion?: string | undefined;
68
+ custodyMode?: "PLATFORM_MANAGED" | "SKR" | "BYOK" | "HYOK" | undefined;
69
+ processingContexts?: ReadonlyArray<string> | undefined;
70
+ execSeamConfined?: boolean | undefined;
71
+ showroomVertical?: string | undefined;
72
+ persona?: string | undefined;
73
+ showroomLocale?: { code: string; currency: string; moneyScale: number } | undefined;
74
+ /** Makes a retried create return the first result instead of a second project. */
75
+ idempotencyKey?: string | undefined;
46
76
  };
47
77
 
48
78
  /**
49
79
  * Project-update patch.
50
80
  */
51
81
  export type UpdateProjectInput = {
52
- name?: string;
82
+ slug?: string | undefined;
83
+ displayName?: string | undefined;
84
+ description?: string | null | undefined;
85
+ region?: string | undefined;
86
+ productScope?: string | undefined;
87
+ softDeleteRetentionDays?: number | undefined;
88
+ processingContexts?: ReadonlyArray<string> | undefined;
53
89
  };
54
90
 
55
91
  /**
@@ -73,7 +109,7 @@ export function makeProjectsNamespace(config: HttpClientConfig) {
73
109
  * Create a new project under the caller's active org.
74
110
  */
75
111
  async create(input: CreateProjectInput): Promise<Envelope<Project>> {
76
- validateSlug(input.slug, "project");
112
+ if (input.slug !== undefined) validateSlug(input.slug, "project");
77
113
  return request<Project>(config, {
78
114
  method: "POST",
79
115
  path: orgPath("projects"),
@@ -88,7 +124,7 @@ export function makeProjectsNamespace(config: HttpClientConfig) {
88
124
  validateSlug(slug, "project");
89
125
  return request<Project>(config, {
90
126
  method: "GET",
91
- path: projectPath(slug, "project"),
127
+ path: orgPath(`projects/${slug}`),
92
128
  });
93
129
  },
94
130
 
@@ -99,7 +135,7 @@ export function makeProjectsNamespace(config: HttpClientConfig) {
99
135
  validateSlug(slug, "project");
100
136
  return request<Project>(config, {
101
137
  method: "PATCH",
102
- path: projectPath(slug, "project"),
138
+ path: orgPath(`projects/${slug}`),
103
139
  body: patch,
104
140
  });
105
141
  },
package/src/realtime.ts CHANGED
@@ -7,33 +7,28 @@
7
7
  * yielding `RealtimeEvent`s — the caller writes idiomatic
8
8
  * `for await (const event of client.realtime.subscribe(topic)) { ... }`.
9
9
  *
10
- * Per `feedback_sdk_abstracted_primitive_default_to_richer_interaction.md`
11
- * +: the SDK chooses the transport (WebSocket primary → SSE
12
- * → poll), transparently. SPLIT (polling) is the SDK-internal fallback
13
- * when WebSocket / SSE are unavailable — NOT a consumer-side design
14
- * choice. The consumer sees one uniform async iterable regardless.
10
+ * Transport: SSE over `GET /@<slug>/realtime/subscribe`. The gateway
11
+ * serves no WebSocket upgrade and no durable replay, so neither is
12
+ * offered; after an `event: resync` frame the client re-reads current
13
+ * state from the data plane and resubscribes.
15
14
  *
16
- * Wire mapping (project-plane;):
17
- * - subscribe → GET /@<slug>/realtime/subscribe?topic=<t>&filter=<jsonb> (WS/SSE upgrade)
18
- * - replay → POST /@<slug>/realtime/replay { topic, fromSequence, untilSequence?, limit? }
15
+ * Wire mapping (project-plane):
16
+ * - subscribe → GET /@<slug>/realtime/subscribe?topic=<t>&filter=<jsonb> (SSE)
19
17
  *
20
- * Per, `filter` + RLS compose (filter narrows, RLS gates).
21
- * Per, surfaces no new codes — reuses K9O_INVALID_FILTER +
22
- * K9O_SUBSCRIPTION_LIMIT_EXCEEDED + K9O_TIER_FEATURE_GATED.
18
+ * `filter` + RLS compose (filter narrows, RLS gates). Surfaces
19
+ * K9O_INVALID_FILTER, K9O_SUBSCRIPTION_LIMIT_EXCEEDED and K9O_TIER_FEATURE_GATED.
23
20
  */
24
21
 
25
- import { request, requestStream, type HttpClientConfig } from "./http.ts";
22
+ import { requestStream, type HttpClientConfig } from "./http.ts";
26
23
  import { projectPath } from "./url-grammar.ts";
27
- import { KError } from "./kerror.ts";
28
- import type { Envelope } from "./envelope.ts";
29
24
  import { parseSseStream } from "./internal/sse.ts";
30
25
 
31
26
  // ----------------------------------------------------------------------
32
- // Topic catalogue (closed 6-family enum)
27
+ // Topic catalogue
33
28
  // ----------------------------------------------------------------------
34
29
 
35
30
  /**
36
- * The 6 closed subscription topic families. Concrete
31
+ * The 6 closed subscription topic families . Concrete
37
32
  * topic strings carry a dynamic segment (collection name, resource id,
38
33
  * event family) — use `realtimeTopic.*` builders to construct them.
39
34
  */
@@ -49,7 +44,7 @@ export type RealtimeTopicFamily =
49
44
  export type RealtimeTopic = string;
50
45
 
51
46
  /**
52
- * Typed builders for the 6 topic families. Prefer these
47
+ * Typed builders for the 6 topic families . Prefer these
53
48
  * over hand-built strings for compile-time family safety.
54
49
  */
55
50
  export const realtimeTopic = {
@@ -83,16 +78,14 @@ export const realtimeTopic = {
83
78
  // Event + filter shapes
84
79
  // ----------------------------------------------------------------------
85
80
 
86
- /** Per-topic filter predicate (vocabulary). */
81
+ /** Per-topic filter predicate . */
87
82
  export type RealtimeFilter = Record<string, unknown>;
88
83
 
89
- /** A single realtime event delivered on a subscription. */
84
+ /** A single realtime event, as the gateway frames it (wire spelling). */
90
85
  export type RealtimeEvent<T = unknown> = {
91
86
  topic: RealtimeTopic;
92
87
  /** Event type within the topic family (e.g. `row_inserted`, `workflow.run.completed`). */
93
- type: string;
94
- /** Monotonic sequence number — use as `fromSequence` for replay catch-up. */
95
- sequence: number;
88
+ eventType: string;
96
89
  payload: T;
97
90
  occurredAt: string;
98
91
  };
@@ -101,35 +94,15 @@ export type SubscribeOptions = {
101
94
  filter?: RealtimeFilter;
102
95
  /** Abort the subscription (closes the underlying transport). */
103
96
  signal?: AbortSignal;
104
- /**
105
- * Force a transport instead of the WebSocket → SSE → poll cascade.
106
- * Mainly for tests + constrained runtimes. Omit for production —
107
- * the SDK picks the richest available transport.
108
- */
109
- transport?: RealtimeTransportKind;
110
- /** Poll-transport interval in ms (default 2000). Only used when polling. */
111
- pollIntervalMs?: number;
112
97
  };
113
98
 
114
- export type RealtimeTransportKind = "websocket" | "sse" | "poll";
115
-
116
99
  /**
117
100
  * A live subscription. It is itself an async iterable (`for await`) and
118
- * exposes `close()` for explicit teardown + `transport` for diagnostics.
101
+ * exposes `close()` for explicit teardown.
119
102
  */
120
103
  export type RealtimeSubscription<T = unknown> = AsyncIterable<RealtimeEvent<T>> & {
121
104
  /** Stop the subscription + release the underlying transport. */
122
105
  close(): void;
123
- /** The transport actually selected (post-fallback). */
124
- readonly transport: RealtimeTransportKind;
125
- };
126
-
127
- export type ReplayInput = {
128
- topic: RealtimeTopic;
129
- filter?: RealtimeFilter;
130
- fromSequence: number;
131
- untilSequence?: number;
132
- limit?: number;
133
106
  };
134
107
 
135
108
  function requireProjectSlug(config: HttpClientConfig): string {
@@ -139,90 +112,7 @@ function requireProjectSlug(config: HttpClientConfig): string {
139
112
  return config.projectSlug;
140
113
  }
141
114
 
142
- /** Pick the richest transport available in this runtime. */
143
- function selectTransport(forced?: RealtimeTransportKind): RealtimeTransportKind {
144
- if (forced) return forced;
145
- if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === "function") return "websocket";
146
- // SSE rides on fetch streaming, which the http layer already requires.
147
- return "sse";
148
- }
149
-
150
- /** Convert an http(s) baseUrl to a ws(s) URL for the WebSocket transport. */
151
- function toWebSocketUrl(baseUrl: string, path: string, query: Record<string, string>): string {
152
- const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
153
- const wsBase = trimmed.replace(/^http/, "ws");
154
- const qs = new URLSearchParams(query).toString();
155
- return `${wsBase}${path}${qs ? `?${qs}` : ""}`;
156
- }
157
-
158
- /** WebSocket transport — primary. Bridges socket → async queue. */
159
- async function* websocketTransport<T>(
160
- config: HttpClientConfig,
161
- topic: RealtimeTopic,
162
- options: SubscribeOptions,
163
- closed: { value: boolean },
164
- ): AsyncGenerator<RealtimeEvent<T>, void, void> {
165
- const slug = requireProjectSlug(config);
166
- const query: Record<string, string> = { topic };
167
- if (options.filter) query.filter = JSON.stringify(options.filter);
168
- if (config.apiKey) query.access_token = config.apiKey; // WS can't set Authorization header
169
- const url = toWebSocketUrl(config.baseUrl, projectPath(slug, "realtime/subscribe"), query);
170
- const WS = (globalThis as { WebSocket: new (url: string) => WebSocket }).WebSocket;
171
- const socket = new WS(url);
172
-
173
- const queue: RealtimeEvent<T>[] = [];
174
- let resolveNext: (() => void) | null = null;
175
- let socketClosed = false;
176
- let socketError: Error | null = null;
177
-
178
- const wake = () => {
179
- if (resolveNext) {
180
- resolveNext();
181
- resolveNext = null;
182
- }
183
- };
184
- socket.onmessage = (ev: MessageEvent) => {
185
- try {
186
- queue.push(JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data)) as RealtimeEvent<T>);
187
- } catch {
188
- /* ignore malformed frame */
189
- }
190
- wake();
191
- };
192
- socket.onerror = () => {
193
- socketError = new Error("realtime websocket transport error");
194
- wake();
195
- };
196
- socket.onclose = () => {
197
- socketClosed = true;
198
- wake();
199
- };
200
- options.signal?.addEventListener("abort", () => socket.close());
201
-
202
- try {
203
- while (!closed.value) {
204
- if (queue.length > 0) {
205
- yield queue.shift() as RealtimeEvent<T>;
206
- continue;
207
- }
208
- if (socketClosed) {
209
- if (socketError) throw socketError;
210
- return;
211
- }
212
- await new Promise<void>((resolve) => {
213
- resolveNext = resolve;
214
- });
215
- }
216
- } finally {
217
- try {
218
- socket.close();
219
- } catch {
220
- /* already closed */
221
- }
222
- }
223
- }
224
-
225
- /** SSE transport — fallback. Rides on fetch streaming. */
115
+ /** SSE transport — rides on fetch streaming. `resync` control frames are not events and are skipped. */
226
116
  async function* sseTransport<T>(
227
117
  config: HttpClientConfig,
228
118
  topic: RealtimeTopic,
@@ -240,73 +130,33 @@ async function* sseTransport<T>(
240
130
  });
241
131
  for await (const event of parseSseStream<RealtimeEvent<T>>(response.body as ReadableStream<Uint8Array>)) {
242
132
  if (closed.value || options.signal?.aborted) return;
133
+ if (typeof (event as { eventType?: unknown }).eventType !== "string") continue;
243
134
  yield event;
244
135
  }
245
136
  }
246
137
 
247
- /** Poll transport — last-resort fallback (SPLIT, SDK-internal). */
248
- async function* pollTransport<T>(
249
- config: HttpClientConfig,
250
- topic: RealtimeTopic,
251
- options: SubscribeOptions,
252
- closed: { value: boolean },
253
- ): AsyncGenerator<RealtimeEvent<T>, void, void> {
254
- const slug = requireProjectSlug(config);
255
- const interval = options.pollIntervalMs ?? 2000;
256
- let cursor = 0;
257
- while (!closed.value && !options.signal?.aborted) {
258
- const { data } = await request<ReadonlyArray<RealtimeEvent<T>>>(config, {
259
- method: "POST",
260
- path: projectPath(slug, "realtime/replay"),
261
- body: { topic, filter: options.filter, fromSequence: cursor },
262
- });
263
- for (const event of data ?? []) {
264
- if (closed.value || options.signal?.aborted) return;
265
- cursor = Math.max(cursor, event.sequence + 1);
266
- yield event;
267
- }
268
- if (closed.value || options.signal?.aborted) return;
269
- await new Promise<void>((resolve) => setTimeout(resolve, interval));
270
- }
271
- }
272
-
273
138
  export function makeRealtimeNamespace(config: HttpClientConfig) {
274
139
  return {
275
140
  /**
276
141
  * Subscribe to a topic. Returns a `RealtimeSubscription` — an async
277
- * iterable of `RealtimeEvent`s. The SDK selects the richest available
278
- * transport (WebSocket → SSE → poll); the caller's
279
- * `for await` loop is transport-agnostic.
142
+ * iterable of `RealtimeEvent`s over SSE.
280
143
  *
281
144
  * ```ts
282
145
  * const sub = client.realtime.subscribe(
283
146
  * realtimeTopic.dashboardRefresh("dsh_abc"),
284
147
  * );
285
148
  * for await (const event of sub) {
286
- * if (event.type === "dashboard.widget.data_refreshed") rerender(event.payload);
149
+ * if (event.eventType === "dashboard.widget.data_refreshed") rerender(event.payload);
287
150
  * }
288
151
  * ```
289
152
  */
290
153
  subscribe<T = unknown>(topic: RealtimeTopic, options: SubscribeOptions = {}): RealtimeSubscription<T> {
291
154
  requireProjectSlug(config);
292
- const transport = selectTransport(options.transport);
293
155
  const closed = { value: false };
294
-
295
- const iterate = (): AsyncGenerator<RealtimeEvent<T>, void, void> => {
296
- switch (transport) {
297
- case "websocket":
298
- return websocketTransport<T>(config, topic, options, closed);
299
- case "poll":
300
- return pollTransport<T>(config, topic, options, closed);
301
- case "sse":
302
- default:
303
- return sseTransport<T>(config, topic, options, closed);
304
- }
305
- };
156
+ const iterate = (): AsyncGenerator<RealtimeEvent<T>, void, void> => sseTransport<T>(config, topic, options, closed);
306
157
 
307
158
  let active: AsyncGenerator<RealtimeEvent<T>, void, void> | null = null;
308
159
  return {
309
- transport,
310
160
  [Symbol.asyncIterator]() {
311
161
  active = iterate();
312
162
  return active;
@@ -317,34 +167,5 @@ export function makeRealtimeNamespace(config: HttpClientConfig) {
317
167
  },
318
168
  };
319
169
  },
320
-
321
- /**
322
- * One-shot historical replay — catch up on missed
323
- * events from `fromSequence`. Capped at 10k events server-side;
324
- * paginate via the returned events' `sequence`. Use the highest
325
- * `sequence` you've seen as the next `fromSequence`.
326
- */
327
- async replay<T = unknown>(input: ReplayInput): Promise<Envelope<ReadonlyArray<RealtimeEvent<T>>>> {
328
- const slug = requireProjectSlug(config);
329
- if (!Number.isInteger(input.fromSequence) || input.fromSequence < 0) {
330
- throw new KError({
331
- code: "K9O_INVALID_FILTER",
332
- message: "replay fromSequence must be a non-negative integer",
333
- context: { fromSequence: input.fromSequence },
334
- recommendation: "Pass the highest sequence number you have already observed (or 0 to replay from the start).",
335
- });
336
- }
337
- return request(config, {
338
- method: "POST",
339
- path: projectPath(slug, "realtime/replay"),
340
- body: {
341
- topic: input.topic,
342
- filter: input.filter,
343
- fromSequence: input.fromSequence,
344
- untilSequence: input.untilSequence,
345
- limit: input.limit,
346
- },
347
- });
348
- },
349
170
  };
350
171
  }
package/src/region.ts CHANGED
@@ -1,5 +1,85 @@
1
1
  /**
2
- * Regional host resolution lives in `@k9o/gate` (which `@k9o/core` composes), so the Gate client and the core client
3
- * share one implementation of the probe and of the `Location` validation. See `packages/gate/src/region.ts`.
2
+ * Regional host resolution.
3
+ *
4
+ * A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`) redirects GET/HEAD only and REFUSES a
5
+ * request that carries a body, an `Authorization` header or a cookie (`400 K9O_INVALID_REQUEST_SHAPE`,
6
+ * `region_free_host_takes_no_credentials`), so credentials must go to `<org>.api-<rgn>.<rest>`. The client learns the
7
+ * region with ONE unauthenticated `HEAD` to the configured host, takes the `308` `Location`, and caches the regional
8
+ * origin.
9
+ *
10
+ * Nothing here knows an apex. The host comes from the configured baseUrl; `<rest>` is whatever follows the second
11
+ * label, so a public-cloud, internal-apex, air-gapped or localhost deployment all work. Properties:
12
+ * - Only a host shaped `<org>.api.<rest>` / `<org>.api-staging.<rest>` is probed. A regional, single-label, IP,
13
+ * localhost or any other baseUrl is used exactly as given.
14
+ * - The probe carries NO credential and none of the configured default headers (they may hold one).
15
+ * - The `Location` is trusted only if it is `https`, has no userinfo/port, and its host is exactly the SAME org and
16
+ * SAME remainder with a 4-character region code appended to the second label. Anything else is ignored and the
17
+ * configured baseUrl stays, so a hostile redirect can never receive a credential.
18
+ * - Not a redirect, or any failure: the configured baseUrl stays and the server's own refusal, if any, is what the
19
+ * caller sees. A definite answer is cached for the life of the client's config; a network failure is not cached.
4
20
  */
5
- export { resolveRegionalOrigin, regionalOriginFromLocation, type RegionConfig } from "@k9o/gate";
21
+
22
+ import { parseApiHost } from "./url-grammar.ts";
23
+
24
+ /** The slice of a client config this module needs. */
25
+ export type RegionConfig = { baseUrl: string; fetch?: typeof globalThis.fetch | undefined };
26
+
27
+ const cache = new WeakMap<RegionConfig, Promise<string>>();
28
+
29
+ /** The regional origin a `Location` may name for `host`, or null if it is not exactly that. Pure. */
30
+ export function regionalOriginFromLocation(host: string, location: string, base: string): string | null {
31
+ const from = parseApiHost(host);
32
+ if (from === null || from.orgSlug === null || from.region !== null) return null;
33
+ let u: URL;
34
+ try {
35
+ u = new URL(location, base);
36
+ } catch {
37
+ return null;
38
+ }
39
+ if (u.protocol !== "https:" || u.username !== "" || u.password !== "" || u.port !== "") return null;
40
+ const to = parseApiHost(u.hostname);
41
+ if (to === null || to.region === null) return null;
42
+ if (to.orgSlug !== from.orgSlug || to.family !== from.family || to.apex !== from.apex) return null;
43
+ return `https://${u.hostname}`;
44
+ }
45
+
46
+ async function probe(config: RegionConfig): Promise<{ origin: string; cacheable: boolean }> {
47
+ const fetchImpl = config.fetch ?? globalThis.fetch;
48
+ const configured = config.baseUrl;
49
+ if (typeof fetchImpl !== "function") return { origin: configured, cacheable: false };
50
+ let host: string;
51
+ try {
52
+ host = new URL(configured).hostname;
53
+ } catch {
54
+ return { origin: configured, cacheable: true };
55
+ }
56
+ const parsedHost = parseApiHost(host);
57
+ if (parsedHost === null || parsedHost.orgSlug === null || parsedHost.region !== null) return { origin: configured, cacheable: true };
58
+ try {
59
+ const res = await fetchImpl(`${configured.replace(/\/+$/, "")}/`, {
60
+ method: "HEAD",
61
+ redirect: "manual",
62
+ headers: { accept: "*/*" },
63
+ } as Parameters<typeof fetchImpl>[1]);
64
+ if (res.status === 308) {
65
+ const loc = res.headers.get("location");
66
+ const origin = loc === null ? null : regionalOriginFromLocation(host, loc, configured);
67
+ if (origin !== null) return { origin, cacheable: true };
68
+ }
69
+ return { origin: configured, cacheable: true };
70
+ } catch {
71
+ return { origin: configured, cacheable: false };
72
+ }
73
+ }
74
+
75
+ /** The origin requests for this client go to: the cached regional origin once learned, else the configured baseUrl. */
76
+ export function resolveRegionalOrigin(config: RegionConfig): Promise<string> {
77
+ const hit = cache.get(config);
78
+ if (hit !== undefined) return hit;
79
+ const p = probe(config).then((r) => {
80
+ if (!r.cacheable) cache.delete(config);
81
+ return r.origin;
82
+ });
83
+ cache.set(config, p);
84
+ return p;
85
+ }
@@ -4,7 +4,8 @@
4
4
  * `storage-locations.ts` and is unchanged.
5
5
  *
6
6
  * Bucket names are logical: the platform resolves them to a physical store, so a developer never sees a
7
- * physical bucket or key. Signed URLs require an explicit `expiresIn` (no default, max 7 days).
7
+ * physical bucket or key. Signed URLs require an explicit `expiresIn` (no default). The gateway accepts 1 to 604800 seconds but never
8
+ * signs for longer than 900 seconds, and the answer's `expiresIn` is the lifetime it actually used.
8
9
  *
9
10
  * What is NOT here yet, by design: a signed PUT (a method- and content-type-bound upload URL). The gateway has
10
11
  * no route for it; uploads go through the multipart `upload` below.
@@ -18,14 +19,15 @@ import type { Envelope } from "./envelope.ts";
18
19
  /** A bucket. */
19
20
  export type StorageBucket = {
20
21
  name: string;
21
- /** `true` removes the authentication requirement on GET; per-object access policies still apply. */
22
+ /** Stored and returned, not enforced: setting it does not yet change who can read objects. */
22
23
  public: boolean;
23
24
  /** Bytes; `null` = no limit. */
24
25
  fileSizeLimit: number | null;
25
26
  /** MIME allow-list; empty = any. */
26
27
  allowedMimeTypes: string[];
28
+ /** Stored and returned, not enforced: the platform does not keep object versions yet. */
27
29
  versioning: boolean;
28
- /** Lifecycle auto-expiry in days; `null` = never. */
30
+ /** Stored and returned, not enforced: nothing deletes objects after this many days yet. `null` = never. */
29
31
  deleteAfterDays: number | null;
30
32
  createdAt: string;
31
33
  updatedAt: string;
@@ -37,10 +39,13 @@ export type StorageBucket = {
37
39
 
38
40
  /** Options for `createBucket`. The bucket name is the first argument (3 to 63 chars, lowercase, digits, hyphens). */
39
41
  export type CreateBucketOptions = {
42
+ /** Stored, not enforced yet (see `StorageBucket.public`). */
40
43
  public?: boolean;
41
44
  fileSizeLimit?: number;
42
45
  allowedMimeTypes?: string[] | null;
46
+ /** Stored, not enforced yet. */
43
47
  versioning?: boolean;
48
+ /** Stored, not enforced yet. */
44
49
  deleteAfterDays?: number;
45
50
  /** Link the bucket to one of the project's storage locations (see `client.storage.locations`). */
46
51
  byosBindingId?: string;
@@ -48,10 +53,13 @@ export type CreateBucketOptions = {
48
53
 
49
54
  /** Fields you may change on a bucket. `null` clears a limit; `allowedMimeTypes: null` allows any type. */
50
55
  export type UpdateBucketOptions = {
56
+ /** Stored, not enforced yet. */
51
57
  public?: boolean;
52
58
  fileSizeLimit?: number | null;
53
59
  allowedMimeTypes?: string[] | null;
60
+ /** Stored, not enforced yet. */
54
61
  versioning?: boolean;
62
+ /** Stored, not enforced yet. */
55
63
  deleteAfterDays?: number | null;
56
64
  /** `null` unlinks the bucket from its storage location. */
57
65
  byosBindingId?: string | null;
@@ -74,12 +82,16 @@ export type UploadOptions = {
74
82
  };
75
83
 
76
84
  export type SignedUrlOptions = {
77
- /** Seconds, required: 1 to 604800 (7 days). There is deliberately no default. */
85
+ /**
86
+ * Seconds, required: 1 to 604800. There is deliberately no default. The gateway clamps the lifetime to 900
87
+ * seconds (15 minutes); read `expiresIn` in the answer for the lifetime you got.
88
+ */
78
89
  expiresIn: number;
79
90
  /** `true` makes the download an attachment (`Content-Disposition: attachment`). */
80
91
  download?: boolean;
81
92
  };
82
93
 
94
+ /** `expiresIn` is the lifetime the gateway used: at most 900 seconds, even when you asked for more. */
83
95
  export type SignedUrl = { signedUrl: string; expiresIn: number };
84
96
  export type RemovedObject = { bucket: string; path: string; deleted: boolean };
85
97
  export type DeletedBucket = { name: string; deleted: true; objectsDeleted: number };
@@ -172,7 +184,7 @@ export function makeBucketHandle(config: HttpClientConfig, bucket: string) {
172
184
  },
173
185
 
174
186
  /**
175
- * A pre-authenticated GET URL for one object. `POST …/objects/sign`. `expiresIn` is required.
187
+ * A pre-authenticated GET URL for one object. `POST …/objects/sign`. `expiresIn` is required; the URL lives at most 900 seconds.
176
188
  * (A signed PUT is not offered: the gateway has no route for it yet.)
177
189
  */
178
190
  async createSignedUrl(path: string, options: SignedUrlOptions): Promise<Envelope<SignedUrl>> {