@tinyburg/treasury-sdk 0.0.0-stage → 0.2.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/src/Sdk.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The api the treasury serves: player-to-player trades, escrowed.
3
+ *
4
+ * Nimblebit has no hold primitive, so the treasury makes one out of the gift
5
+ * channel: each side's item is sent to a treasury-owned vault tower, where it
6
+ * sits as an *unreceived* gift - a native escrow slot - until both deposits
7
+ * are verified, and only then is each item forwarded to the other side. A
8
+ * save-splice trade instead rewrites both saves directly, which is why it
9
+ * requires an explicit confirmation from both parties before anything runs.
10
+ *
11
+ * Every endpoint but the public stats is bearer authenticated with a JWT
12
+ * access token the Tinyburg OAuth server issued for the Tinyburg api
13
+ * (`<site>/v1`), and guarded by a leaf of the `treasury` scope area in
14
+ * `Scopes`. The treasury lives behind tinyburg.app: the paths here are
15
+ * reached at `<site>/v1/treasury/*`, which tinyburg.app forwards verbatim,
16
+ * `Authorization` header included. The first-party app gets its token from
17
+ * tinyburg.app's `/auth/token`; any other application uses the one it was
18
+ * issued.
19
+ *
20
+ * A token without the endpoint's scope is refused with `403` and an
21
+ * `insufficient_scope` challenge. Any other `403` from a trade endpoint
22
+ * means the treasury holds no usable grant for the caller's towers;
23
+ * `GrantStatus` says so explicitly and carries the url that starts the
24
+ * connect flow.
25
+ *
26
+ * @since 1.0.0
27
+ */
28
+
29
+ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/http-api";
30
+ import * as Schema from "effect/Schema";
31
+
32
+ import * as NimblebitConfig from "@tinyburg/nimblebit-sdk/NimblebitConfig";
33
+ import * as NimblebitScopes from "@tinyburg/nimblebit-sdk/NimblebitScopes";
34
+ import * as SyncItemType from "@tinyburg/tinytower-sdk/SyncItemType";
35
+ import * as OAuthScope from "@yielded/auth/OAuthScope";
36
+
37
+ import * as Scopes from "./Scopes.ts";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Schemas
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * How a trade moves its items. `gift` rides Nimblebit's gift channel through
45
+ * the vault; `splice` rewrites both saves and can carry what the gift channel
46
+ * cannot (costumes, pets, coins, bux).
47
+ *
48
+ * @since 1.0.0
49
+ * @category Schemas
50
+ */
51
+ export const TradeMechanism = Schema.Literals(["gift", "splice"]);
52
+
53
+ /**
54
+ * The games the treasury trades in. TinyTower only, until the tinyburg api
55
+ * serves more.
56
+ *
57
+ * @since 1.0.0
58
+ * @category Schemas
59
+ */
60
+ export const TradeGame = Schema.Literals(["tinytower"]);
61
+
62
+ /**
63
+ * Where a trade is in its life. The happy path is `proposed` → `accepted` →
64
+ * (`awaiting_confirmation`, splice only) → `escrowing` → `escrowed` →
65
+ * `releasing` (gift) or `executing` (splice) → `settled`; everything else is
66
+ * one of the ways out.
67
+ *
68
+ * @since 1.0.0
69
+ * @category Schemas
70
+ */
71
+ export const TradeState = Schema.Literals([
72
+ "proposed",
73
+ "accepted",
74
+ "awaiting_confirmation",
75
+ "escrowing",
76
+ "escrowed",
77
+ "releasing",
78
+ "executing",
79
+ "settled",
80
+ "cancelled",
81
+ "refunding",
82
+ "refunded",
83
+ "expired",
84
+ "failed",
85
+ ]);
86
+
87
+ /**
88
+ * One side of a gift trade's journey through the vault: `pending` → `sent`
89
+ * (deposit dispatched) → `verified` (seen in the vault's gift queue) →
90
+ * `released` (forwarded to the counterparty) → `settled` (vault slot
91
+ * claimed); refunds run `refund_sent` → `refunded`, and `lost` is a deposit
92
+ * that never arrived.
93
+ *
94
+ * @since 1.0.0
95
+ * @category Schemas
96
+ */
97
+ export const LegState = Schema.Literals([
98
+ "pending",
99
+ "sent",
100
+ "verified",
101
+ "released",
102
+ "settled",
103
+ "refund_sent",
104
+ "refunded",
105
+ "lost",
106
+ ]);
107
+
108
+ /**
109
+ * @since 1.0.0
110
+ * @category Schemas
111
+ */
112
+ export const TradeRole = Schema.Literals(["proposer", "counterparty"]);
113
+
114
+ /**
115
+ * One item offered over the gift channel, in the encoded form the game
116
+ * exchanges items in - the same string `SendItem` carries.
117
+ *
118
+ * @since 1.0.0
119
+ * @category Schemas
120
+ */
121
+ export const GiftItem = Schema.Struct({
122
+ _tag: Schema.tag("GiftItem"),
123
+ itemType: Schema.Enum(SyncItemType.SyncItemType),
124
+ item: Schema.String,
125
+ });
126
+
127
+ /**
128
+ * One item offered by splice. Bitizens, costumes and pets carry the encoded
129
+ * string a save stores them as; coins and bux carry an amount instead.
130
+ *
131
+ * @since 1.0.0
132
+ * @category Schemas
133
+ */
134
+ export const SpliceItem = Schema.Struct({
135
+ kind: Schema.Literals(["bitizen", "costume", "pet", "coins", "bux"]),
136
+ item: Schema.optional(Schema.String),
137
+ amount: Schema.optional(Schema.Int),
138
+ });
139
+
140
+ /**
141
+ * @since 1.0.0
142
+ * @category Schemas
143
+ */
144
+ export const SpliceItems = Schema.Struct({
145
+ _tag: Schema.tag("SpliceItems"),
146
+ items: Schema.Array(SpliceItem),
147
+ });
148
+
149
+ /**
150
+ * What one side of a trade puts in: a gift-channel item or a list of splice
151
+ * items, matching the trade's mechanism.
152
+ *
153
+ * @since 1.0.0
154
+ * @category Schemas
155
+ */
156
+ export const ItemSpec = Schema.Union([GiftItem, SpliceItems]);
157
+
158
+ /**
159
+ * A trade as the api describes it. `gives` and `wants` are always from the
160
+ * proposer's point of view; `role` is which side the caller is on, absent for
161
+ * an open offer the caller could accept.
162
+ *
163
+ * @since 1.0.0
164
+ * @category Schemas
165
+ */
166
+ export const Trade = Schema.Struct({
167
+ id: Schema.String,
168
+ mechanism: TradeMechanism,
169
+ game: TradeGame,
170
+ state: TradeState,
171
+ proposerPlayerId: NimblebitConfig.PlayerIdSchema,
172
+ counterpartyPlayerId: Schema.optional(NimblebitConfig.PlayerIdSchema),
173
+ gives: ItemSpec,
174
+ wants: ItemSpec,
175
+ role: Schema.optional(TradeRole),
176
+ proposerConfirmedAt: Schema.optional(Schema.DateTimeUtcFromString),
177
+ counterpartyConfirmedAt: Schema.optional(Schema.DateTimeUtcFromString),
178
+ createdAt: Schema.DateTimeUtcFromString,
179
+ expiresAt: Schema.DateTimeUtcFromString,
180
+ failureReason: Schema.optional(Schema.String),
181
+ });
182
+
183
+ /**
184
+ * @since 1.0.0
185
+ * @category Schemas
186
+ */
187
+ export const TradeLeg = Schema.Struct({
188
+ role: TradeRole,
189
+ fromPlayerId: NimblebitConfig.PlayerIdSchema,
190
+ toPlayerId: NimblebitConfig.PlayerIdSchema,
191
+ state: LegState,
192
+ itemType: Schema.Enum(SyncItemType.SyncItemType),
193
+ item: Schema.String,
194
+ });
195
+
196
+ /**
197
+ * One entry of a trade's append-only history, as shown on its timeline.
198
+ *
199
+ * @since 1.0.0
200
+ * @category Schemas
201
+ */
202
+ export const LedgerEvent = Schema.Struct({
203
+ event: Schema.String,
204
+ at: Schema.DateTimeUtcFromString,
205
+ });
206
+
207
+ /**
208
+ * A trade with its legs and its history, for the participants only.
209
+ *
210
+ * @since 1.0.0
211
+ * @category Schemas
212
+ */
213
+ export const TradeDetail = Schema.Struct({
214
+ trade: Trade,
215
+ legs: Schema.Array(TradeLeg),
216
+ ledger: Schema.Array(LedgerEvent),
217
+ });
218
+
219
+ /**
220
+ * Whether the treasury can currently act on the caller's towers, and where
221
+ * to go to make it so. The url is built server-side so the browser stays
222
+ * configuration-free.
223
+ *
224
+ * @since 1.0.0
225
+ * @category Schemas
226
+ */
227
+ export const GrantStatus = Schema.Struct({
228
+ connected: Schema.Boolean,
229
+ scope: Schema.optional(Schema.String),
230
+ connectUrl: Schema.String,
231
+ });
232
+
233
+ /**
234
+ * What a tower could put into a trade, read out of its save. Bitizens are the
235
+ * encoded strings the gift channel carries; costumes and pets are the strings
236
+ * the save stores.
237
+ *
238
+ * @since 1.0.0
239
+ * @category Schemas
240
+ */
241
+ export const Inventory = Schema.Struct({
242
+ playerId: NimblebitConfig.PlayerIdSchema,
243
+ bitizens: Schema.Array(Schema.String),
244
+ costumes: Schema.Array(Schema.String),
245
+ pets: Schema.Array(Schema.String),
246
+ coins: Schema.Int,
247
+ bux: Schema.Int,
248
+ });
249
+
250
+ /**
251
+ * The treasury's public counters, for the home page's stat tiles. Counts
252
+ * only, cached server-side, and deliberately free of anything per-user.
253
+ *
254
+ * @since 1.0.0
255
+ * @category Schemas
256
+ */
257
+ export const TreasuryStats = Schema.Struct({
258
+ activeTraders: Schema.Int,
259
+ settledTrades: Schema.Int,
260
+ itemsMoved: Schema.Int,
261
+ });
262
+
263
+ /**
264
+ * @since 1.0.0
265
+ * @category Schemas
266
+ */
267
+ export const TradeProposal = Schema.Struct({
268
+ mechanism: TradeMechanism,
269
+ game: TradeGame,
270
+ proposerPlayerId: NimblebitConfig.PlayerIdSchema,
271
+ /** Absent for an open offer, which whoever accepts first gets. */
272
+ counterpartyPlayerId: Schema.optional(NimblebitConfig.PlayerIdSchema),
273
+ gives: ItemSpec,
274
+ wants: ItemSpec,
275
+ ttlHours: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 168 })),
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Endpoints
280
+ // ---------------------------------------------------------------------------
281
+
282
+ const tradeId = Schema.String;
283
+ const playerId = NimblebitConfig.PlayerIdSchema;
284
+
285
+ /**
286
+ * What acting on a trade can fail with: it does not exist or the caller is
287
+ * not a participant (`404`, and the api never confirms a trade exists to a
288
+ * stranger), it is not in a state the action applies to (`409`), the
289
+ * treasury holds no usable grant for the caller (`403`), or the world
290
+ * beyond - tinyburg.app, Nimblebit - did not cooperate (`503`).
291
+ */
292
+ const TradeErrors = [
293
+ HttpApiError.NotFound,
294
+ HttpApiError.Conflict,
295
+ HttpApiError.Forbidden,
296
+ HttpApiError.ServiceUnavailable,
297
+ ] as const;
298
+
299
+ const GetGrantStatus = HttpApiEndpoint.get("GrantStatus", "/v1/treasury/grant", {
300
+ success: GrantStatus,
301
+ }).annotate(OAuthScope.Required, Scopes.Treasury.read.grant_status.required);
302
+
303
+ const ListTrades = HttpApiEndpoint.get("ListTrades", "/v1/treasury/trades", {
304
+ success: Schema.Array(Trade),
305
+ }).annotate(OAuthScope.Required, Scopes.Treasury.read.list_trades.required);
306
+
307
+ const GetTrade = HttpApiEndpoint.get("GetTrade", "/v1/treasury/trades/:tradeId", {
308
+ params: { tradeId },
309
+ error: HttpApiError.NotFound,
310
+ success: TradeDetail,
311
+ }).annotate(OAuthScope.Required, Scopes.Treasury.read.get_trade.required);
312
+
313
+ const ProposeTrade = HttpApiEndpoint.post("ProposeTrade", "/v1/treasury/trades", {
314
+ payload: TradeProposal,
315
+ error: [HttpApiError.BadRequest, HttpApiError.Forbidden, HttpApiError.ServiceUnavailable],
316
+ success: Trade,
317
+ }).annotate(OAuthScope.Required, Scopes.Treasury.write.propose.required);
318
+
319
+ const AcceptTrade = HttpApiEndpoint.post("AcceptTrade", "/v1/treasury/trades/:tradeId/accept", {
320
+ params: { tradeId },
321
+ payload: Schema.Struct({ playerId }),
322
+ error: TradeErrors,
323
+ success: Trade,
324
+ }).annotate(OAuthScope.Required, Scopes.Treasury.write.accept.required);
325
+
326
+ const CancelTrade = HttpApiEndpoint.post("CancelTrade", "/v1/treasury/trades/:tradeId/cancel", {
327
+ params: { tradeId },
328
+ error: [HttpApiError.NotFound, HttpApiError.Conflict],
329
+ success: Trade,
330
+ }).annotate(OAuthScope.Required, Scopes.Treasury.write.cancel.required);
331
+
332
+ const ConfirmSplice = HttpApiEndpoint.post("ConfirmSplice", "/v1/treasury/trades/:tradeId/confirm", {
333
+ params: { tradeId },
334
+ error: [HttpApiError.NotFound, HttpApiError.Conflict],
335
+ success: Trade,
336
+ }).annotate(OAuthScope.Required, Scopes.Treasury.write.confirm_splice.required);
337
+
338
+ const ListInventory = HttpApiEndpoint.get("ListInventory", "/v1/treasury/inventory/:playerId", {
339
+ params: { playerId },
340
+ error: [HttpApiError.NotFound, HttpApiError.Forbidden, HttpApiError.ServiceUnavailable],
341
+ success: Inventory,
342
+ }).annotate(OAuthScope.Required, Scopes.Treasury.read.list_inventory.required);
343
+
344
+ const GetStats = HttpApiEndpoint.get("Stats", "/v1/treasury/stats", {
345
+ success: TreasuryStats,
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Groups
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /**
353
+ * Everything the treasury serves. The group annotation is the floor: an
354
+ * endpoint added later without its own leaf accepts only the whole
355
+ * `treasury` scope, so nothing defaults to a weaker permission.
356
+ *
357
+ * @since 1.0.0
358
+ * @category Groups
359
+ */
360
+ export const TreasuryGroup = HttpApiGroup.make("TreasuryGroup")
361
+ .add(GetGrantStatus)
362
+ .add(ListTrades)
363
+ .add(GetTrade)
364
+ .add(ProposeTrade)
365
+ .add(AcceptTrade)
366
+ .add(CancelTrade)
367
+ .add(ConfirmSplice)
368
+ .add(ListInventory)
369
+ .annotate(OAuthScope.Required, NimblebitScopes.requiredBy(Scopes.Treasury))
370
+ .middleware(OAuthScope.RequireAccess);
371
+
372
+ /**
373
+ * What the treasury tells anyone at all: the stat counters the home page
374
+ * shows. The one group without `RequireAccess` or a scope requirement, and
375
+ * it must stay that way - the home page is public, so this rides
376
+ * tinyburg.app's proxy without a token behind it.
377
+ *
378
+ * @since 1.0.0
379
+ * @category Groups
380
+ */
381
+ export const TreasuryPublicGroup = HttpApiGroup.make("TreasuryPublicGroup").add(GetStats);
382
+
383
+ /**
384
+ * @since 1.0.0
385
+ * @category Api
386
+ */
387
+ export const Api = HttpApi.make("TreasurySdk").add(TreasuryGroup).add(TreasuryPublicGroup);
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+
5
+ /**
6
+ * The scopes the treasury api is guarded by, as one `treasury` area.
7
+ *
8
+ * These guard the treasury's own api - proposing, accepting and watching
9
+ * trades - not the towers the trades move items between. Acting on a tower
10
+ * (depositing an item into escrow, splicing a save) happens through the
11
+ * tinyburg api under that game's own scopes, with a grant the player gives the
12
+ * treasury separately on the consent screen. Splitting the two means "let the
13
+ * treasury manage my trades" and "let the treasury touch my tower" are
14
+ * distinct questions with distinct answers.
15
+ *
16
+ * The area is built with the same `defineArea` every game's scopes are, so
17
+ * `Treasury.read.list_trades` is the leaf named `treasury:list_trades`, and
18
+ * `Treasury.read.list_trades.required` is what goes on the endpoint.
19
+ *
20
+ * @since 1.0.0
21
+ * @category Scopes
22
+ */
23
+ export * as Scopes from "./Scopes.ts"
24
+
25
+ /**
26
+ * The api the treasury serves: player-to-player trades, escrowed.
27
+ *
28
+ * Nimblebit has no hold primitive, so the treasury makes one out of the gift
29
+ * channel: each side's item is sent to a treasury-owned vault tower, where it
30
+ * sits as an *unreceived* gift - a native escrow slot - until both deposits
31
+ * are verified, and only then is each item forwarded to the other side. A
32
+ * save-splice trade instead rewrites both saves directly, which is why it
33
+ * requires an explicit confirmation from both parties before anything runs.
34
+ *
35
+ * Every endpoint but the public stats is bearer authenticated with a JWT
36
+ * access token the Tinyburg OAuth server issued for the Tinyburg api
37
+ * (`<site>/v1`), and guarded by a leaf of the `treasury` scope area in
38
+ * `Scopes`. The treasury lives behind tinyburg.app: the paths here are
39
+ * reached at `<site>/v1/treasury/*`, which tinyburg.app forwards verbatim,
40
+ * `Authorization` header included. The first-party app gets its token from
41
+ * tinyburg.app's `/auth/token`; any other application uses the one it was
42
+ * issued.
43
+ *
44
+ * A token without the endpoint's scope is refused with `403` and an
45
+ * `insufficient_scope` challenge. Any other `403` from a trade endpoint
46
+ * means the treasury holds no usable grant for the caller's towers;
47
+ * `GrantStatus` says so explicitly and carries the url that starts the
48
+ * connect flow.
49
+ *
50
+ * @since 1.0.0
51
+ */
52
+ export * as Sdk from "./Sdk.ts"