@byok-sdk/server 0.1.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.
@@ -0,0 +1,378 @@
1
+ import type { AgentEventOrUnknown, BlobRef, PermissionPolicy, RuntimeCapabilities, RuntimeId, RuntimeInfo, TaskApprovalResolvedPayload, TaskArtifactPayload, TaskState } from '@byok-sdk/protocol';
2
+ import type { BlobStore } from './blob-store';
3
+ import type { RateLimiterOptions } from './rate-limiter';
4
+ import type { TaskStore } from './task-store';
5
+ import type { TokenSigner } from './auth';
6
+ /** Options for {@link createByokServer}. */
7
+ export interface CreateByokServerOptions {
8
+ /**
9
+ * Identifies which product this server instance serves. Checked against the
10
+ * `productId` a daemon announces in `conn.hello` — one daemon process is
11
+ * always scoped to one product (see plan: "一产品一 daemon 进程"), so a
12
+ * mismatched daemon is rejected at handshake time.
13
+ */
14
+ productId: string;
15
+ /** WS-native ping interval, ms (§ heartbeat). Default 30s. */
16
+ heartbeatIntervalMs?: number;
17
+ /** How long `GET /byok/events` holds an empty poll open before returning, ms (§8). Default ~50s; override for tests. */
18
+ longPollHoldMs?: number;
19
+ /** Per-product blob size ceiling in bytes (§7). Default 100MB. */
20
+ maxBlobSizeBytes?: number;
21
+ /** Override the reference {@link BlobStore} (e.g. a real object-store-backed implementation, or `sqlite-blob-store.ts`'s `SqliteBlobStore` for a persistent single-node deployment). */
22
+ blobStore?: BlobStore;
23
+ /** Override the reference {@link TaskStore} (e.g. `sqlite-task-store.ts`'s `SqliteTaskStore` for a persistent single-node deployment). Defaults to an in-memory store that loses all task state on restart. */
24
+ taskStore?: TaskStore;
25
+ /** Override the reference {@link TokenSigner} (e.g. an org-wide/KMS-backed signer). */
26
+ tokenSigner?: TokenSigner;
27
+ /**
28
+ * How long a `Claimed`/`Running`/`AwaitApproval` task may sit with no
29
+ * inbound `task.*` activity from its owning device while that device is
30
+ * dark (disconnected, or long-poll-silent) before the server reaps it to
31
+ * `Failed(retryable: true, reason: 'lease-expired')` — no new task state,
32
+ * no new wire message; the embedder is expected to re-dispatch as a
33
+ * brand-new task, same as any other retryable failure. Deliberately
34
+ * generous — it exists purely as a backstop for a device that never
35
+ * reconnects at all (M1's redelivery, docs/protocol.md §9, already covers
36
+ * "came back within the window"), so it must stay far larger than any
37
+ * realistic task duration or it will race and fail perfectly healthy
38
+ * long-running tasks. A task on a *connected*, actively-progressing
39
+ * device is never touched regardless of this value — see
40
+ * `ConnectionHub`'s lease-reaper doc comment (`hub.ts`) for the full
41
+ * design and its accepted residual risk. Default 30 minutes.
42
+ */
43
+ taskLeaseMs?: number;
44
+ /**
45
+ * M4 Phase 4 (part A): per-device inbound-envelope token bucket, enforced
46
+ * by `ConnectionHub.handleInbound` (`hub.ts`) — the single choke point
47
+ * both WS (`ws-server.ts`) and long-poll (`POST /byok/messages`, `http.ts`)
48
+ * inbound traffic passes through. Defaults: 50 msg/s sustained, burst 100
49
+ * (see `rate-limiter.ts`'s own defaults). Exceeding it never drops
50
+ * silently: it counts in `ConnectionHub.stats()`'s `rateLimitEvents`, and
51
+ * emits a `device.rate_limited` {@link ByokServerEvent} — see that
52
+ * variant's own doc comment for the per-transport enforcement shape (WS
53
+ * close vs. long-poll 429). Blob upload/download routes (`http.ts`) are
54
+ * deliberately NOT covered by this same bucket — see that file's own
55
+ * comment on why a shared limiter didn't drop in cleanly there.
56
+ *
57
+ * Honest caveat (no code change changes this — it's an inherent property
58
+ * of an abrupt WS close, not something rate limiting adds): a
59
+ * flood-triggered 1008 close is not special. Envelopes the daemon's own
60
+ * WS transport already handed off to its socket write between the moment
61
+ * the device exceeded budget and the close actually landing share the
62
+ * ordinary at-most-once exposure of ANY abrupt WS disconnect (network
63
+ * blip, server restart, etc.) — the wire's at-least-once guarantee
64
+ * (docs/protocol.md §9) is specified for the server->daemon direction
65
+ * only; daemon->server has no redelivery cursor to begin with, so this
66
+ * was already true before rate limiting existed. A flood just makes that
67
+ * pre-existing window more likely to have something in flight at the
68
+ * exact moment of a close.
69
+ */
70
+ rateLimit?: RateLimiterOptions;
71
+ /**
72
+ * M4 Phase 4 (part B.2): opt-in `GET /healthz` liveness route on the Hono
73
+ * app (`http.ts`) — deliberately unauthenticated (no bearer check) and
74
+ * carrying no sensitive data (no device ids, no counts), just
75
+ * `{ok:true, uptimeMs}`; see `http.ts`'s own comment on that route for the
76
+ * full auth-posture rationale. Default `false` (no route mounted at all).
77
+ * `ConnectionHub.stats()` (richer, in-process-only detail) is never
78
+ * exposed over HTTP by this SDK regardless of this flag — an embedder that
79
+ * wants that surfaced remotely builds its own authenticated route around
80
+ * `stats()`.
81
+ */
82
+ healthzRoute?: boolean;
83
+ }
84
+ /** Input to {@link ByokServer.dispatch}. */
85
+ export interface DispatchInput {
86
+ instruction: string;
87
+ runtime?: RuntimeId;
88
+ policy?: PermissionPolicy;
89
+ deviceId?: string;
90
+ sessionRef?: string;
91
+ }
92
+ /** Outcome of a task that reached a terminal state. */
93
+ export interface TaskResult {
94
+ state: Extract<TaskState, 'Complete' | 'Failed' | 'Cancelled'>;
95
+ summary?: string;
96
+ sessionRef?: string;
97
+ artifactRefs?: BlobRef[];
98
+ reason?: string;
99
+ retryable?: boolean;
100
+ }
101
+ /**
102
+ * Normalized event stream for a dispatched task: incoming `task.progress`
103
+ * AgentEvents, state transitions, and artifacts, folded into one feed so a
104
+ * consumer only has to read one `events()` iterable per task.
105
+ *
106
+ * `event` is {@link AgentEventOrUnknown}, not the narrower `AgentEvent`
107
+ * (pre-freeze tolerance, `@byok-sdk/protocol`'s `agent-event.ts`): an
108
+ * unknown-type event — one a newer daemon/runtime-adapter minor version
109
+ * produced that this build doesn't recognize — is forwarded here as-is
110
+ * rather than dropped. It's still observability data a newer embedder UI
111
+ * may understand even if this server doesn't; the reference server's job is
112
+ * to tolerate and forward, not to decide what's renderable. Use the
113
+ * exported `isKnownAgentEvent`/`partitionAgentEvents` helpers if a consumer
114
+ * needs to distinguish the two.
115
+ */
116
+ export type ServerTaskEvent = {
117
+ kind: 'state';
118
+ state: TaskState;
119
+ at: string;
120
+ } | {
121
+ kind: 'agent';
122
+ event: AgentEventOrUnknown;
123
+ } | {
124
+ kind: 'artifact';
125
+ artifact: TaskArtifactPayload;
126
+ } | {
127
+ kind: 'await_approval';
128
+ summary: string;
129
+ } | {
130
+ kind: 'error';
131
+ reason: string;
132
+ retryable?: boolean;
133
+ };
134
+ /** Handle returned by {@link ByokServer.dispatch} for one in-flight task. */
135
+ export interface TaskHandle {
136
+ readonly taskId: string;
137
+ events(): AsyncIterable<ServerTaskEvent>;
138
+ cancel(reason?: string): Promise<void>;
139
+ /**
140
+ * M5 (approval targeting, docs/protocol.md §5.3): `opts.approvalId`
141
+ * targets a SPECIFIC pending approval rather than "whichever one is
142
+ * currently pending" (the default when `opts` is omitted, unchanged from
143
+ * pre-M5). Thin wrapper over `ConnectionHub.approveTask` (`hub.ts`) — see
144
+ * that method's own doc comment for the full targeting/staleness
145
+ * semantics, including when this throws `StaleApprovalError` (exported
146
+ * from the package index for a caller to catch/inspect).
147
+ */
148
+ approve(opts?: {
149
+ approvalId?: string;
150
+ }): Promise<void>;
151
+ /** M5: same `opts.approvalId` targeting semantics as {@link approve} above. */
152
+ reject(reason?: string, opts?: {
153
+ approvalId?: string;
154
+ }): Promise<void>;
155
+ steer(text: string): Promise<void>;
156
+ result(): Promise<TaskResult>;
157
+ }
158
+ /** A device known to this server, joined from pairing identity + live connection state. */
159
+ export interface MachineInfo {
160
+ deviceId: string;
161
+ deviceName: string;
162
+ connected: boolean;
163
+ lastSeen?: string;
164
+ /** Runtimes detected on this device, as reported in its last `conn.hello` (M1: typed, replaces the old untyped `agents`). */
165
+ runtimes?: RuntimeInfo[];
166
+ }
167
+ /** Snapshot of a task as tracked by the in-memory {@link TaskStore}. */
168
+ export interface TaskSnapshot {
169
+ taskId: string;
170
+ state: TaskState;
171
+ instruction: string;
172
+ /**
173
+ * The REQUESTED runtime — `DispatchInput.runtime`, forwarded verbatim into
174
+ * `task.offer.runtime`. Set once at `dispatch()` time and never touched
175
+ * again afterward, regardless of what the daemon actually ends up running.
176
+ * `undefined` means "no preference was expressed" (the daemon auto-selects,
177
+ * pi-first) — NOT "the daemon ran no runtime". Contrast with
178
+ * {@link claimedRuntime}, the ACTUAL runtime the daemon reports having
179
+ * picked; see that field's own doc comment for the full requested-vs-
180
+ * claimed distinction (docs/protocol.md §3.1).
181
+ */
182
+ runtime?: RuntimeId;
183
+ policy: PermissionPolicy;
184
+ deviceId?: string;
185
+ sessionRef?: string;
186
+ createdAt: string;
187
+ updatedAt: string;
188
+ result?: TaskResult;
189
+ /**
190
+ * M5 (approval targeting, docs/protocol.md §5.3): the daemon-reported
191
+ * `approvalId` for the CURRENT `AwaitApproval` cycle, if this server has
192
+ * learned one (`ConnectionHub.onAwaitApproval`, `hub.ts`) — `undefined`
193
+ * whenever the task isn't currently awaiting approval, OR it is but no id
194
+ * was ever reported for it (a legacy daemon). Cleared centrally the
195
+ * instant the task LEAVES `AwaitApproval` (`ConnectionHub`'s
196
+ * `transitionTask`), so a later `AwaitApproval` cycle for the same task
197
+ * never inherits a stale id from a previous one. `approveTask`/
198
+ * `rejectTask` compare an operator-supplied target id against this field
199
+ * to decide whether a decision is stale (`StaleApprovalError`) — see
200
+ * `hub.ts` for the full mechanism.
201
+ */
202
+ pendingApprovalId?: string;
203
+ /**
204
+ * M5 (claimed runtime, docs/protocol.md §3.1): the ACTUAL adapter the
205
+ * daemon reports having selected for this task (`task.claim.runtime`,
206
+ * `ConnectionHub.onClaim` — `hub.ts`) — covers both the explicit-runtime
207
+ * path (echoes {@link runtime}) and the auto-select/pi-first path (a value
208
+ * where {@link runtime} is `undefined`, since no preference was ever
209
+ * requested). `undefined` until the first `task.claim` for this task
210
+ * arrives, and forever after for a legacy daemon that predates this field
211
+ * (an old daemon's `task.claim` simply omits it). Set exactly once, at the
212
+ * `Offered -> Claimed` transition, and never modified again afterward — a
213
+ * retried/idempotent claim from the same device is a no-op that never
214
+ * reaches `onClaim`'s patch at all (see `onClaim`'s own doc comment), so
215
+ * this can never be silently overwritten by a redelivered claim.
216
+ */
217
+ claimedRuntime?: RuntimeId;
218
+ /**
219
+ * S0/D-4 (runtime-honest control surface): the capability block the
220
+ * CLAIMING adapter reported for itself on its own `task.claim`
221
+ * (`TaskClaimPayload.capabilities`, `@byok-sdk/protocol`), snapshotted at the
222
+ * exact moment of the `Offered -> Claimed` transition
223
+ * (`ConnectionHub.onClaim`, `hub.ts`).
224
+ *
225
+ * Sourced from the claim and from nothing else. The connection-level
226
+ * `conn.hello.runtimes[].capabilities` is discovery data — it describes a
227
+ * device rather than a task, and a long-poll-only daemon never sends
228
+ * `conn.hello` at all — so it is never read here or by the gate; see
229
+ * `SteerRejectedError` (`hub.ts`) for the full argument.
230
+ *
231
+ * A SNAPSHOT, deliberately — not a live read of anything: the same device
232
+ * can reconnect later with a different adapter set (a runtime upgraded,
233
+ * removed, or newly installed mid-task), and a task that is already running
234
+ * must keep being judged against what was true when it was claimed.
235
+ * `ConnectionHub.steerTask` is the consumer: it fails closed with a
236
+ * `SteerRejectedError` (`hub.ts`) unless this snapshot says `steer === true`,
237
+ * BEFORE any `task.steer` envelope exists.
238
+ *
239
+ * `undefined` means "this server does not know" — never "supported" and
240
+ * never "unsupported as a fact". It stays `undefined` when the claim carried
241
+ * no `capabilities` (a pre-D-4 daemon; the wire field is optional) and for
242
+ * every task record written before S0 existed. Both are treated as a refusal
243
+ * by the steer gate rather than filled in with a guessed default.
244
+ *
245
+ * Written exactly once, alongside {@link claimedRuntime}, on the first
246
+ * real claim — a retried/idempotent claim returns from `onClaim` before
247
+ * the patch, so this can never be silently rewritten later.
248
+ */
249
+ claimedRuntimeCapabilities?: RuntimeCapabilities;
250
+ }
251
+ /**
252
+ * Cross-cutting server event feed (device connects/disconnects, task
253
+ * creation/state changes) — the "event hub" from the plan's 服务端参考实现
254
+ * section, as opposed to `TaskHandle.events()` which is scoped to one task.
255
+ * Not part of the pinned wire contract; a server-embedder-facing convenience.
256
+ */
257
+ export type ByokServerEvent = {
258
+ kind: 'device.connected';
259
+ deviceId: string;
260
+ at: string;
261
+ } | {
262
+ kind: 'device.disconnected';
263
+ deviceId: string;
264
+ at: string;
265
+ } | {
266
+ kind: 'task.created';
267
+ taskId: string;
268
+ at: string;
269
+ } | {
270
+ kind: 'task.state';
271
+ taskId: string;
272
+ state: TaskState;
273
+ at: string;
274
+ /**
275
+ * M5 (claimed runtime): mirrors {@link TaskSnapshot.claimedRuntime} at
276
+ * the moment of this transition — `undefined` until (and unless) the
277
+ * daemon's `task.claim` reported one, so it first appears on the
278
+ * `Offered -> Claimed` event and stays whatever value it had from then
279
+ * on for every later transition of the same task. See that field's own
280
+ * doc comment for the requested-vs-claimed distinction
281
+ * (docs/protocol.md §3.1).
282
+ */
283
+ claimedRuntime?: RuntimeId;
284
+ }
285
+ /**
286
+ * M4 Phase 3 hardening (orchestrator-directed): the daemon resolved a
287
+ * pending approval entirely locally (M4 Phase 3's local `approvals.resolve`
288
+ * control-socket path) — no wire `task.approve`/`task.reject` ever reached
289
+ * the server for it. This fires when daemon-originated task traffic
290
+ * (`task.progress`/`task.artifact`/`task.complete`) for a task the server's
291
+ * own record still has as `AwaitApproval` proves, after the fact, that the
292
+ * approval was resolved on the device — see `ConnectionHub`'s
293
+ * `resumeIfImplicitlyApproved` (hub.ts) for the state-machine side of this.
294
+ * Deliberately NOT a wire message (no `packages/protocol` change) — a
295
+ * first-class `task.approval_resolved` wire notification is a deferred
296
+ * v1.1 candidate; this is purely an embedder-facing observability signal
297
+ * so a SaaS UI can distinguish "approved server-side" from "the device
298
+ * says it was approved locally" if it cares to.
299
+ */
300
+ | {
301
+ kind: 'task.approval_resolved_implicit';
302
+ taskId: string;
303
+ at: string;
304
+ }
305
+ /**
306
+ * M4 (additive-minor): the EXPLICIT counterpart to
307
+ * `task.approval_resolved_implicit` above — fires when the daemon reports
308
+ * a locally-resolved approval via the wire `task.approval_resolved`
309
+ * message (`ConnectionHub.onApprovalResolved`, `hub.ts`) rather than the
310
+ * server having to infer it from later task traffic. Carries the same
311
+ * `approvalId`/`decision`/`resolvedBy` the daemon reported, so an embedder
312
+ * can render/audit exactly what was resolved and by which path, not just
313
+ * that a resolution happened. `resolvedBy` is currently always `'local'`
314
+ * (`@byok-sdk/protocol`'s `TaskApprovalResolvedPayloadSchema` — a single-value
315
+ * enum today, future-proofed for an additional value later without a
316
+ * version bump). Mutually exclusive with `task.approval_resolved_implicit`
317
+ * for the same resolution: whichever mechanism the server processes first
318
+ * performs the actual `AwaitApproval -> Running` transition, and the other
319
+ * is already a no-op by the time it would otherwise run — see
320
+ * `onApprovalResolved`'s own doc comment (`hub.ts`) for the full
321
+ * relationship.
322
+ */
323
+ | ({
324
+ kind: 'task.approval_resolved';
325
+ taskId: string;
326
+ at: string;
327
+ /**
328
+ * M5 (hello-capability plumbing, docs/protocol.md §5.3): whether the
329
+ * REPORTING device advertised the `approval-targeting` capability flag
330
+ * (`version.ts`) in its `conn.hello` — an observability-only signal
331
+ * (see that flag's own doc comment: it never gates matching, which is
332
+ * always decided by field presence on the specific message). `false`
333
+ * for a legacy daemon, or one whose connection capabilities this hub
334
+ * never recorded (see `ConnectionHub.getDeviceCapabilities`).
335
+ */
336
+ targeted: boolean;
337
+ } & Pick<TaskApprovalResolvedPayload, 'approvalId' | 'decision' | 'resolvedBy'>)
338
+ /**
339
+ * M4 Phase 4 (part A): `deviceId` exceeded its inbound-envelope rate limit
340
+ * (`CreateByokServerOptions.rateLimit`, enforced in
341
+ * `ConnectionHub.handleInbound`, `hub.ts`) — fired for every envelope that
342
+ * arrives once the bucket is empty, not just the first. Never a silent
343
+ * drop: this event fires AND the occurrence is counted in
344
+ * `ConnectionHub.stats()`'s `rateLimitEvents`. Per-transport enforcement
345
+ * differs (both still emit this same event): a WS connection is closed
346
+ * (policy-violation close code) right after, so the client's existing
347
+ * backoff+reconnect (protocol §9's redelivery covers the rest); a
348
+ * long-poll device has no live connection to close, so `POST
349
+ * /byok/messages` (`http.ts`) instead answers that request with HTTP 429.
350
+ */
351
+ | {
352
+ kind: 'device.rate_limited';
353
+ deviceId: string;
354
+ at: string;
355
+ };
356
+ /**
357
+ * Plain, serializable in-process snapshot returned by
358
+ * `ConnectionHub.stats()` (`hub.ts`) — M4 Phase 4 (part B.1). Deliberately
359
+ * NOT exposed over HTTP by this SDK (see `CreateByokServerOptions.healthzRoute`'s
360
+ * doc comment): an embedder that wants any of this surfaced remotely builds
361
+ * its own authenticated route around `ByokServer.stats()`.
362
+ */
363
+ export interface HubStats {
364
+ /** Devices with a currently-live WS or long-poll connection. */
365
+ connectedDeviceCount: number;
366
+ /** Every {@link TaskState} mapped to how many known tasks currently sit in it. */
367
+ taskCountsByState: Record<TaskState, number>;
368
+ /** Total inbound daemon->server envelopes {@link ConnectionHub.handleInbound} has ever been called with (every outcome, including rejected/rate-limited). */
369
+ envelopesIn: number;
370
+ /** Total server->daemon envelopes ever constructed via {@link ConnectionHub}'s single outbound choke point (`sendToDevice`), regardless of whether a live transport was available to flush them immediately. */
371
+ envelopesOut: number;
372
+ /** Inbound envelopes recognized as an already-seen `(deviceId, id)` pair (N3) — a no-op wire-level success, counted here for observability. */
373
+ dedupDrops: number;
374
+ /** Inbound envelopes rejected for exceeding a device's rate limit — see `device.rate_limited` on {@link ByokServerEvent}. */
375
+ rateLimitEvents: number;
376
+ /** Milliseconds since this `ConnectionHub` was constructed. */
377
+ uptimeMs: number;
378
+ }
@@ -0,0 +1,20 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { type AuthDeps } from './auth';
3
+ import type { ConnectionHub } from './hub';
4
+ interface AttachDeps extends AuthDeps {
5
+ hub: ConnectionHub;
6
+ productId: string;
7
+ /** WS-native ping interval, ms. Defaults inside `heartbeat.ts` (30s) if omitted. */
8
+ heartbeatIntervalMs?: number;
9
+ }
10
+ /**
11
+ * Wire up the `GET /byok/ws` upgrade on a raw Node HTTP server (the one
12
+ * `@hono/node-server`'s `serve()` returns). Auth happens on the upgrade
13
+ * request itself via `Authorization: Bearer <accessToken>` (a JWT minted by
14
+ * `/byok/pair` or `/byok/token` — Auth v2, §6); an invalid, expired, or
15
+ * revoked token gets a 401 and the socket is destroyed. Handshake
16
+ * (`conn.hello` -> `conn.ack`) happens on the first WS message once
17
+ * upgraded.
18
+ */
19
+ export declare function attachWebSocket(server: HttpServer, deps: AttachDeps): void;
20
+ export {};
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@byok-sdk/server",
3
+ "version": "0.1.0",
4
+ "description": "BYOK SDK server: in-memory M0 reference implementation of the SaaS-side coordinator",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Ancienttwo/byok-sdk.git",
10
+ "directory": "packages/server"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Ancienttwo/byok-sdk/issues"
14
+ },
15
+ "homepage": "https://github.com/Ancienttwo/byok-sdk#readme",
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "sideEffects": false,
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@hono/node-server": "^2.0.10",
40
+ "hono": "^4.12.30",
41
+ "jose": "^6.2.3",
42
+ "ws": "^8.21.1",
43
+ "@byok-sdk/protocol": "0.1.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/ws": "^8.18.1"
47
+ },
48
+ "scripts": {
49
+ "build": "tsup && tsc -p tsconfig.build.json",
50
+ "dev": "tsup --watch",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "typecheck": "tsc --noEmit",
54
+ "clean": "rm -rf dist"
55
+ }
56
+ }