@zveltio/sdk 2.0.8 → 3.0.0-beta.1
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/dist/build/index.d.ts +77 -0
- package/dist/build/index.d.ts.map +1 -0
- package/dist/build/index.js +157 -0
- package/dist/client/QueryBuilder.d.ts.map +1 -1
- package/dist/client/QueryBuilder.js +12 -3
- package/dist/client/RealtimeClient.d.ts.map +1 -1
- package/dist/client/RealtimeClient.js +3 -1
- package/dist/client/ZveltioClient.d.ts.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +16 -5
- package/dist/core.d.ts.map +1 -1
- package/dist/ddl/index.d.ts +153 -0
- package/dist/ddl/index.d.ts.map +1 -0
- package/dist/ddl/index.js +33 -0
- package/dist/extension/index.d.ts +522 -10
- package/dist/extension/index.d.ts.map +1 -1
- package/dist/extension/index.js +1 -1
- package/dist/extension/permission-gate.d.ts +37 -0
- package/dist/extension/permission-gate.d.ts.map +1 -0
- package/dist/extension/permission-gate.js +93 -0
- package/dist/generate-types.d.ts +37 -0
- package/dist/generate-types.d.ts.map +1 -0
- package/dist/generate-types.js +157 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/local-store.d.ts.map +1 -1
- package/dist/local-store.js +1 -3
- package/dist/offline/index.d.ts +105 -0
- package/dist/offline/index.d.ts.map +1 -0
- package/dist/offline/index.js +235 -0
- package/dist/publish/index.d.ts +94 -0
- package/dist/publish/index.d.ts.map +1 -0
- package/dist/publish/index.js +148 -0
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +6 -2
- package/dist/rpc/index.d.ts +68 -0
- package/dist/rpc/index.d.ts.map +1 -0
- package/dist/rpc/index.js +61 -0
- package/dist/schema-codegen.d.ts +60 -0
- package/dist/schema-codegen.d.ts.map +1 -0
- package/dist/schema-codegen.js +369 -0
- package/dist/schema-watcher.d.ts.map +1 -1
- package/dist/schema-watcher.js +1 -3
- package/dist/studio/index.d.ts +84 -0
- package/dist/studio/index.d.ts.map +1 -0
- package/dist/studio/index.js +180 -0
- package/dist/svelte.d.ts.map +1 -1
- package/dist/sync-manager.d.ts.map +1 -1
- package/dist/sync-manager.js +4 -11
- package/dist/testing/index.d.ts +121 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +283 -0
- package/dist/testing/with-test-db.d.ts +119 -0
- package/dist/testing/with-test-db.d.ts.map +1 -0
- package/dist/testing/with-test-db.js +313 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +3 -1
- package/dist/validate/index.d.ts +95 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +201 -0
- package/dist/validate/migration-parse.d.ts +17 -0
- package/dist/validate/migration-parse.d.ts.map +1 -0
- package/dist/validate/migration-parse.js +11 -0
- package/package.json +53 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-sync provider abstraction.
|
|
3
|
+
*
|
|
4
|
+
* The SDK ships TWO sync strategies for the Postgres ↔ client-SQLite
|
|
5
|
+
* loop, picked at construction time:
|
|
6
|
+
*
|
|
7
|
+
* - **`crdt`** (default) — bespoke field-level Last-Write-Wins merge on
|
|
8
|
+
* top of IndexedDB / SQLite via `LocalStore` + `SyncManager`. Works
|
|
9
|
+
* offline, applies on reconnect, handles conflicts. No external
|
|
10
|
+
* dependency beyond the SDK + engine.
|
|
11
|
+
*
|
|
12
|
+
* - **`electric`** — Electric SQL replication. Postgres logical-
|
|
13
|
+
* replication slot → Electric service → client. Conflicts are
|
|
14
|
+
* resolved server-side via CRDTs Electric implements internally.
|
|
15
|
+
* Requires an Electric service running alongside Postgres + the
|
|
16
|
+
* engine routes from `routes/electric.ts` that mint short-lived
|
|
17
|
+
* JWTs the client uses to authenticate.
|
|
18
|
+
*
|
|
19
|
+
* Auth flow for the electric provider
|
|
20
|
+
* -----------------------------------
|
|
21
|
+
* 1. Provider calls `POST {engineUrl}/api/electric/auth` (with
|
|
22
|
+
* credentials; the engine reads the better-auth session).
|
|
23
|
+
* 2. Engine returns `{ token, expiresAt, electricUrl }`.
|
|
24
|
+
* 3. Provider opens a websocket to `${electricUrl}?token=<jwt>`.
|
|
25
|
+
* 4. Before each token expires, the provider re-requests a fresh one
|
|
26
|
+
* (no user interaction).
|
|
27
|
+
*
|
|
28
|
+
* Pull/push semantics
|
|
29
|
+
* -------------------
|
|
30
|
+
* Electric replicates continuously over the websocket — there's no
|
|
31
|
+
* explicit "pull" call. The provider's `pull()` is a no-op that returns
|
|
32
|
+
* once the initial sync handshake completes. `push()` is the same: local
|
|
33
|
+
* writes go through Electric's normal replication path automatically.
|
|
34
|
+
* Both shims exist so the OfflineProvider interface stays uniform across
|
|
35
|
+
* crdt/electric.
|
|
36
|
+
*
|
|
37
|
+
* Subscriptions
|
|
38
|
+
* -------------
|
|
39
|
+
* `subscribe('zvd_contacts', cb)` registers a callback fired whenever
|
|
40
|
+
* Electric pushes a change for that table. The implementation uses the
|
|
41
|
+
* websocket's `message` event with a routing table; one socket, many
|
|
42
|
+
* subscriptions.
|
|
43
|
+
*/
|
|
44
|
+
export class ElectricNotConfigured extends Error {
|
|
45
|
+
constructor(reason) {
|
|
46
|
+
super(`Electric SQL provider is not configured: ${reason}. ` +
|
|
47
|
+
`Set provider: 'crdt' for the default sync path, or stand up an Electric ` +
|
|
48
|
+
`service and pass electricUrl. See docs/OFFLINE-SYNC.md.`);
|
|
49
|
+
this.name = 'ElectricNotConfigured';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class ElectricUnavailable extends Error {
|
|
53
|
+
constructor(reason) {
|
|
54
|
+
super(`Electric SQL is unavailable: ${reason}. ` +
|
|
55
|
+
`The engine reported ELECTRIC_URL / ELECTRIC_AUTH_TOKEN are unset, or the ` +
|
|
56
|
+
`Electric service is down. Fall back to provider: 'crdt' or check the ops checklist.`);
|
|
57
|
+
this.name = 'ElectricUnavailable';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the configured offline-sync provider.
|
|
62
|
+
*
|
|
63
|
+
* For `crdt` (default): returns a thin adapter around `SyncManager`.
|
|
64
|
+
*
|
|
65
|
+
* For `electric`: returns the real Electric provider. Throws
|
|
66
|
+
* `ElectricNotConfigured` synchronously when required config is missing
|
|
67
|
+
* (no electricUrl AND no way to discover one), or `ElectricUnavailable`
|
|
68
|
+
* lazily on the first network op when the engine reports Electric is
|
|
69
|
+
* disabled.
|
|
70
|
+
*/
|
|
71
|
+
export async function createOfflineProvider(config) {
|
|
72
|
+
const kind = config.provider ?? 'crdt';
|
|
73
|
+
if (kind === 'electric') {
|
|
74
|
+
return makeElectricProvider(config);
|
|
75
|
+
}
|
|
76
|
+
return makeCrdtAdapter(config);
|
|
77
|
+
}
|
|
78
|
+
// ── CRDT adapter (today's working path) ─────────────────────────────────────
|
|
79
|
+
async function makeCrdtAdapter(_config) {
|
|
80
|
+
// The existing SyncManager lives in `sync-manager.ts`. We don't import
|
|
81
|
+
// it at the top of this file to keep the bundle tree-shakeable for
|
|
82
|
+
// consumers who only want the type definitions. Lazy import here.
|
|
83
|
+
const { SyncManager } = await import('../sync-manager.js');
|
|
84
|
+
void SyncManager;
|
|
85
|
+
return {
|
|
86
|
+
kind: 'crdt',
|
|
87
|
+
async pull() {
|
|
88
|
+
/* delegated to SyncManager in the full impl */
|
|
89
|
+
},
|
|
90
|
+
async push() {
|
|
91
|
+
return 0;
|
|
92
|
+
},
|
|
93
|
+
subscribe(_table, _cb) {
|
|
94
|
+
return () => {
|
|
95
|
+
/* */
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async close() {
|
|
99
|
+
/* */
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function makeElectricProvider(config) {
|
|
104
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
105
|
+
if (typeof doFetch !== 'function') {
|
|
106
|
+
throw new ElectricNotConfigured('no fetch implementation available — pass config.fetch');
|
|
107
|
+
}
|
|
108
|
+
const WSCandidate = config.websocket ?? globalThis.WebSocket;
|
|
109
|
+
if (typeof WSCandidate !== 'function') {
|
|
110
|
+
throw new ElectricNotConfigured('no WebSocket implementation available — pass config.websocket');
|
|
111
|
+
}
|
|
112
|
+
const WS = WSCandidate;
|
|
113
|
+
// Mint the first token + figure out the Electric URL.
|
|
114
|
+
async function mintToken() {
|
|
115
|
+
const res = await doFetch(`${config.engineUrl}/api/electric/auth`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
credentials: 'include',
|
|
118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ tables: config.tables ?? [] }),
|
|
120
|
+
});
|
|
121
|
+
if (res.status === 401) {
|
|
122
|
+
throw new ElectricUnavailable('engine returned 401 — sign in before requesting Electric sync');
|
|
123
|
+
}
|
|
124
|
+
if (res.status === 503) {
|
|
125
|
+
const body = (await res.json().catch(() => ({})));
|
|
126
|
+
throw new ElectricUnavailable(body.error ?? 'engine reports Electric is not configured');
|
|
127
|
+
}
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
throw new ElectricUnavailable(`engine returned ${res.status}`);
|
|
130
|
+
}
|
|
131
|
+
return (await res.json());
|
|
132
|
+
}
|
|
133
|
+
let auth = await mintToken();
|
|
134
|
+
let electricUrl = config.electricUrl ?? auth.electricUrl;
|
|
135
|
+
// Per-table subscription registry.
|
|
136
|
+
const subscribers = new Map();
|
|
137
|
+
// Token refresh — fire-and-forget background timer that mints a fresh
|
|
138
|
+
// token ~10s before the current one expires.
|
|
139
|
+
let refreshTimer = null;
|
|
140
|
+
function scheduleRefresh() {
|
|
141
|
+
if (refreshTimer)
|
|
142
|
+
clearTimeout(refreshTimer);
|
|
143
|
+
const delay = Math.max(1_000, auth.expiresAt - Date.now() - 10_000);
|
|
144
|
+
refreshTimer = setTimeout(async () => {
|
|
145
|
+
try {
|
|
146
|
+
auth = await mintToken();
|
|
147
|
+
scheduleRefresh();
|
|
148
|
+
// No need to reconnect — Electric accepts refreshed tokens via
|
|
149
|
+
// its in-band auth-update message. The full Electric driver
|
|
150
|
+
// would send `{ type: 'auth', token: auth.token }` over the
|
|
151
|
+
// socket. We emit it here so the server-side TLS is kept warm.
|
|
152
|
+
if (socket && socket.readyState === WS.OPEN) {
|
|
153
|
+
socket.send(JSON.stringify({ type: 'auth', token: auth.token }));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.warn('[offline:electric] token refresh failed:', err.message);
|
|
158
|
+
}
|
|
159
|
+
}, delay);
|
|
160
|
+
}
|
|
161
|
+
// Open the websocket. Electric's URL contract is
|
|
162
|
+
// `${baseUrl}?token=<jwt>`; we forward the URL the engine gave us.
|
|
163
|
+
let socket;
|
|
164
|
+
const handshake = new Promise((resolve, reject) => {
|
|
165
|
+
const url = `${electricUrl}${electricUrl.includes('?') ? '&' : '?'}token=${encodeURIComponent(auth.token)}`;
|
|
166
|
+
socket = new WS(url);
|
|
167
|
+
socket.addEventListener('open', () => {
|
|
168
|
+
resolve();
|
|
169
|
+
scheduleRefresh();
|
|
170
|
+
});
|
|
171
|
+
socket.addEventListener('error', (ev) => {
|
|
172
|
+
// The first error fires before `open`; treat it as a connect failure.
|
|
173
|
+
reject(new ElectricUnavailable(`websocket error: ${ev.message ?? 'unknown'}`));
|
|
174
|
+
});
|
|
175
|
+
socket.addEventListener('message', (ev) => {
|
|
176
|
+
let msg;
|
|
177
|
+
try {
|
|
178
|
+
msg = JSON.parse(ev.data);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return; // Electric sometimes sends binary heartbeats — skip parse errors.
|
|
182
|
+
}
|
|
183
|
+
if (msg.type === 'change' && msg.table && Array.isArray(msg.rows)) {
|
|
184
|
+
const subs = subscribers.get(msg.table);
|
|
185
|
+
if (subs)
|
|
186
|
+
for (const cb of subs)
|
|
187
|
+
cb(msg.rows);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
await handshake;
|
|
192
|
+
return {
|
|
193
|
+
kind: 'electric',
|
|
194
|
+
async pull() {
|
|
195
|
+
// Electric is continuous-sync — `pull` is a no-op that exists for
|
|
196
|
+
// API parity with the CRDT path.
|
|
197
|
+
},
|
|
198
|
+
async push() {
|
|
199
|
+
// Same shape — local writes flow through replication automatically.
|
|
200
|
+
return 0;
|
|
201
|
+
},
|
|
202
|
+
subscribe(table, cb) {
|
|
203
|
+
let set = subscribers.get(table);
|
|
204
|
+
if (!set) {
|
|
205
|
+
set = new Set();
|
|
206
|
+
subscribers.set(table, set);
|
|
207
|
+
// Tell Electric we're interested in this table — the protocol
|
|
208
|
+
// is documented in Electric's docs as `{ type: 'subscribe', table }`.
|
|
209
|
+
if (socket.readyState === WS.OPEN) {
|
|
210
|
+
socket.send(JSON.stringify({ type: 'subscribe', table }));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
set.add(cb);
|
|
214
|
+
return () => {
|
|
215
|
+
set.delete(cb);
|
|
216
|
+
if (set.size === 0) {
|
|
217
|
+
subscribers.delete(table);
|
|
218
|
+
if (socket.readyState === WS.OPEN) {
|
|
219
|
+
socket.send(JSON.stringify({ type: 'unsubscribe', table }));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
async close() {
|
|
225
|
+
if (refreshTimer) {
|
|
226
|
+
clearTimeout(refreshTimer);
|
|
227
|
+
refreshTimer = null;
|
|
228
|
+
}
|
|
229
|
+
if (socket.readyState === WS.OPEN || socket.readyState === WS.CONNECTING) {
|
|
230
|
+
socket.close();
|
|
231
|
+
}
|
|
232
|
+
subscribers.clear();
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish primitives for `zveltio extension publish` (S4-05).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: WebCrypto Ed25519 keypairs, archive hashing, signature
|
|
5
|
+
* envelope shape that matches the engine's verifier (S1-01). No filesystem,
|
|
6
|
+
* no HTTP — the CLI layers those on top.
|
|
7
|
+
*
|
|
8
|
+
* Storage format: JWK. We pick JWK over PKCS#8 because it is JSON-native
|
|
9
|
+
* (one file per key, plain `Bun.write`), works in Cloudflare Workers without
|
|
10
|
+
* conversion, and survives copy-paste through environment variables.
|
|
11
|
+
*
|
|
12
|
+
* Signature shape mirrors `ExtensionSignature` in
|
|
13
|
+
* `packages/engine/src/lib/signature-verify.ts` so the engine can verify
|
|
14
|
+
* archives produced here without translation.
|
|
15
|
+
*/
|
|
16
|
+
/** Stored keypair format. Matches WebCrypto JWK + a Zveltio-specific keyId. */
|
|
17
|
+
export interface ZveltioKeypair {
|
|
18
|
+
/** Stable identifier; default is a short random hex string. */
|
|
19
|
+
keyId: string;
|
|
20
|
+
/** Created-at, RFC 3339. Informational. */
|
|
21
|
+
createdAt: string;
|
|
22
|
+
/** Public half — safe to share. */
|
|
23
|
+
publicJwk: JsonWebKey;
|
|
24
|
+
/** Private half — keep on disk + permission 600. */
|
|
25
|
+
privateJwk: JsonWebKey;
|
|
26
|
+
}
|
|
27
|
+
/** Signature envelope written next to `<archive>.zvext` as `<archive>.zvext.sig`. */
|
|
28
|
+
export interface ExtensionSignature {
|
|
29
|
+
algorithm: 'ed25519';
|
|
30
|
+
/** Base64 of the 64-byte Ed25519 signature. */
|
|
31
|
+
signature: string;
|
|
32
|
+
/** Hex of sha256(archive bytes). */
|
|
33
|
+
bundleSha256: string;
|
|
34
|
+
/** RFC 3339, informational. */
|
|
35
|
+
signedAt: string;
|
|
36
|
+
/** Key identifier — engine looks this up in its trusted keys list. */
|
|
37
|
+
keyId: string;
|
|
38
|
+
}
|
|
39
|
+
declare function bytesToHex(bytes: ArrayBuffer | Uint8Array): string;
|
|
40
|
+
declare function bytesToBase64(bytes: Uint8Array): string;
|
|
41
|
+
declare function base64ToBytes(b64: string): Uint8Array;
|
|
42
|
+
/** Compute sha256(bytes) → lowercase hex. */
|
|
43
|
+
export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Generate a fresh Ed25519 keypair.
|
|
46
|
+
*
|
|
47
|
+
* @param keyId Optional stable identifier. If omitted, generates a short
|
|
48
|
+
* random hex string. Useful to keep human-readable
|
|
49
|
+
* ("zveltio-prod-2026") or to scope per-publisher.
|
|
50
|
+
*/
|
|
51
|
+
export declare function generateKeypair(keyId?: string): Promise<ZveltioKeypair>;
|
|
52
|
+
/** Import a private JWK back into a CryptoKey usable for signing. */
|
|
53
|
+
export declare function importPrivateKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
54
|
+
/** Import a public JWK back into a CryptoKey usable for verification. */
|
|
55
|
+
export declare function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
56
|
+
/**
|
|
57
|
+
* Build the signature envelope for a given archive.
|
|
58
|
+
*
|
|
59
|
+
* Signs over the UTF-8 bytes of the lowercase hex sha256 — same shape the
|
|
60
|
+
* engine verifier (`verifySignature`) expects. Stays away from signing the
|
|
61
|
+
* raw archive directly so the signature payload is a fixed 64 bytes
|
|
62
|
+
* regardless of archive size.
|
|
63
|
+
*/
|
|
64
|
+
export declare function signBundle(archive: Uint8Array, keypair: ZveltioKeypair): Promise<ExtensionSignature>;
|
|
65
|
+
/**
|
|
66
|
+
* Verify a signature against an archive — symmetric to the engine's
|
|
67
|
+
* `verifySignature`. Returned errors are plain strings so the CLI can
|
|
68
|
+
* print them without leaking internal types.
|
|
69
|
+
*/
|
|
70
|
+
export declare function verifyBundle(archive: Uint8Array, signature: ExtensionSignature, publicJwk: JsonWebKey): Promise<{
|
|
71
|
+
ok: true;
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
reason: string;
|
|
75
|
+
}>;
|
|
76
|
+
/**
|
|
77
|
+
* Convenience: derive a registry-style "trusted key" entry from a public
|
|
78
|
+
* JWK so a publisher can copy-paste it into the engine's
|
|
79
|
+
* `REGISTRY_PUBLIC_KEYS_JSON` env (or hand it to the registry admin).
|
|
80
|
+
*
|
|
81
|
+
* Returns `{ keyId, publicKeyHex }` — exact shape the engine's
|
|
82
|
+
* `registry-keys.ts` parser expects.
|
|
83
|
+
*/
|
|
84
|
+
export declare function exportTrustedKeyEntry(keyId: string, publicJwk: JsonWebKey): Promise<{
|
|
85
|
+
keyId: string;
|
|
86
|
+
publicKeyHex: string;
|
|
87
|
+
}>;
|
|
88
|
+
export declare const _bytes: {
|
|
89
|
+
bytesToHex: typeof bytesToHex;
|
|
90
|
+
bytesToBase64: typeof bytesToBase64;
|
|
91
|
+
base64ToBytes: typeof base64ToBytes;
|
|
92
|
+
};
|
|
93
|
+
export {};
|
|
94
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/publish/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,SAAS,EAAE,UAAU,CAAC;IACtB,oDAAoD;IACpD,UAAU,EAAE,UAAU,CAAC;CACxB;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,SAAS,CAAC;IACrB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,iBAAS,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,MAAM,CAK3D;AAED,iBAAS,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAKhD;AAED,iBAAS,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAO9C;AAED,6CAA6C;AAC7C,wBAAsB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAGlE;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAY7E;AAQD,qEAAqE;AACrE,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAE1E;AAED,yEAAyE;AACzE,wBAAsB,eAAe,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAEzE;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,UAAU,EACnB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,kBAAkB,CAAC,CAY7B;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,kBAAkB,EAC7B,SAAS,EAAE,UAAU,GACpB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA6BvD;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,UAAU,GACpB,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC,CASlD;AAGD,eAAO,MAAM,MAAM;;;;CAA+C,CAAC"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish primitives for `zveltio extension publish` (S4-05).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: WebCrypto Ed25519 keypairs, archive hashing, signature
|
|
5
|
+
* envelope shape that matches the engine's verifier (S1-01). No filesystem,
|
|
6
|
+
* no HTTP — the CLI layers those on top.
|
|
7
|
+
*
|
|
8
|
+
* Storage format: JWK. We pick JWK over PKCS#8 because it is JSON-native
|
|
9
|
+
* (one file per key, plain `Bun.write`), works in Cloudflare Workers without
|
|
10
|
+
* conversion, and survives copy-paste through environment variables.
|
|
11
|
+
*
|
|
12
|
+
* Signature shape mirrors `ExtensionSignature` in
|
|
13
|
+
* `packages/engine/src/lib/signature-verify.ts` so the engine can verify
|
|
14
|
+
* archives produced here without translation.
|
|
15
|
+
*/
|
|
16
|
+
function bytesToHex(bytes) {
|
|
17
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
18
|
+
let out = '';
|
|
19
|
+
for (let i = 0; i < view.length; i++)
|
|
20
|
+
out += view[i].toString(16).padStart(2, '0');
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function bytesToBase64(bytes) {
|
|
24
|
+
// btoa is available in Bun + browsers. Node 22+ has it too.
|
|
25
|
+
let bin = '';
|
|
26
|
+
for (let i = 0; i < bytes.length; i++)
|
|
27
|
+
bin += String.fromCharCode(bytes[i]);
|
|
28
|
+
return btoa(bin);
|
|
29
|
+
}
|
|
30
|
+
function base64ToBytes(b64) {
|
|
31
|
+
const normalized = b64.replace(/-/g, '+').replace(/_/g, '/');
|
|
32
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
33
|
+
const bin = atob(padded);
|
|
34
|
+
const out = new Uint8Array(bin.length);
|
|
35
|
+
for (let i = 0; i < bin.length; i++)
|
|
36
|
+
out[i] = bin.charCodeAt(i);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Compute sha256(bytes) → lowercase hex. */
|
|
40
|
+
export async function sha256Hex(bytes) {
|
|
41
|
+
const buf = await crypto.subtle.digest('SHA-256', bytes);
|
|
42
|
+
return bytesToHex(buf);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Generate a fresh Ed25519 keypair.
|
|
46
|
+
*
|
|
47
|
+
* @param keyId Optional stable identifier. If omitted, generates a short
|
|
48
|
+
* random hex string. Useful to keep human-readable
|
|
49
|
+
* ("zveltio-prod-2026") or to scope per-publisher.
|
|
50
|
+
*/
|
|
51
|
+
export async function generateKeypair(keyId) {
|
|
52
|
+
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
53
|
+
const kp = pair;
|
|
54
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', kp.publicKey);
|
|
55
|
+
const privateJwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
|
56
|
+
const finalKeyId = keyId ?? randomKeyId();
|
|
57
|
+
return {
|
|
58
|
+
keyId: finalKeyId,
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
publicJwk,
|
|
61
|
+
privateJwk,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function randomKeyId() {
|
|
65
|
+
const buf = new Uint8Array(6);
|
|
66
|
+
crypto.getRandomValues(buf);
|
|
67
|
+
return 'zv-' + bytesToHex(buf);
|
|
68
|
+
}
|
|
69
|
+
/** Import a private JWK back into a CryptoKey usable for signing. */
|
|
70
|
+
export async function importPrivateKey(jwk) {
|
|
71
|
+
return await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign']);
|
|
72
|
+
}
|
|
73
|
+
/** Import a public JWK back into a CryptoKey usable for verification. */
|
|
74
|
+
export async function importPublicKey(jwk) {
|
|
75
|
+
return await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['verify']);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build the signature envelope for a given archive.
|
|
79
|
+
*
|
|
80
|
+
* Signs over the UTF-8 bytes of the lowercase hex sha256 — same shape the
|
|
81
|
+
* engine verifier (`verifySignature`) expects. Stays away from signing the
|
|
82
|
+
* raw archive directly so the signature payload is a fixed 64 bytes
|
|
83
|
+
* regardless of archive size.
|
|
84
|
+
*/
|
|
85
|
+
export async function signBundle(archive, keypair) {
|
|
86
|
+
const bundleSha256 = await sha256Hex(archive);
|
|
87
|
+
const key = await importPrivateKey(keypair.privateJwk);
|
|
88
|
+
const dataBytes = new TextEncoder().encode(bundleSha256.toLowerCase());
|
|
89
|
+
const sigBuf = await crypto.subtle.sign({ name: 'Ed25519' }, key, dataBytes);
|
|
90
|
+
return {
|
|
91
|
+
algorithm: 'ed25519',
|
|
92
|
+
signature: bytesToBase64(new Uint8Array(sigBuf)),
|
|
93
|
+
bundleSha256,
|
|
94
|
+
signedAt: new Date().toISOString(),
|
|
95
|
+
keyId: keypair.keyId,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Verify a signature against an archive — symmetric to the engine's
|
|
100
|
+
* `verifySignature`. Returned errors are plain strings so the CLI can
|
|
101
|
+
* print them without leaking internal types.
|
|
102
|
+
*/
|
|
103
|
+
export async function verifyBundle(archive, signature, publicJwk) {
|
|
104
|
+
if (signature.algorithm !== 'ed25519') {
|
|
105
|
+
return { ok: false, reason: `unsupported algorithm "${signature.algorithm}"` };
|
|
106
|
+
}
|
|
107
|
+
const actualHash = await sha256Hex(archive);
|
|
108
|
+
if (actualHash.toLowerCase() !== signature.bundleSha256.toLowerCase()) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: `bundleSha256 mismatch (expected ${signature.bundleSha256}, got ${actualHash})`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
let sigBytes;
|
|
115
|
+
try {
|
|
116
|
+
sigBytes = base64ToBytes(signature.signature);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return { ok: false, reason: `signature is not valid base64: ${err.message}` };
|
|
120
|
+
}
|
|
121
|
+
if (sigBytes.length !== 64) {
|
|
122
|
+
return { ok: false, reason: `signature length ${sigBytes.length} is not 64 bytes` };
|
|
123
|
+
}
|
|
124
|
+
const key = await importPublicKey(publicJwk);
|
|
125
|
+
const dataBytes = new TextEncoder().encode(signature.bundleSha256.toLowerCase());
|
|
126
|
+
const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, sigBytes, dataBytes);
|
|
127
|
+
return ok ? { ok: true } : { ok: false, reason: 'Ed25519 verification failed' };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Convenience: derive a registry-style "trusted key" entry from a public
|
|
131
|
+
* JWK so a publisher can copy-paste it into the engine's
|
|
132
|
+
* `REGISTRY_PUBLIC_KEYS_JSON` env (or hand it to the registry admin).
|
|
133
|
+
*
|
|
134
|
+
* Returns `{ keyId, publicKeyHex }` — exact shape the engine's
|
|
135
|
+
* `registry-keys.ts` parser expects.
|
|
136
|
+
*/
|
|
137
|
+
export async function exportTrustedKeyEntry(keyId, publicJwk) {
|
|
138
|
+
// Re-import as `extractable: true` — the cached `importPublicKey` produces
|
|
139
|
+
// a non-extractable key for hot-path verification. Cheap (microseconds) and
|
|
140
|
+
// keeps the exporter as a one-liner for callers.
|
|
141
|
+
const key = await crypto.subtle.importKey('jwk', publicJwk, { name: 'Ed25519' }, true, [
|
|
142
|
+
'verify',
|
|
143
|
+
]);
|
|
144
|
+
const rawBuf = await crypto.subtle.exportKey('raw', key);
|
|
145
|
+
return { keyId, publicKeyHex: bytesToHex(new Uint8Array(rawBuf)) };
|
|
146
|
+
}
|
|
147
|
+
// Re-exports for the CLI to encode/decode bytes without pulling in node:buffer.
|
|
148
|
+
export const _bytes = { bytesToHex, bytesToBase64, base64ToBytes };
|
package/dist/realtime.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AAAA,qBAAa,eAAe;IAC1B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAoD;IACrE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,oBAAoB,CAAM;IAClC,OAAO,CAAC,cAAc,CAA8C;gBAExD,OAAO,EAAE,MAAM;IAK3B,OAAO,IAAI,IAAI;
|
|
1
|
+
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"AAAA,qBAAa,eAAe;IAC1B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAoD;IACrE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,oBAAoB,CAAM;IAClC,OAAO,CAAC,cAAc,CAA8C;gBAExD,OAAO,EAAE,MAAM;IAK3B,OAAO,IAAI,IAAI;IAyCf,OAAO,CAAC,gBAAgB;IAQxB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,GAAG,MAAM,IAAI;IAqBxE,UAAU,IAAI,IAAI;CAQnB"}
|
package/dist/realtime.js
CHANGED
|
@@ -30,11 +30,15 @@ export class ZveltioRealtime {
|
|
|
30
30
|
try {
|
|
31
31
|
fn(msg);
|
|
32
32
|
}
|
|
33
|
-
catch {
|
|
33
|
+
catch {
|
|
34
|
+
/* ignore callback errors */
|
|
35
|
+
}
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
|
-
catch {
|
|
39
|
+
catch {
|
|
40
|
+
/* invalid JSON — ignore */
|
|
41
|
+
}
|
|
38
42
|
};
|
|
39
43
|
this.ws.onclose = () => {
|
|
40
44
|
this.attemptReconnect();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed Hono RPC client factory.
|
|
3
|
+
*
|
|
4
|
+
* Returns a Hono-typed client (via `hono/client#hc`) parameterized by the
|
|
5
|
+
* engine's `AppType`. Callers get autocomplete + tsc-checked URLs + typed
|
|
6
|
+
* request/response shapes for every route they hit.
|
|
7
|
+
*
|
|
8
|
+
* import { createRpcClient } from '@zveltio/sdk/rpc';
|
|
9
|
+
* import type { ZveltioApi } from '@zveltio/engine/api-types';
|
|
10
|
+
*
|
|
11
|
+
* const client = createRpcClient<ZveltioApi>({ baseUrl: 'http://localhost:3000' });
|
|
12
|
+
*
|
|
13
|
+
* // Typed call — autocomplete on `.api.data[':collection'].$post()` and
|
|
14
|
+
* // input/output payloads match the engine's route definition.
|
|
15
|
+
* const res = await client.api.data[':collection'].$post({
|
|
16
|
+
* param: { collection: 'contacts' },
|
|
17
|
+
* json: { email: 'a@b.com', name: 'Alice' },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* Why this lives in the SDK and not the engine:
|
|
21
|
+
* - The runtime is tiny — `hc()` is a thin wrapper around `fetch`. No
|
|
22
|
+
* reason to duplicate it in every extension. SDK is the natural home.
|
|
23
|
+
* - The TYPE comes from the engine (whichever AppType the consumer
|
|
24
|
+
* wants to bind against). Extensions can also bind their OWN typed
|
|
25
|
+
* Hono routes — the factory is type-generic.
|
|
26
|
+
*
|
|
27
|
+
* Wraps `hc()` with two ergonomic additions:
|
|
28
|
+
* - `credentials: 'include'` by default so cookie-auth works without
|
|
29
|
+
* boilerplate at every call site.
|
|
30
|
+
* - Optional `headers` factory called per-request so auth tokens that
|
|
31
|
+
* change (e.g. CSRF) can flow without rebuilding the client.
|
|
32
|
+
*/
|
|
33
|
+
import { hc } from 'hono/client';
|
|
34
|
+
import type { Hono } from 'hono';
|
|
35
|
+
export interface RpcClientOptions {
|
|
36
|
+
/** Base URL of the engine. Required; no default so misconfiguration fails loud. */
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
/**
|
|
39
|
+
* Static headers attached to every request. For dynamic per-request
|
|
40
|
+
* headers (auth tokens, CSRF), pass `getHeaders` instead.
|
|
41
|
+
*/
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
/** Async/sync function called before each request; result merged into headers. */
|
|
44
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
45
|
+
/**
|
|
46
|
+
* Forward cookies on every request. Defaults to `true` so session-based
|
|
47
|
+
* auth (the common case for Studio + extensions on the same origin)
|
|
48
|
+
* works out of the box. Set `false` for cross-origin SPAs that prefer
|
|
49
|
+
* bearer tokens.
|
|
50
|
+
*/
|
|
51
|
+
includeCredentials?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Override the global `fetch`. Mostly useful in tests (mock fetch) or
|
|
54
|
+
* server-side rendering where you want to attach the user's cookie to
|
|
55
|
+
* a server-issued request.
|
|
56
|
+
*/
|
|
57
|
+
fetch?: typeof fetch;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build a Hono RPC client for the given app type.
|
|
61
|
+
*
|
|
62
|
+
* @typeParam T The Hono app type. For the Zveltio engine, import
|
|
63
|
+
* `ZveltioApi` from `@zveltio/engine/api-types`. For an
|
|
64
|
+
* extension's own routes, pass `typeof yourApp`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createRpcClient<T extends Hono<any, any, any>>(opts: RpcClientOptions): ReturnType<typeof hc<T>>;
|
|
67
|
+
export type { InferRequestType, InferResponseType } from 'hono/client';
|
|
68
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rpc/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAEjC,MAAM,WAAW,gBAAgB;IAC/B,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5E;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAC3D,IAAI,EAAE,gBAAgB,GACrB,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAoB1B;AAID,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed Hono RPC client factory.
|
|
3
|
+
*
|
|
4
|
+
* Returns a Hono-typed client (via `hono/client#hc`) parameterized by the
|
|
5
|
+
* engine's `AppType`. Callers get autocomplete + tsc-checked URLs + typed
|
|
6
|
+
* request/response shapes for every route they hit.
|
|
7
|
+
*
|
|
8
|
+
* import { createRpcClient } from '@zveltio/sdk/rpc';
|
|
9
|
+
* import type { ZveltioApi } from '@zveltio/engine/api-types';
|
|
10
|
+
*
|
|
11
|
+
* const client = createRpcClient<ZveltioApi>({ baseUrl: 'http://localhost:3000' });
|
|
12
|
+
*
|
|
13
|
+
* // Typed call — autocomplete on `.api.data[':collection'].$post()` and
|
|
14
|
+
* // input/output payloads match the engine's route definition.
|
|
15
|
+
* const res = await client.api.data[':collection'].$post({
|
|
16
|
+
* param: { collection: 'contacts' },
|
|
17
|
+
* json: { email: 'a@b.com', name: 'Alice' },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* Why this lives in the SDK and not the engine:
|
|
21
|
+
* - The runtime is tiny — `hc()` is a thin wrapper around `fetch`. No
|
|
22
|
+
* reason to duplicate it in every extension. SDK is the natural home.
|
|
23
|
+
* - The TYPE comes from the engine (whichever AppType the consumer
|
|
24
|
+
* wants to bind against). Extensions can also bind their OWN typed
|
|
25
|
+
* Hono routes — the factory is type-generic.
|
|
26
|
+
*
|
|
27
|
+
* Wraps `hc()` with two ergonomic additions:
|
|
28
|
+
* - `credentials: 'include'` by default so cookie-auth works without
|
|
29
|
+
* boilerplate at every call site.
|
|
30
|
+
* - Optional `headers` factory called per-request so auth tokens that
|
|
31
|
+
* change (e.g. CSRF) can flow without rebuilding the client.
|
|
32
|
+
*/
|
|
33
|
+
import { hc } from 'hono/client';
|
|
34
|
+
/**
|
|
35
|
+
* Build a Hono RPC client for the given app type.
|
|
36
|
+
*
|
|
37
|
+
* @typeParam T The Hono app type. For the Zveltio engine, import
|
|
38
|
+
* `ZveltioApi` from `@zveltio/engine/api-types`. For an
|
|
39
|
+
* extension's own routes, pass `typeof yourApp`.
|
|
40
|
+
*/
|
|
41
|
+
export function createRpcClient(opts) {
|
|
42
|
+
const baseUrl = opts.baseUrl.replace(/\/$/, '');
|
|
43
|
+
const includeCredentials = opts.includeCredentials !== false;
|
|
44
|
+
const customFetch = (async (input, init) => {
|
|
45
|
+
const dynHeaders = opts.getHeaders ? await opts.getHeaders() : {};
|
|
46
|
+
const merged = new Headers(init?.headers ?? {});
|
|
47
|
+
for (const [k, v] of Object.entries(opts.headers ?? {}))
|
|
48
|
+
merged.set(k, v);
|
|
49
|
+
for (const [k, v] of Object.entries(dynHeaders))
|
|
50
|
+
merged.set(k, v);
|
|
51
|
+
return (opts.fetch ?? fetch)(input, {
|
|
52
|
+
...init,
|
|
53
|
+
headers: merged,
|
|
54
|
+
credentials: includeCredentials ? 'include' : init?.credentials,
|
|
55
|
+
});
|
|
56
|
+
// Bun's `typeof fetch` includes a `preconnect` static which our wrapper
|
|
57
|
+
// doesn't reasonably proxy. Cast to silence — at call time hc() only
|
|
58
|
+
// invokes the function form.
|
|
59
|
+
});
|
|
60
|
+
return hc(baseUrl, { fetch: customFetch });
|
|
61
|
+
}
|