@sequenceholdings/artifact-studio 0.6.4 → 0.6.6
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/README.md +183 -6
- package/VERSION-PIN.md +56 -1
- package/dist/gateway/admission.d.ts +23 -1
- package/dist/gateway/admission.js +30 -13
- package/dist/gateway/control.d.ts +40 -0
- package/dist/gateway/control.js +76 -7
- package/dist/gateway/hosted-proxy.d.ts +11 -8
- package/dist/gateway/hosted-proxy.js +99 -91
- package/dist/gateway/ingress-admission.d.ts +77 -0
- package/dist/gateway/ingress-admission.js +150 -0
- package/dist/gateway/ingress-audit.d.ts +46 -0
- package/dist/gateway/ingress-audit.js +84 -0
- package/dist/gateway/ingress-context.d.ts +57 -0
- package/dist/gateway/ingress-context.js +57 -0
- package/dist/gateway/ingress-development.d.ts +12 -0
- package/dist/gateway/ingress-development.js +23 -0
- package/dist/gateway/ingress-handler.d.ts +44 -0
- package/dist/gateway/ingress-handler.js +320 -0
- package/dist/gateway/ingress-pass.d.ts +83 -0
- package/dist/gateway/ingress-pass.js +143 -0
- package/dist/gateway/ingress-response.d.ts +74 -0
- package/dist/gateway/ingress-response.js +98 -0
- package/dist/gateway/ingress-runtime.d.ts +86 -0
- package/dist/gateway/ingress-runtime.js +126 -0
- package/dist/gateway/ingress-snapshot.d.ts +54 -0
- package/dist/gateway/ingress-snapshot.js +63 -0
- package/dist/gateway/ingress-stages.d.ts +60 -0
- package/dist/gateway/ingress-stages.js +387 -0
- package/dist/gateway/ingress.d.ts +259 -0
- package/dist/gateway/ingress.js +204 -0
- package/dist/gateway/lattice-invoke.js +2 -0
- package/dist/gateway/lattice-upstream.d.ts +3 -4
- package/dist/gateway/lattice-upstream.js +11 -9
- package/dist/gateway/runtime-transport.d.ts +17 -0
- package/dist/gateway/runtime-transport.js +26 -0
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +21 -0
- package/dist/gateway/session-response.d.ts +15 -0
- package/dist/gateway/session-response.js +40 -0
- package/dist/gateway/usage.d.ts +8 -1
- package/dist/gateway-main.js +32 -8
- package/dist/manifest-next-ingress.d.ts +5 -2
- package/dist/manifest-next-ingress.js +27 -2
- package/dist/manifest-next.d.ts +20 -6
- package/dist/manifest-next.js +13 -5
- package/dist/manifest.d.ts +7 -2
- package/dist/next-project.d.ts +4 -0
- package/dist/sdk.d.ts +12 -3
- package/dist/server/broker-contract.d.ts +62 -0
- package/dist/server/broker-contract.js +43 -1
- package/dist/server/client.js +38 -13
- package/dist/server/external-context.d.ts +8 -1
- package/dist/server/external-context.js +7 -2
- package/dist/server/internal/v1-context.js +17 -1
- package/dist/server/internal/verify-any.d.ts +11 -2
- package/dist/server/internal/verify-any.js +11 -3
- package/dist/server/process-run-sharing.d.ts +86 -0
- package/dist/server/process-run-sharing.js +29 -0
- package/dist/server/process-runs-page.d.ts +39 -0
- package/dist/server/process-runs-page.js +10 -0
- package/dist/server/processes.d.ts +8 -9
- package/dist/server/processes.js +4 -0
- package/dist/server/request-context.js +10 -3
- package/dist/server/runtime-config.d.ts +10 -1
- package/dist/server/runtime-config.js +11 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/tunnel/broker.d.ts +18 -0
- package/dist/tunnel/broker.js +175 -17
- package/dist/tunnel/client.d.ts +13 -0
- package/dist/tunnel/client.js +40 -2
- package/dist/tunnel/external.d.ts +51 -0
- package/dist/tunnel/external.js +10 -0
- package/dist/tunnel/index.d.ts +1 -1
- package/dist/tunnel/protocol.d.ts +20 -2
- package/dist/tunnel/protocol.js +42 -6
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -64,6 +64,142 @@ deployer provide generic application capabilities; each deployed artifact owns
|
|
|
64
64
|
its product behavior. Primitive code must not branch on an artifact ID or import
|
|
65
65
|
an application's services. See [Platform primitive boundaries](../../../docs/platform/platform-primitives.md).
|
|
66
66
|
|
|
67
|
+
## Lattice run lists and collaboration (internal prerelease)
|
|
68
|
+
|
|
69
|
+
The server entry point remains an **internal** surface. These methods describe
|
|
70
|
+
workspace source awaiting a matching SDK release and platform deployment;
|
|
71
|
+
they are not a newly supported public SDK or direct HTTP contract. See
|
|
72
|
+
[VERSION-PIN.md](./VERSION-PIN.md) for compatibility requirements.
|
|
73
|
+
|
|
74
|
+
On the request-scoped client returned by `createArtifactServerClient`, declare
|
|
75
|
+
the exact process IDs the application reads or acts on:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
server:
|
|
79
|
+
capabilities:
|
|
80
|
+
processes:
|
|
81
|
+
read: [review-process]
|
|
82
|
+
act: [review-process]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`client.processes.listRunsPage` returns compact summaries for activity lists:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const page = await client.processes.listRunsPage({
|
|
89
|
+
process: 'review-process',
|
|
90
|
+
statuses: ['pending', 'running'],
|
|
91
|
+
entity: { type: 'application', ids: ['example-application-1'] },
|
|
92
|
+
limit: 100,
|
|
93
|
+
})
|
|
94
|
+
// page: { runs: ArtifactProcessRunSummary[], nextCursor: string | null }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each summary contains `id`, `processId`, `status`, `entityType`, `entityId`,
|
|
98
|
+
`startedAt`, `completedAt` and `error`; it does not fetch run state, output or
|
|
99
|
+
step payloads. Use `getRun` for detail. `statuses` is an optional, nonempty set
|
|
100
|
+
of run statuses. `entity` accepts a type alone, one `id`, or 1–500 `ids`; `id`
|
|
101
|
+
and `ids` are mutually exclusive. `archived: true` selects only archived runs;
|
|
102
|
+
the default excludes them. The existing `listRuns` contract is unchanged.
|
|
103
|
+
|
|
104
|
+
The caller's run Viewer permission filters the results. `limit` bounds visible
|
|
105
|
+
rows (default 100, maximum 500). Pass `nextCursor` back as `cursor` with the
|
|
106
|
+
same process, status, entity and archive filters; the limit may change. The
|
|
107
|
+
encrypted cursor is bound to the caller and expires after one hour. It is
|
|
108
|
+
returned only when another visible row exists and resumes after the last
|
|
109
|
+
returned visible row. A successful empty page has `nextCursor: null`.
|
|
110
|
+
|
|
111
|
+
The platform establishes the caller's complete authorization scope before
|
|
112
|
+
selecting run rows. Unavailable authorization, an unsupported authorization
|
|
113
|
+
model, or an incomplete grant scope rejects the call with 503 and no partial
|
|
114
|
+
page. A 503 must not be treated as an empty list. Invalid, expired, or
|
|
115
|
+
filter-mismatched cursors reject with 400. These reads work from read-only and
|
|
116
|
+
stale request contexts.
|
|
117
|
+
|
|
118
|
+
`client.processes.shareRun` adds collaborators to selected human nodes while
|
|
119
|
+
preserving their existing assignments, and grants those recipients run viewing:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const shared = await client.processes.shareRun({
|
|
123
|
+
runId: 'example-run-1',
|
|
124
|
+
nodeIds: ['review', 'reopen'],
|
|
125
|
+
users: ['auth0|example-reviewer'],
|
|
126
|
+
})
|
|
127
|
+
// shared: { run_id, nodes_updated, users, groups, authorization_applied }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This requires `processes.act`, a signed-in human caller, and Owner on the run
|
|
131
|
+
unless the caller explicitly uses an onward-sharing authority below. It is
|
|
132
|
+
refused in service mode, from external callers, and from read-only or stale
|
|
133
|
+
request contexts. Recipients gain full-run viewing, so a node's delegation
|
|
134
|
+
permission alone does not authorize this operation. Authored assignment
|
|
135
|
+
allowlists still constrain owners, and disabled users or deleted groups cannot
|
|
136
|
+
receive access.
|
|
137
|
+
|
|
138
|
+
Name 1–100 human nodes and 1–100 users and groups combined. Node IDs are
|
|
139
|
+
1–255 characters and cannot contain the reserved `::` separator. Each target
|
|
140
|
+
node must already have an explicit assignment on this run; missing assignments are
|
|
141
|
+
rejected rather than replacing authored or dynamic defaults. Sharing preserves
|
|
142
|
+
existing assignees, is additive on retries, and never adds the caller implicitly.
|
|
143
|
+
Terminal runs are refused with 410. Use `assign` to replace one parked step's
|
|
144
|
+
assignees, or `grantViewers` to grant viewing without participation.
|
|
145
|
+
|
|
146
|
+
If sharing was saved but authorization delivery is pending, the call rejects
|
|
147
|
+
with 503 and `SHARE_PENDING`. Retry the same share before changing the
|
|
148
|
+
application's own task ownership; an error here does not mean the saved sharing
|
|
149
|
+
intent was rolled back.
|
|
150
|
+
|
|
151
|
+
### Explicit onward sharing
|
|
152
|
+
|
|
153
|
+
A run owner can also consent to recipients sharing full-run viewing and the
|
|
154
|
+
same or fewer human nodes onward, within this application's live runtime
|
|
155
|
+
audience:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const shared = await client.processes.shareRun({
|
|
159
|
+
runId: 'example-run-1',
|
|
160
|
+
nodeIds: ['review', 'reopen'],
|
|
161
|
+
users: ['auth0|example-reviewer'],
|
|
162
|
+
delegateSharing: { audience: 'current_app' },
|
|
163
|
+
})
|
|
164
|
+
// Check shared.authorization_applied and shared.sharingAuthorityId before changing task ownership.
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
This option accepts individual users only; groups cannot receive delegated
|
|
168
|
+
sharing authority. The platform resolves `current_app` from the admitted
|
|
169
|
+
request, and checks the current application audience on every share. The
|
|
170
|
+
authority is pinned to the run, its process bundle and the allowed nodes.
|
|
171
|
+
Each onward recipient remains bounded by that authority and cannot expand the
|
|
172
|
+
audience or node set. Delegates must pass `delegateSharing` explicitly on each
|
|
173
|
+
onward share; omitting it retains the ordinary Owner-only behavior. Owning or
|
|
174
|
+
viewing one human step does not itself grant this authority.
|
|
175
|
+
|
|
176
|
+
Run owners can inspect and revoke root authorities through the same
|
|
177
|
+
`processes.act` declaration, in user mode:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const page = await client.processes.listRunSharingAuthorities({
|
|
181
|
+
runId: 'example-run-1',
|
|
182
|
+
limit: 20,
|
|
183
|
+
})
|
|
184
|
+
// Follow page.nextCursor with the same runId until null.
|
|
185
|
+
const authority = page.authorities[0]
|
|
186
|
+
if (authority) {
|
|
187
|
+
await client.processes.revokeRunSharingAuthority({
|
|
188
|
+
runId: 'example-run-1',
|
|
189
|
+
authorityId: authority.id,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Authority pages default to 20 records, at most 50. Each record includes its
|
|
195
|
+
run, bundle, node scope, application audience, creator, creation/revocation
|
|
196
|
+
timestamps and up to 100 delegates, including who granted each delegation and
|
|
197
|
+
when. Record fields use the platform's snake case; the page exposes
|
|
198
|
+
`nextCursor`. Listing works from stale/read-only contexts; revocation requires
|
|
199
|
+
a current writable request. Revocation is idempotent and returns
|
|
200
|
+
`{ authority_id, revoked: true }`. Revoking a root stops onward sharing through
|
|
201
|
+
every delegate under it; existing run viewing and step participation remain.
|
|
202
|
+
|
|
67
203
|
## Lost connections in a Next.js server
|
|
68
204
|
|
|
69
205
|
The server client (`@sequenceholdings/artifact-studio/server`) sends a read up
|
|
@@ -72,12 +208,53 @@ connection was refused or reset, the host or network was unreachable, or a DNS
|
|
|
72
208
|
lookup failed transiently. It pauses up to 100 ms, then up to 200 ms, and logs a
|
|
73
209
|
`broker.reconnect` warning for each resend. Reads are `client.orm` operations
|
|
74
210
|
the manifest lists with `kind: 'query'`, `client.api` GET and HEAD requests,
|
|
75
|
-
Storage GET and HEAD requests,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
and is never resent.
|
|
211
|
+
Storage GET and HEAD requests, flag evaluations, and ORM mutations sent with an
|
|
212
|
+
idempotency key (below). Other ORM mutations, other API methods, Storage
|
|
213
|
+
writes, function invokes and process starts are sent once, because a reset can
|
|
214
|
+
come after Atlas acted on them, and reject with fetch's `TypeError: fetch
|
|
215
|
+
failed` as before. (Audit delivery keeps its own single resend under the same
|
|
216
|
+
event id.) A response with any status is Atlas's answer and is never resent.
|
|
217
|
+
To retry an ORM mutation yourself, pass `{ idempotencyKey }` as the third
|
|
218
|
+
argument of `orm(...).execute`; the data plane returns the first attempt's
|
|
219
|
+
receipt for the same key and body (a key on a query is refused).
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// Mutations only: a key on a manifest-listed query throws 400 `idempotency_key_on_query`.
|
|
223
|
+
await db.execute('SaveApplication', variables, { idempotencyKey: `save:${applicationId}:${draftId}` })
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Keys are 1-128 letters, digits, dots, underscores, colons or hyphens; anything
|
|
227
|
+
else throws 400 `invalid_idempotency_key` before a call is made. An Atlas build
|
|
228
|
+
that predates the key rejects it with a 400, so pass one only to an Atlas that
|
|
229
|
+
includes it (see [VERSION-PIN.md](./VERSION-PIN.md)).
|
|
230
|
+
|
|
231
|
+
## Previewing a retained Storage file in the browser
|
|
232
|
+
|
|
233
|
+
An Embed app can use the host's shared PDF/image dialog without adding its own
|
|
234
|
+
PDF worker or changing sandbox CSP. Declare the workspace under browser
|
|
235
|
+
`capabilities.storage` with at least `viewer` reach, then call:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
if (seq.storage.preview) {
|
|
239
|
+
await seq.storage.preview({ workspaceId, fileId, versionId, filename, mime: 'application/pdf' })
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`versionId` is required: previews never silently switch to the latest file.
|
|
244
|
+
The manifest limits reach; it does not grant access. Atlas requests the download
|
|
245
|
+
lease as the signed-in user and Storage checks their permissions and the
|
|
246
|
+
file/version association. No URL or bytes are accepted from the app or returned
|
|
247
|
+
by this method. `filename` and `mime` are display hints; supported MIME types are
|
|
248
|
+
`application/pdf`, `image/jpeg`, `image/png`, `image/gif`, and `image/webp`.
|
|
249
|
+
The promise resolves `{ opened: true }` when the dialog opens; loading/access
|
|
250
|
+
errors appear there. Close and reopen to retry or refresh an expired lease.
|
|
251
|
+
|
|
252
|
+
Next.js browser integrations can make the same host request through
|
|
253
|
+
`bridge.request({ method: 'storage.preview', args: [input] })`; this is a browser
|
|
254
|
+
UI operation, never a server SDK operation or service-account read. It requires
|
|
255
|
+
an Atlas build with Storage preview support. Older Embed hosts omit
|
|
256
|
+
`seq.storage.preview`; feature-detect it and retain an external-open fallback
|
|
257
|
+
until the host rollout completes. Host/session replacement closes the preview.
|
|
81
258
|
|
|
82
259
|
## Calling agents from a Next.js server
|
|
83
260
|
|
package/VERSION-PIN.md
CHANGED
|
@@ -33,16 +33,53 @@ refuse a v2 context, or any token signed with an `external-` kid, with reason
|
|
|
33
33
|
through `verifyArtifactExternalContext` or `createArtifactServerClient({ accept })`,
|
|
34
34
|
and an external principal reaches platform services only through `asService()`.
|
|
35
35
|
`@sequenceholdings/artifact-studio/server/testing` signs test contexts.
|
|
36
|
+
The server client's `orm(...).execute(op, variables, { idempotencyKey })` sends
|
|
37
|
+
an idempotency key (the data plane's `^[A-Za-z0-9._:-]{1,128}$`) with a
|
|
38
|
+
mutation, so a retry returns the first attempt's receipt; a key on a query is
|
|
39
|
+
refused. Atlas scopes an external caller's key to that caller before the data
|
|
40
|
+
plane sees it, so callers sharing the ingress service identity never share a
|
|
41
|
+
receipt. Because a resend carries the same key and body, the server client also
|
|
42
|
+
resends a keyed mutation once its connection to Atlas fails before an answer,
|
|
43
|
+
as it does a read. Atlas builds without this change reject `idempotencyKey`
|
|
44
|
+
(400); pass a key only once the target Atlas includes it, and publish 0.6.0
|
|
45
|
+
from a commit that contains it.
|
|
36
46
|
0.6.0 also carries `error.atlas` (`{ code, detail }`) on a refused broker call
|
|
37
47
|
(#13398); a step-up 401 still surfaces as `insufficient_user_authentication`.
|
|
38
48
|
|
|
39
49
|
0.6.0 also accepts the `server.ingress` manifest block. Earlier CLIs and Atlas
|
|
40
50
|
versions parse the server block strictly and reject the key, so an app that
|
|
41
|
-
declares an ingress needs 0.6.0 on both sides; that failure is closed.
|
|
51
|
+
declares an ingress needs 0.6.0 on both sides; that failure is closed.
|
|
52
|
+
An ingress names every ORM operation the application may run through the
|
|
53
|
+
broker for an external caller: reads in `capabilities.data.operations` and
|
|
54
|
+
writes in `capabilities.data.write`, each as `namespace/ops/<Operation>` (no
|
|
55
|
+
globs, in a namespace the ingress's own data reaches). Atlas refuses an
|
|
56
|
+
external caller's call to any other persisted operation, query or mutation,
|
|
57
|
+
with 403 `operation_not_allowed`, before the data plane's table check;
|
|
58
|
+
`data.operations` defaults to none, so an ingress that lists no reads runs no
|
|
59
|
+
queries. The list does not bound a Managed Function the ingress invokes: that
|
|
60
|
+
function runs with its own data credential. User and v1 service-mode calls
|
|
61
|
+
keep table-set admission. An
|
|
42
62
|
external context binds the canonical route path (`server/ingress-routes`): a
|
|
43
63
|
handler's `path` is canonicalised the same way before it is compared, and a
|
|
44
64
|
signed path that is not canonical is refused.
|
|
45
65
|
|
|
66
|
+
An external context in `development` mode (local `artifact dev` only) is
|
|
67
|
+
accepted only when the runtime configuration came from a development lease:
|
|
68
|
+
the owner-only file `artifact dev` names in `SEQUENCE_ARTIFACT_RUNTIME_FILE`,
|
|
69
|
+
exposed as `runtimeConfig.development === true`, and only for that lease's
|
|
70
|
+
session. The marker is never parsed from JSON, so a hosted runtime
|
|
71
|
+
(`SEQUENCE_ARTIFACT_RUNTIME_CONFIG`), the lease file's content and an explicit
|
|
72
|
+
`runtime` option all refuse development contexts. A gateway proxies a
|
|
73
|
+
development target only when its own `APP_ENV` is `development`.
|
|
74
|
+
|
|
75
|
+
Over the development tunnel the app sees the ingress host as `host` and
|
|
76
|
+
`x-forwarded-host`, as it sees the ingress origin on a hosted release: the
|
|
77
|
+
broker sets it, and the tunnel client keeps it only on a frame whose context is
|
|
78
|
+
signed by an `external-` kid. A tunnel client from an SDK before this change
|
|
79
|
+
pins every request, ingress ones included, to the session origin
|
|
80
|
+
(`<session>.apps.localhost`), so app code that builds absolute URLs from the
|
|
81
|
+
host behaves differently locally until the CLI is upgraded.
|
|
82
|
+
|
|
46
83
|
Context version policy: a context verifier rejects any unknown top-level or
|
|
47
84
|
`principal` key unless it sits under `ext` or is named in `crit` (and `crit`
|
|
48
85
|
names only extensions the verifier understands; today none, so it is empty).
|
|
@@ -57,6 +94,24 @@ before publication; an ordinary released CLI intentionally cannot.
|
|
|
57
94
|
The React/Vite starter therefore uses released primitives and responsive CSS
|
|
58
95
|
until that pin advances. Its build test exercises the ordinary CLI toolchain.
|
|
59
96
|
|
|
97
|
+
## Unreleased server process additions
|
|
98
|
+
|
|
99
|
+
Workspace source adds `processes.listRunsPage` (visible, paginated run summaries)
|
|
100
|
+
and `processes.shareRun` (additive collaboration on explicitly assigned human
|
|
101
|
+
nodes, with owner-consented onward sharing scoped to the current application).
|
|
102
|
+
The owner-only `listRunSharingAuthorities` and `revokeRunSharingAuthority`
|
|
103
|
+
methods inspect and stop that onward authority. These methods need an SDK
|
|
104
|
+
published from this change and a target platform deployment containing their
|
|
105
|
+
matching handlers. An SDK version string
|
|
106
|
+
alone does not establish that the deployed platform supports them; earlier
|
|
107
|
+
platform builds reject the new operations. See the SDK README for their input,
|
|
108
|
+
pagination, authorization and retry contracts.
|
|
109
|
+
|
|
110
|
+
These additions use the existing `./server` and `./server/broker-contract`
|
|
111
|
+
entry points, which remain classified **internal**. They add no package export
|
|
112
|
+
subpath or supported direct HTTP surface. Publication and a reviewed public
|
|
113
|
+
contract decision are separate from local build verification.
|
|
114
|
+
|
|
60
115
|
## What this means
|
|
61
116
|
|
|
62
117
|
`shared/services/artifact-studio/src/build.ts` force-aliases platform peers via
|
|
@@ -37,6 +37,22 @@ export declare class ArtifactAdmissionError extends Error {
|
|
|
37
37
|
readonly retryAfterSeconds?: number | undefined;
|
|
38
38
|
constructor(status: 429 | 503, code: 'capacity_exceeded' | 'admission_unavailable', retryAfterSeconds?: number | undefined);
|
|
39
39
|
}
|
|
40
|
+
/** Per-request concurrency an external ingress adds on top of the app's (design "Steady state" step 7). */
|
|
41
|
+
export declare const INGRESS_CONCURRENCY: Readonly<{
|
|
42
|
+
perSubject: 4;
|
|
43
|
+
perClient: 8;
|
|
44
|
+
appShare: 0.5;
|
|
45
|
+
}>;
|
|
46
|
+
/** An external ingress request: its share of the app, and per subject and client caps. */
|
|
47
|
+
export interface AdmissionIngress {
|
|
48
|
+
id: string;
|
|
49
|
+
/** `sha256(subject)`, lowercase hex. */
|
|
50
|
+
subjectHash: string;
|
|
51
|
+
/** `ingressClientBucket(clientIp)`. */
|
|
52
|
+
clientBucket: string;
|
|
53
|
+
}
|
|
54
|
+
/** The ingress's share of the app: half its capacity, and never zero. */
|
|
55
|
+
export declare function ingressConcurrencyShare(appMaximum: number): number;
|
|
40
56
|
/** Fail-closed admission with no command retries. An uncertain acquisition is
|
|
41
57
|
* never used; its unique permit expires even if Redis accepted it before loss. */
|
|
42
58
|
export declare class ArtifactRequestAdmission {
|
|
@@ -51,6 +67,12 @@ export declare class ArtifactRequestAdmission {
|
|
|
51
67
|
kind: 'admission_release_failed' | 'admission_timeout_callback_failed';
|
|
52
68
|
}) => void;
|
|
53
69
|
});
|
|
54
|
-
|
|
70
|
+
/**
|
|
71
|
+
* `ingress` adds the external ingress keys: the ingress's share of the app, 4 per subject and 8
|
|
72
|
+
* per client bucket, in the same atomic acquisition as the app and deployment keys.
|
|
73
|
+
*/
|
|
74
|
+
acquire(target: AdmissionTarget, { ingress }?: {
|
|
75
|
+
ingress?: AdmissionIngress;
|
|
76
|
+
}): Promise<AdmissionLease>;
|
|
55
77
|
}
|
|
56
78
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { performance } from 'node:perf_hooks';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { artifactNextResourceLimitsSchema } from '../manifest-next.js';
|
|
4
|
+
import { artifactNextIngressIdSchema, artifactNextResourceLimitsSchema } from '../manifest-next.js';
|
|
5
5
|
import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
|
|
6
6
|
export class ArtifactAdmissionError extends Error {
|
|
7
7
|
status;
|
|
@@ -24,16 +24,16 @@ const resources = z.object({
|
|
|
24
24
|
max_instances: resourceFields.max_instances,
|
|
25
25
|
timeout_seconds: resourceFields.timeout_seconds,
|
|
26
26
|
});
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
27
|
+
// Every key has the same Redis Cluster hash tag. A lease can never exist in only some of the sets,
|
|
28
|
+
// and previews/retained releases consume the same application cap. ARGV[2..n+1] are the per-key
|
|
29
|
+
// maxima (an ingress shares the app's capacity with stricter keys of its own); ARGV[n+2] the timeout.
|
|
30
|
+
const ACQUIRE = `-- artifact-request-admission-acquire-v2
|
|
30
31
|
local time = redis.call('TIME')
|
|
31
32
|
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
32
|
-
local
|
|
33
|
-
|
|
34
|
-
for _, key in ipairs(KEYS) do
|
|
33
|
+
local deadline = now + tonumber(ARGV[#KEYS + 2])
|
|
34
|
+
for index, key in ipairs(KEYS) do
|
|
35
35
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
|
36
|
-
if redis.call('ZCARD', key) >=
|
|
36
|
+
if redis.call('ZCARD', key) >= tonumber(ARGV[index + 1]) then return {0, now} end
|
|
37
37
|
end
|
|
38
38
|
for _, key in ipairs(KEYS) do
|
|
39
39
|
redis.call('ZADD', key, deadline, ARGV[1])
|
|
@@ -41,6 +41,13 @@ for _, key in ipairs(KEYS) do
|
|
|
41
41
|
redis.call('PEXPIRE', key, tonumber(latest[2]) - now + 1000)
|
|
42
42
|
end
|
|
43
43
|
return {1, deadline}`;
|
|
44
|
+
/** Per-request concurrency an external ingress adds on top of the app's (design "Steady state" step 7). */
|
|
45
|
+
export const INGRESS_CONCURRENCY = Object.freeze({ perSubject: 4, perClient: 8, appShare: 0.5 });
|
|
46
|
+
/** The ingress's share of the app: half its capacity, and never zero. */
|
|
47
|
+
export function ingressConcurrencyShare(appMaximum) {
|
|
48
|
+
return Math.max(1, Math.floor(INGRESS_CONCURRENCY.appShare * appMaximum));
|
|
49
|
+
}
|
|
50
|
+
const subjectHashSchema = z.string().regex(/^[0-9a-f]{64}$/);
|
|
44
51
|
const RELEASE = `-- artifact-request-admission-release-v1
|
|
45
52
|
for _, key in ipairs(KEYS) do
|
|
46
53
|
redis.call('ZREM', key, ARGV[1])
|
|
@@ -62,7 +69,11 @@ export class ArtifactRequestAdmission {
|
|
|
62
69
|
throw new Error('Admission Redis must disable offline queues, command replay and retries');
|
|
63
70
|
}
|
|
64
71
|
}
|
|
65
|
-
|
|
72
|
+
/**
|
|
73
|
+
* `ingress` adds the external ingress keys: the ingress's share of the app, 4 per subject and 8
|
|
74
|
+
* per client bucket, in the same atomic acquisition as the app and deployment keys.
|
|
75
|
+
*/
|
|
76
|
+
async acquire(target, { ingress } = {}) {
|
|
66
77
|
if (namespace.parse(target.environment) !== this.namespace)
|
|
67
78
|
throw new Error('Admission environment mismatch');
|
|
68
79
|
const projectId = identifier.parse(target.projectId), deploymentId = identifier.parse(target.deploymentId);
|
|
@@ -71,11 +82,17 @@ export class ArtifactRequestAdmission {
|
|
|
71
82
|
const timeoutMs = limits.timeout_seconds * 1000;
|
|
72
83
|
const requestId = randomUUID();
|
|
73
84
|
const prefix = `artifact-admission:${this.namespace}:{${this.namespace}:${projectId}}`;
|
|
74
|
-
const
|
|
85
|
+
const keyed = [[`${prefix}:app`, maximum], [`${prefix}:deployment:${deploymentId}`, maximum]];
|
|
86
|
+
if (ingress) {
|
|
87
|
+
const id = artifactNextIngressIdSchema.parse(ingress.id), subject = subjectHashSchema.parse(ingress.subjectHash).slice(0, 16);
|
|
88
|
+
const client = createHash('sha256').update(z.string().min(1).max(64).parse(ingress.clientBucket)).digest('hex').slice(0, 16);
|
|
89
|
+
keyed.push([`${prefix}:ingress:${id}`, ingressConcurrencyShare(maximum)], [`${prefix}:ingress:${id}:subject:${subject}`, INGRESS_CONCURRENCY.perSubject], [`${prefix}:ingress:${id}:client:${client}`, INGRESS_CONCURRENCY.perClient]);
|
|
90
|
+
}
|
|
91
|
+
const keys = keyed.map(([key]) => key);
|
|
75
92
|
const began = performance.now();
|
|
76
93
|
let result;
|
|
77
94
|
try {
|
|
78
|
-
result = z.tuple([z.number().int().min(0).max(1), z.number().int().positive()]).parse(await this.options.redis.eval(ACQUIRE,
|
|
95
|
+
result = z.tuple([z.number().int().min(0).max(1), z.number().int().positive()]).parse(await this.options.redis.eval(ACQUIRE, keys.length, ...keys, requestId, ...keyed.map(([, max]) => max), timeoutMs));
|
|
79
96
|
}
|
|
80
97
|
catch {
|
|
81
98
|
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
@@ -93,7 +110,7 @@ export class ArtifactRequestAdmission {
|
|
|
93
110
|
cleanup?.();
|
|
94
111
|
// Repeated finish/close/error notifications must never free another
|
|
95
112
|
// request's permit, nor cause a failed command to be retried.
|
|
96
|
-
releasePromise ??= this.options.redis.eval(RELEASE,
|
|
113
|
+
releasePromise ??= this.options.redis.eval(RELEASE, keys.length, ...keys, requestId).then(() => undefined, () => { throw new ArtifactAdmissionError(503, 'admission_unavailable'); });
|
|
97
114
|
return releasePromise;
|
|
98
115
|
};
|
|
99
116
|
const releaseQuietly = () => {
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { TunnelLease } from '../tunnel/index.js';
|
|
3
3
|
import type { GatewaySession } from './session.js';
|
|
4
4
|
import type { GatewayUsageEvent } from './usage.js';
|
|
5
|
+
import { type IngressAuditBatch, type IngressExchangeRequest, type IngressExchangeResponse, type IngressGrantRequest, type IngressGrantResponse, type IngressRevokeRequest, type IngressSnapshot } from '../server/ingress-control-contract.js';
|
|
5
6
|
export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
6
7
|
kind: z.ZodLiteral<"development">;
|
|
7
8
|
sessionId: z.ZodString;
|
|
@@ -36,6 +37,10 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
36
37
|
frameworkBuildId: z.ZodString;
|
|
37
38
|
upstreamUrl: z.ZodString;
|
|
38
39
|
upstreamAudience: z.ZodOptional<z.ZodString>;
|
|
40
|
+
runtimeTransport: z.ZodOptional<z.ZodEnum<{
|
|
41
|
+
"cloud-run": "cloud-run";
|
|
42
|
+
"local-docker": "local-docker";
|
|
43
|
+
}>>;
|
|
39
44
|
active: z.ZodBoolean;
|
|
40
45
|
published: z.ZodOptional<z.ZodBoolean>;
|
|
41
46
|
projectId: z.ZodString;
|
|
@@ -70,6 +75,10 @@ declare const latticeInvokeGrantSchema: z.ZodObject<{
|
|
|
70
75
|
frameworkBuildId: z.ZodString;
|
|
71
76
|
upstreamUrl: z.ZodString;
|
|
72
77
|
upstreamAudience: z.ZodOptional<z.ZodString>;
|
|
78
|
+
runtimeTransport: z.ZodOptional<z.ZodEnum<{
|
|
79
|
+
"cloud-run": "cloud-run";
|
|
80
|
+
"local-docker": "local-docker";
|
|
81
|
+
}>>;
|
|
73
82
|
publicOrigin: z.ZodString;
|
|
74
83
|
environment: z.ZodString;
|
|
75
84
|
principalSub: z.ZodString;
|
|
@@ -86,6 +95,26 @@ export declare class GatewayControlError extends Error {
|
|
|
86
95
|
readonly status: number;
|
|
87
96
|
constructor(status: number);
|
|
88
97
|
}
|
|
98
|
+
/** Codes an ingress control route may answer with (design §(i)), and the status each one carries. */
|
|
99
|
+
declare const INGRESS_CONTROL_CODES: {
|
|
100
|
+
readonly invalid_token: 401;
|
|
101
|
+
readonly exchange_denied: 403;
|
|
102
|
+
readonly not_found: 404;
|
|
103
|
+
readonly rate_limited: 429;
|
|
104
|
+
readonly temporarily_unavailable: 503;
|
|
105
|
+
};
|
|
106
|
+
export type IngressControlCode = keyof typeof INGRESS_CONTROL_CODES;
|
|
107
|
+
/**
|
|
108
|
+
* A refusal an ingress control route decided: its contract code and Atlas's `Retry-After`, so the
|
|
109
|
+
* gateway answers the caller with Atlas's decision instead of inventing one. `code` is null when the
|
|
110
|
+
* response named no contract code matching its status (a misconfigured control token, a bare 500):
|
|
111
|
+
* the gateway then answers 503, never a code that would send the client into a sign-in loop.
|
|
112
|
+
*/
|
|
113
|
+
export declare class IngressControlError extends GatewayControlError {
|
|
114
|
+
readonly code: IngressControlCode | null;
|
|
115
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
116
|
+
constructor(status: number, code: IngressControlCode | null, retryAfterSeconds?: number | undefined);
|
|
117
|
+
}
|
|
89
118
|
export declare class ArtifactGatewayControl {
|
|
90
119
|
private readonly options;
|
|
91
120
|
private readonly origin;
|
|
@@ -95,6 +124,17 @@ export declare class ArtifactGatewayControl {
|
|
|
95
124
|
fetch?: typeof fetch;
|
|
96
125
|
});
|
|
97
126
|
private call;
|
|
127
|
+
private send;
|
|
128
|
+
/** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
|
|
129
|
+
ingressSnapshot(): Promise<IngressSnapshot>;
|
|
130
|
+
/** Verifies an identity provider's token for an ingress; refusals carry Atlas's contract code. */
|
|
131
|
+
ingressExchange(body: IngressExchangeRequest): Promise<IngressExchangeResponse>;
|
|
132
|
+
/** Re-checks one ingress request against the active deployment and returns where to proxy it. */
|
|
133
|
+
ingressGrant(body: IngressGrantRequest): Promise<IngressGrantResponse>;
|
|
134
|
+
/** Logout: revokes every identity provider token the subject was issued up to Atlas's now. */
|
|
135
|
+
ingressRevoke(body: IngressRevokeRequest): Promise<void>;
|
|
136
|
+
/** One batch of refusals the gateway decided locally (Atlas audits the ones it decided itself). */
|
|
137
|
+
ingressAudit(body: IngressAuditBatch): Promise<void>;
|
|
98
138
|
/** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
|
|
99
139
|
usage(events: GatewayUsageEvent[]): Promise<{
|
|
100
140
|
rejected: number;
|
package/dist/gateway/control.js
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
4
4
|
import { artifactRequestContextSchema } from '../server/request-context.js';
|
|
5
5
|
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
6
|
+
import { INGRESS_SNAPSHOT_MAX_BYTES, ingressExchangeResponseSchema, ingressGrantResponseSchema, ingressSnapshotSchema, } from '../server/ingress-control-contract.js';
|
|
6
7
|
const id = z.string().min(1).max(255);
|
|
7
8
|
const base = {
|
|
8
9
|
projectId: id, ownerId: id, environment: id, publicOrigin: z.string().transform(normalizePublicOrigin),
|
|
@@ -17,7 +18,7 @@ const base = {
|
|
|
17
18
|
};
|
|
18
19
|
export const gatewayTargetSchema = z.discriminatedUnion('kind', [
|
|
19
20
|
z.object({ ...base, kind: z.literal('development'), sessionId: id, epoch: z.number().int().positive() }),
|
|
20
|
-
z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), active: z.boolean(), published: z.boolean().optional() }),
|
|
21
|
+
z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(), active: z.boolean(), published: z.boolean().optional() }),
|
|
21
22
|
]);
|
|
22
23
|
const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
|
|
23
24
|
const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
|
|
@@ -27,6 +28,7 @@ const latticeInvokeGrantSchema = z.object({
|
|
|
27
28
|
frameworkBuildId: artifactNextFrameworkBuildIdSchema,
|
|
28
29
|
upstreamUrl: z.string().url(),
|
|
29
30
|
upstreamAudience: z.string().url().optional(),
|
|
31
|
+
runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(),
|
|
30
32
|
publicOrigin: z.string().min(1).max(2048),
|
|
31
33
|
environment: id,
|
|
32
34
|
principalSub: id,
|
|
@@ -46,6 +48,49 @@ export class GatewayControlError extends Error {
|
|
|
46
48
|
this.name = 'GatewayControlError';
|
|
47
49
|
}
|
|
48
50
|
}
|
|
51
|
+
/** Codes an ingress control route may answer with (design §(i)), and the status each one carries. */
|
|
52
|
+
const INGRESS_CONTROL_CODES = { invalid_token: 401, exchange_denied: 403, not_found: 404, rate_limited: 429, temporarily_unavailable: 503 };
|
|
53
|
+
const INGRESS_ERROR_BODY_MAX_BYTES = 4096;
|
|
54
|
+
function isIngressControlCode(value) {
|
|
55
|
+
return Object.hasOwn(INGRESS_CONTROL_CODES, value);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A refusal an ingress control route decided: its contract code and Atlas's `Retry-After`, so the
|
|
59
|
+
* gateway answers the caller with Atlas's decision instead of inventing one. `code` is null when the
|
|
60
|
+
* response named no contract code matching its status (a misconfigured control token, a bare 500):
|
|
61
|
+
* the gateway then answers 503, never a code that would send the client into a sign-in loop.
|
|
62
|
+
*/
|
|
63
|
+
export class IngressControlError extends GatewayControlError {
|
|
64
|
+
code;
|
|
65
|
+
retryAfterSeconds;
|
|
66
|
+
constructor(status, code, retryAfterSeconds) {
|
|
67
|
+
super(status);
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
70
|
+
this.name = 'IngressControlError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The contract code of a refused control response, read from at most 4 KiB of its JSON body. */
|
|
74
|
+
async function ingressRefusal(response) {
|
|
75
|
+
const retryAfter = Number(response.headers.get('retry-after'));
|
|
76
|
+
const retryAfterSeconds = Number.isSafeInteger(retryAfter) && retryAfter > 0 && retryAfter <= 3600 ? retryAfter : undefined;
|
|
77
|
+
let code = null;
|
|
78
|
+
try {
|
|
79
|
+
const length = Number(response.headers.get('content-length'));
|
|
80
|
+
const text = length > INGRESS_ERROR_BODY_MAX_BYTES ? '' : (await response.text()).slice(0, INGRESS_ERROR_BODY_MAX_BYTES);
|
|
81
|
+
const parsed = z.object({ error: z.string() }).safeParse(JSON.parse(text));
|
|
82
|
+
const named = parsed.success && isIngressControlCode(parsed.data.error) ? parsed.data.error : null;
|
|
83
|
+
// A code is believed only with the status the contract gives it.
|
|
84
|
+
code = named !== null && INGRESS_CONTROL_CODES[named] === response.status ? named : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
code = null;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await response.body?.cancel().catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
return new IngressControlError(response.status, code, retryAfterSeconds);
|
|
93
|
+
}
|
|
49
94
|
export class ArtifactGatewayControl {
|
|
50
95
|
options;
|
|
51
96
|
origin;
|
|
@@ -55,15 +100,19 @@ export class ArtifactGatewayControl {
|
|
|
55
100
|
if (options.token.length < 32)
|
|
56
101
|
throw new Error('Artifact control plane token requires at least 32 characters');
|
|
57
102
|
}
|
|
58
|
-
|
|
103
|
+
call(path, body, timeoutMs = 10_000) {
|
|
104
|
+
return this.send({ path, body, timeoutMs, maxBytes: 128 * 1024 });
|
|
105
|
+
}
|
|
106
|
+
async send({ path, body, timeoutMs, maxBytes, ingress = false }) {
|
|
59
107
|
const response = await (this.options.fetch ?? fetch)(`${this.origin}/api/artifact-studio/next/control/${path}`, {
|
|
60
|
-
method:
|
|
61
|
-
|
|
108
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
109
|
+
headers: { authorization: `Bearer ${this.options.token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
110
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
|
|
62
111
|
});
|
|
63
112
|
if (!response.ok)
|
|
64
|
-
throw new GatewayControlError(response.status);
|
|
113
|
+
throw ingress ? await ingressRefusal(response) : new GatewayControlError(response.status);
|
|
65
114
|
const contentLength = Number(response.headers.get('content-length'));
|
|
66
|
-
if (contentLength >
|
|
115
|
+
if (contentLength > maxBytes) {
|
|
67
116
|
await response.body?.cancel();
|
|
68
117
|
throw new GatewayControlError(502);
|
|
69
118
|
}
|
|
@@ -78,7 +127,7 @@ export class ArtifactGatewayControl {
|
|
|
78
127
|
if (done)
|
|
79
128
|
break;
|
|
80
129
|
length += value.length;
|
|
81
|
-
if (length >
|
|
130
|
+
if (length > maxBytes)
|
|
82
131
|
throw new GatewayControlError(502);
|
|
83
132
|
chunks.push(value);
|
|
84
133
|
}
|
|
@@ -88,6 +137,26 @@ export class ArtifactGatewayControl {
|
|
|
88
137
|
await reader.cancel().catch(() => undefined);
|
|
89
138
|
}
|
|
90
139
|
}
|
|
140
|
+
/** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
|
|
141
|
+
async ingressSnapshot() {
|
|
142
|
+
return ingressSnapshotSchema.parse(await this.send({ path: 'ingress-snapshot', timeoutMs: 10_000, maxBytes: INGRESS_SNAPSHOT_MAX_BYTES }));
|
|
143
|
+
}
|
|
144
|
+
/** Verifies an identity provider's token for an ingress; refusals carry Atlas's contract code. */
|
|
145
|
+
async ingressExchange(body) {
|
|
146
|
+
return ingressExchangeResponseSchema.parse(await this.send({ path: 'ingress-exchange', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true }));
|
|
147
|
+
}
|
|
148
|
+
/** Re-checks one ingress request against the active deployment and returns where to proxy it. */
|
|
149
|
+
async ingressGrant(body) {
|
|
150
|
+
return ingressGrantResponseSchema.parse(await this.send({ path: 'ingress-grant', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true }));
|
|
151
|
+
}
|
|
152
|
+
/** Logout: revokes every identity provider token the subject was issued up to Atlas's now. */
|
|
153
|
+
async ingressRevoke(body) {
|
|
154
|
+
await this.send({ path: 'ingress-revoke', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true });
|
|
155
|
+
}
|
|
156
|
+
/** One batch of refusals the gateway decided locally (Atlas audits the ones it decided itself). */
|
|
157
|
+
async ingressAudit(body) {
|
|
158
|
+
await this.send({ path: 'ingress-audit', body, timeoutMs: 5_000, maxBytes: 16 * 1024, ingress: true });
|
|
159
|
+
}
|
|
91
160
|
/** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
|
|
92
161
|
async usage(events) {
|
|
93
162
|
const value = await this.call('usage', { events }, 5_000);
|