@slytrunk/sly-tifo-client 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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/index.cjs +200 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1126 -0
- package/dist/index.d.ts +1126 -0
- package/dist/index.js +169 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SlyTrunk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @slytrunk/sly-tifo-client
|
|
2
|
+
|
|
3
|
+
A typed client for the Sly Tifo loyalty API: standing, awards, and the
|
|
4
|
+
collectibles/collections/actions catalog.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @slytrunk/sly-tifo-client
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
The client never sees your secret key. It only holds a short-lived end-user
|
|
15
|
+
token, minted by your own server and handed to the client through `getToken`.
|
|
16
|
+
Your secret key stays on your server; it never reaches this package, the
|
|
17
|
+
browser, or anything you ship to a customer.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { API_BASES, createClient } from '@slytrunk/sly-tifo-client'
|
|
21
|
+
|
|
22
|
+
const client = createClient({
|
|
23
|
+
apiBase: API_BASES.production,
|
|
24
|
+
async getToken() {
|
|
25
|
+
// Call your own server, which holds the secret key and mints a
|
|
26
|
+
// short-lived token for this end user. This package never sees that key.
|
|
27
|
+
const res = await fetch('/api/sly-tifo-token')
|
|
28
|
+
const { token, expiresAt } = await res.json()
|
|
29
|
+
return { token, expiresAt }
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const standing = await client.me()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`apiBase` accepts a trailing slash. Use `API_BASES.stage` while testing against
|
|
37
|
+
your stage environment.
|
|
38
|
+
|
|
39
|
+
## The methods
|
|
40
|
+
|
|
41
|
+
- `client.me()` — the signed-in end user's standing (tier, progress,
|
|
42
|
+
collectibles).
|
|
43
|
+
- `client.award({ action, payload?, subject?, idempotencyKey? })` — record an
|
|
44
|
+
event and get back the tier and collectible changes it caused.
|
|
45
|
+
- `client.collectibles()` — the collectible catalog.
|
|
46
|
+
- `client.collections()` — the collection catalog.
|
|
47
|
+
- `client.actions()` — the action catalog.
|
|
48
|
+
|
|
49
|
+
## Refusals
|
|
50
|
+
|
|
51
|
+
Some perfectly ordinary outcomes — a daily cap already spent, a season that
|
|
52
|
+
hasn't opened yet, a ladder with no tier to award — come back as an HTTP 200,
|
|
53
|
+
not an error. `me()` and `award()` return either the real value or a typed
|
|
54
|
+
`Refused`, so a refusal can't be mistaken for success just because nothing
|
|
55
|
+
threw.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { isRefused } from '@slytrunk/sly-tifo-client'
|
|
59
|
+
|
|
60
|
+
const result = await client.award({ action: 'watch-game' })
|
|
61
|
+
|
|
62
|
+
if (isRefused(result)) {
|
|
63
|
+
console.log(`refused: ${result.code}`, result.detail)
|
|
64
|
+
} else {
|
|
65
|
+
console.log('awarded', result)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A `Refused` already carries `code` and `detail`, split out of `reason`, so
|
|
70
|
+
branch on `result.code`. `splitRefusal(reason)` does the same split on a raw
|
|
71
|
+
`reason` string — reach for it only if you got one some other way.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
Anything that isn't a refusal throws an `ApiError`, with `status` and `code`.
|
|
76
|
+
A `status` of `0` and `code: 'unreachable'` means the request never reached
|
|
77
|
+
the server at all (no connection, DNS failure, and so on) — there was no HTTP
|
|
78
|
+
response to read a status from.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { ApiError } from '@slytrunk/sly-tifo-client'
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await client.me()
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof ApiError) {
|
|
87
|
+
console.error(error.status, error.code, error.message)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Troubleshooting: a 401 that won't go away
|
|
93
|
+
|
|
94
|
+
The end-user token you pass in is minted with your secret key, and the key
|
|
95
|
+
decides which environment the token is good for — production or stage. If
|
|
96
|
+
`apiBase` points at the other environment, every request for that token
|
|
97
|
+
fails with 401. The client already retries once with a fresh token before
|
|
98
|
+
giving up, so a 401 that survives one retry is not a fluke: it means the key
|
|
99
|
+
that minted the token and the `apiBase` you're calling don't agree on
|
|
100
|
+
environment.
|
|
101
|
+
|
|
102
|
+
This is easy to miss because both environments issue keys that look the
|
|
103
|
+
same — `sk_live_…` is not a production-only prefix. If you're getting a
|
|
104
|
+
persistent `ApiError(401)`, check that the key your server used to mint the
|
|
105
|
+
token and the `apiBase` you passed to `createClient` are for the same
|
|
106
|
+
environment.
|
|
107
|
+
|
|
108
|
+
## React
|
|
109
|
+
|
|
110
|
+
Building a React app? See
|
|
111
|
+
[`@slytrunk/sly-tifo-react`](https://www.npmjs.com/package/@slytrunk/sly-tifo-react)
|
|
112
|
+
for `<SlyProvider>` and a set of hooks built on this client.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
API_BASES: () => API_BASES,
|
|
24
|
+
ApiError: () => ApiError,
|
|
25
|
+
createClient: () => createClient,
|
|
26
|
+
isRefused: () => isRefused,
|
|
27
|
+
splitRefusal: () => splitRefusal
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/refusal.ts
|
|
32
|
+
function splitRefusal(reason) {
|
|
33
|
+
const at = reason.indexOf(":");
|
|
34
|
+
if (at === -1) return { code: reason, detail: "" };
|
|
35
|
+
return { code: reason.slice(0, at).trim(), detail: reason.slice(at + 1).trim() };
|
|
36
|
+
}
|
|
37
|
+
function isRefused(body) {
|
|
38
|
+
const shape = body;
|
|
39
|
+
return shape?.accepted === false && typeof shape.reason === "string";
|
|
40
|
+
}
|
|
41
|
+
function asRefused(body) {
|
|
42
|
+
const { code, detail } = splitRefusal(body.reason);
|
|
43
|
+
return { accepted: false, reason: body.reason, code, detail };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/tokens.ts
|
|
47
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
48
|
+
function createTokenHolder(opts) {
|
|
49
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
50
|
+
let held = null;
|
|
51
|
+
let inFlight = null;
|
|
52
|
+
const alive = (token) => {
|
|
53
|
+
const expires = Date.parse(token.expiresAt);
|
|
54
|
+
if (Number.isNaN(expires)) return false;
|
|
55
|
+
return expires - now().getTime() > REFRESH_MARGIN_MS;
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
async current() {
|
|
59
|
+
if (held !== null && alive(held)) return held.token;
|
|
60
|
+
if (inFlight === null) {
|
|
61
|
+
inFlight = opts.getToken().finally(() => {
|
|
62
|
+
inFlight = null;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
held = await inFlight;
|
|
66
|
+
return held.token;
|
|
67
|
+
},
|
|
68
|
+
invalidate() {
|
|
69
|
+
held = null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/transport.ts
|
|
75
|
+
var ApiError = class extends Error {
|
|
76
|
+
status;
|
|
77
|
+
code;
|
|
78
|
+
// `options` is the standard `ErrorOptions`, optional so every existing
|
|
79
|
+
// three-argument call keeps compiling. Its `cause` is the error that made
|
|
80
|
+
// this one — for `unreachable`, the fetch failure the message rewrites.
|
|
81
|
+
constructor(message, status, code, options) {
|
|
82
|
+
super(message, options);
|
|
83
|
+
this.name = "ApiError";
|
|
84
|
+
this.status = status;
|
|
85
|
+
this.code = code;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
async function send(opts) {
|
|
89
|
+
let response;
|
|
90
|
+
try {
|
|
91
|
+
response = await opts.fetch(`${opts.apiBase}${opts.path}`, {
|
|
92
|
+
method: opts.method,
|
|
93
|
+
headers: {
|
|
94
|
+
authorization: `Bearer ${opts.token}`,
|
|
95
|
+
...opts.body === void 0 ? {} : { "content-type": "application/json" },
|
|
96
|
+
...opts.headers ?? {}
|
|
97
|
+
},
|
|
98
|
+
...opts.body === void 0 ? {} : { body: JSON.stringify(opts.body) }
|
|
99
|
+
});
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
throw new ApiError(
|
|
102
|
+
`The server at ${opts.apiBase} could not be reached. Check your connection and try again.`,
|
|
103
|
+
0,
|
|
104
|
+
"unreachable",
|
|
105
|
+
{ cause }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const text = await response.text();
|
|
109
|
+
let parsed = void 0;
|
|
110
|
+
try {
|
|
111
|
+
parsed = text === "" ? void 0 : JSON.parse(text);
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const shape = parsed;
|
|
116
|
+
const message = typeof shape?.message === "string" ? shape.message : `The request failed (${response.status}).`;
|
|
117
|
+
const code = typeof shape?.error === "string" ? shape.error : "http-error";
|
|
118
|
+
throw new ApiError(message, response.status, code);
|
|
119
|
+
}
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/client.ts
|
|
124
|
+
function createClient(opts) {
|
|
125
|
+
const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
126
|
+
const apiBase = opts.apiBase.replace(/\/+$/, "");
|
|
127
|
+
const tokens = createTokenHolder({
|
|
128
|
+
getToken: opts.getToken,
|
|
129
|
+
...opts.now === void 0 ? {} : { now: opts.now }
|
|
130
|
+
});
|
|
131
|
+
const call = async (method, path, body, headers) => {
|
|
132
|
+
const attempt = async () => send({
|
|
133
|
+
fetch: doFetch,
|
|
134
|
+
apiBase,
|
|
135
|
+
method,
|
|
136
|
+
path,
|
|
137
|
+
token: await tokens.current(),
|
|
138
|
+
...body === void 0 ? {} : { body },
|
|
139
|
+
...headers === void 0 ? {} : { headers }
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
return await attempt();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
145
|
+
tokens.invalidate();
|
|
146
|
+
return attempt();
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
async me() {
|
|
153
|
+
const body = await call("GET", "/v1/me");
|
|
154
|
+
return isRefused(body) ? asRefused(body) : body;
|
|
155
|
+
},
|
|
156
|
+
async award(input) {
|
|
157
|
+
const body = await call(
|
|
158
|
+
"POST",
|
|
159
|
+
"/v1/events",
|
|
160
|
+
{
|
|
161
|
+
action: input.action,
|
|
162
|
+
...input.payload === void 0 ? {} : { payload: input.payload },
|
|
163
|
+
...input.subject === void 0 ? {} : { subject: input.subject }
|
|
164
|
+
},
|
|
165
|
+
input.idempotencyKey === void 0 ? void 0 : { "idempotency-key": input.idempotencyKey }
|
|
166
|
+
);
|
|
167
|
+
return isRefused(body) ? asRefused(body) : body;
|
|
168
|
+
},
|
|
169
|
+
// All three unwrap an envelope. The API answers `{ collectibles: [...] }`,
|
|
170
|
+
// not a bare array; the SDK returns the array, because an envelope is a
|
|
171
|
+
// transport detail and every caller would immediately index past it.
|
|
172
|
+
async collectibles() {
|
|
173
|
+
const body = await call("GET", "/v1/collectibles");
|
|
174
|
+
return body.collectibles;
|
|
175
|
+
},
|
|
176
|
+
async collections() {
|
|
177
|
+
const body = await call("GET", "/v1/collections");
|
|
178
|
+
return body.collections;
|
|
179
|
+
},
|
|
180
|
+
async actions() {
|
|
181
|
+
const body = await call("GET", "/v1/actions");
|
|
182
|
+
return body.actions;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/environments.ts
|
|
188
|
+
var API_BASES = {
|
|
189
|
+
production: "https://api.slytifo.com",
|
|
190
|
+
stage: "https://api.stage-slytifo.com"
|
|
191
|
+
};
|
|
192
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
193
|
+
0 && (module.exports = {
|
|
194
|
+
API_BASES,
|
|
195
|
+
ApiError,
|
|
196
|
+
createClient,
|
|
197
|
+
isRefused,
|
|
198
|
+
splitRefusal
|
|
199
|
+
});
|
|
200
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/refusal.ts","../src/tokens.ts","../src/transport.ts","../src/client.ts","../src/environments.ts"],"sourcesContent":["export { createClient } from './client'\nexport { API_BASES } from './environments'\nexport type {\n AwardAccepted,\n AwardInput,\n CatalogAction,\n CatalogCollectible,\n ClientOptions,\n Collection,\n SlyClient,\n Standing,\n} from './client'\nexport { isRefused, splitRefusal, type Refused } from './refusal'\nexport type { GetToken, MintedToken } from './tokens'\nexport { ApiError } from './transport'\n","/**\n * A refusal that arrived as HTTP 200.\n *\n * `{ accepted: false, reason: \"cap-exhausted: …\" }` is a 200 because a daily\n * cap, a closed season or a half-built ladder is ordinary program state rather\n * than a fault in the request. A generated client reports SUCCESS for this\n * unless the caller reads `accepted` — which is the single most common way to\n * misuse this API, and the reason this package makes it a typed value rather\n * than a documented footnote.\n *\n * The reason is split so a caller can branch on a stable code without string\n * matching at every call site. `apps/api` writes it as `code: detail`; an\n * unprefixed reason is all code and no detail.\n */\nexport type Refused = {\n accepted: false\n reason: string\n code: string\n detail: string\n}\n\nexport function splitRefusal(reason: string): { code: string; detail: string } {\n const at = reason.indexOf(':')\n if (at === -1) return { code: reason, detail: '' }\n return { code: reason.slice(0, at).trim(), detail: reason.slice(at + 1).trim() }\n}\n\n/** Narrow a 200 body that may be either an answer or a refusal. */\nexport function isRefused(body: unknown): body is { accepted: false; reason: string } {\n const shape = body as { accepted?: unknown; reason?: unknown } | null\n return shape?.accepted === false && typeof shape.reason === 'string'\n}\n\nexport function asRefused(body: { accepted: false; reason: string }): Refused {\n const { code, detail } = splitRefusal(body.reason)\n return { accepted: false, reason: body.reason, code, detail }\n}\n","/**\n * The end-user token's fifteen minutes, handled here so no caller has to.\n *\n * THE IN-FLIGHT PROMISE IS NOT AN OPTIMIZATION. A page that mounts three\n * widgets calls this three times in the same tick; without the shared promise\n * that is three mints, and under GoTrue-style single-live-credential semantics\n * the later ones can invalidate the earlier. Sharing the promise makes a burst\n * of callers one mint.\n *\n * The sixty-second margin is refresh-AHEAD, not refresh-on-failure. A token\n * that passes the check here can still be rejected — clock skew, a token\n * revoked server-side — which is why `client.ts` ALSO retries once on a 401.\n * The two are belt and braces and neither replaces the other.\n */\nconst REFRESH_MARGIN_MS = 60_000\n\nexport type MintedToken = { token: string; expiresAt: string }\nexport type GetToken = () => Promise<MintedToken>\n\nexport type TokenHolder = {\n current(): Promise<string>\n invalidate(): void\n}\n\nexport function createTokenHolder(opts: { getToken: GetToken; now?: () => Date }): TokenHolder {\n const now = opts.now ?? (() => new Date())\n let held: MintedToken | null = null\n let inFlight: Promise<MintedToken> | null = null\n\n const alive = (token: MintedToken): boolean => {\n const expires = Date.parse(token.expiresAt)\n // An unparseable expiry is treated as already dead rather than as\n // permanently alive: failing toward an extra mint is recoverable, failing\n // toward a token that is never refreshed is a page that breaks after\n // fifteen minutes with no error.\n if (Number.isNaN(expires)) return false\n return expires - now().getTime() > REFRESH_MARGIN_MS\n }\n\n return {\n async current(): Promise<string> {\n if (held !== null && alive(held)) return held.token\n if (inFlight === null) {\n inFlight = opts.getToken().finally(() => {\n inFlight = null\n })\n }\n held = await inFlight\n return held.token\n },\n invalidate(): void {\n held = null\n },\n }\n}\n","/**\n * One request, one place.\n *\n * The rules here are lifted from the admin app's API client, which\n * solved the same problem for the admin's session credential: read the body\n * BEFORE branching on `ok`, because this API writes its message on the failure\n * paths; throw the server's sentence rather than a status, because the sentence\n * is written for a person to act on; and replace `TypeError: fetch failed` with\n * something that names the service, because a JavaScript builtin's name is not\n * a next step.\n *\n * What is NOT here, deliberately: retries. The token-aware layer in\n * `client.ts` retries exactly once on a 401 after refreshing, and that is the\n * only retry this package performs.\n */\nexport type Fetch = typeof globalThis.fetch\n\nexport class ApiError extends Error {\n readonly status: number\n readonly code: string\n\n // `options` is the standard `ErrorOptions`, optional so every existing\n // three-argument call keeps compiling. Its `cause` is the error that made\n // this one — for `unreachable`, the fetch failure the message rewrites.\n constructor(message: string, status: number, code: string, options?: ErrorOptions) {\n super(message, options)\n this.name = 'ApiError'\n this.status = status\n this.code = code\n }\n}\n\nexport async function send(opts: {\n fetch: Fetch\n apiBase: string\n method: 'GET' | 'POST'\n path: string\n token: string\n body?: unknown\n headers?: Record<string, string>\n}): Promise<unknown> {\n let response: Response\n try {\n response = await opts.fetch(`${opts.apiBase}${opts.path}`, {\n method: opts.method,\n headers: {\n authorization: `Bearer ${opts.token}`,\n ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }),\n ...(opts.headers ?? {}),\n },\n ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),\n })\n } catch (cause) {\n // The message names the service; `cause` keeps the builtin's error, which\n // is the only record of WHY. \"Illegal invocation\" is an unbound fetch in\n // workerd, not a network outage, and they read identically without it.\n throw new ApiError(\n `The server at ${opts.apiBase} could not be reached. Check your connection and try again.`,\n 0,\n 'unreachable',\n { cause },\n )\n }\n\n const text = await response.text()\n let parsed: unknown = undefined\n try {\n parsed = text === '' ? undefined : JSON.parse(text)\n } catch {\n // Not JSON — a proxy's HTML error page, or the service being down. Fall\n // through to the status-shaped message rather than throwing a SyntaxError,\n // which would name a parser and not the problem.\n }\n\n if (!response.ok) {\n const shape = parsed as { message?: unknown; error?: unknown } | undefined\n const message =\n typeof shape?.message === 'string'\n ? shape.message\n : `The request failed (${response.status}).`\n const code = typeof shape?.error === 'string' ? shape.error : 'http-error'\n throw new ApiError(message, response.status, code)\n }\n return parsed\n}\n","import type { operations } from './generated/openapi'\nimport { asRefused, isRefused, type Refused } from './refusal'\nimport { createTokenHolder, type GetToken } from './tokens'\nimport { ApiError, send, type Fetch } from './transport'\n\n/** The 200 JSON body of one operation. */\ntype Json200<Op> = Op extends {\n responses: { 200: { content: { 'application/json': infer T } } }\n}\n ? T\n : never\n\n// `getMyStanding`'s 200 is a union: the standing arm has no `accepted` key at\n// all, and the refusal arm is `{ accepted: false; reason: string }`. Exclude\n// rather than Extract, because the standing arm has no discriminant of its own.\nexport type Standing = Exclude<Json200<operations['getMyStanding']>, { accepted: false }>\n\n// `awardEvent`'s arms keep `accepted: true` / `accepted: false` as LITERAL\n// types, so this one discriminates directly.\nexport type AwardAccepted = Extract<Json200<operations['awardEvent']>, { accepted: true }>\n\n// All three catalog routes are ENVELOPED. The client unwraps them so the SDK\n// surface is a flat array.\nexport type CatalogCollectible = Json200<operations['listCollectibles']>['collectibles'][number]\nexport type Collection = Json200<operations['listCollections']>['collections'][number]\nexport type CatalogAction = Json200<operations['listActions']>['actions'][number]\n\nexport type AwardInput = {\n action: string\n payload?: unknown\n subject?: string\n /** Sent as the `Idempotency-Key` HEADER. The API has never read a body field of that name. */\n idempotencyKey?: string\n}\n\nexport type ClientOptions = {\n apiBase: string\n /**\n * Where a token comes from. The client NEVER sees a secret key — this is the\n * property that makes the package publishable, and a caller wiring a secret\n * key in here has defeated the whole design.\n */\n getToken: GetToken\n fetch?: Fetch\n now?: () => Date\n}\n\nexport type SlyClient = {\n me(): Promise<Standing | Refused>\n award(input: AwardInput): Promise<AwardAccepted | Refused>\n collectibles(): Promise<CatalogCollectible[]>\n collections(): Promise<Collection[]>\n actions(): Promise<CatalogAction[]>\n}\n\nexport function createClient(opts: ClientOptions): SlyClient {\n // `fetch.bind(globalThis)`, not the bare reference: workerd's global `fetch`\n // throws \"Illegal invocation\" the moment it is called as an object property\n // rather than on the global scope. The testbed Worker found this the hard\n // way, and this package is destined for exactly that runtime.\n const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis)\n // A pasted base often ends in `/`, and `${apiBase}${path}` would then ask for\n // `//v1/me` — a 404 that reads like a missing route.\n const apiBase = opts.apiBase.replace(/\\/+$/, '')\n const tokens = createTokenHolder({\n getToken: opts.getToken,\n ...(opts.now === undefined ? {} : { now: opts.now }),\n })\n\n /**\n * ONE retry, and only on a 401.\n *\n * A 401 that survives a fresh token is a real refusal — a revoked key, a\n * token for an end user that no longer exists — and retrying it forever is\n * how a demo becomes a hot spin in front of a customer. Every other status\n * is returned as it came: no new token fixes a 422.\n */\n const call = async (\n method: 'GET' | 'POST',\n path: string,\n body?: unknown,\n headers?: Record<string, string>,\n ): Promise<unknown> => {\n const attempt = async (): Promise<unknown> =>\n send({\n fetch: doFetch,\n apiBase,\n method,\n path,\n token: await tokens.current(),\n ...(body === undefined ? {} : { body }),\n ...(headers === undefined ? {} : { headers }),\n })\n\n try {\n return await attempt()\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) {\n tokens.invalidate()\n return attempt()\n }\n throw error\n }\n }\n\n return {\n async me(): Promise<Standing | Refused> {\n const body = await call('GET', '/v1/me')\n return isRefused(body) ? asRefused(body) : (body as Standing)\n },\n\n async award(input: AwardInput): Promise<AwardAccepted | Refused> {\n const body = await call(\n 'POST',\n '/v1/events',\n {\n action: input.action,\n ...(input.payload === undefined ? {} : { payload: input.payload }),\n ...(input.subject === undefined ? {} : { subject: input.subject }),\n },\n input.idempotencyKey === undefined\n ? undefined\n : { 'idempotency-key': input.idempotencyKey },\n )\n return isRefused(body) ? asRefused(body) : (body as AwardAccepted)\n },\n\n // All three unwrap an envelope. The API answers `{ collectibles: [...] }`,\n // not a bare array; the SDK returns the array, because an envelope is a\n // transport detail and every caller would immediately index past it.\n async collectibles(): Promise<CatalogCollectible[]> {\n const body = (await call('GET', '/v1/collectibles')) as { collectibles: CatalogCollectible[] }\n return body.collectibles\n },\n\n async collections(): Promise<Collection[]> {\n const body = (await call('GET', '/v1/collections')) as { collections: Collection[] }\n return body.collections\n },\n\n async actions(): Promise<CatalogAction[]> {\n const body = (await call('GET', '/v1/actions')) as { actions: CatalogAction[] }\n return body.actions\n },\n }\n}\n","/**\n * The two hosted API origins, so nobody hand-types a hostname.\n *\n * Deliberately NOT an `environment: 'stage'` option on `createClient`: a\n * customer behind their own proxy needs the raw base anyway, and an option\n * would hide which host is being called. And the environment is decided by\n * the KEY, not by this: a token minted with a production key and sent to stage\n * answers 401, retried once, then thrown as `ApiError(401)`.\n */\nexport const API_BASES = {\n production: 'https://api.slytifo.com',\n stage: 'https://api.stage-slytifo.com',\n} as const\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACqBO,SAAS,aAAa,QAAkD;AAC7E,QAAM,KAAK,OAAO,QAAQ,GAAG;AAC7B,MAAI,OAAO,GAAI,QAAO,EAAE,MAAM,QAAQ,QAAQ,GAAG;AACjD,SAAO,EAAE,MAAM,OAAO,MAAM,GAAG,EAAE,EAAE,KAAK,GAAG,QAAQ,OAAO,MAAM,KAAK,CAAC,EAAE,KAAK,EAAE;AACjF;AAGO,SAAS,UAAU,MAA4D;AACpF,QAAM,QAAQ;AACd,SAAO,OAAO,aAAa,SAAS,OAAO,MAAM,WAAW;AAC9D;AAEO,SAAS,UAAU,MAAoD;AAC5E,QAAM,EAAE,MAAM,OAAO,IAAI,aAAa,KAAK,MAAM;AACjD,SAAO,EAAE,UAAU,OAAO,QAAQ,KAAK,QAAQ,MAAM,OAAO;AAC9D;;;ACtBA,IAAM,oBAAoB;AAUnB,SAAS,kBAAkB,MAA6D;AAC7F,QAAM,MAAM,KAAK,QAAQ,MAAM,oBAAI,KAAK;AACxC,MAAI,OAA2B;AAC/B,MAAI,WAAwC;AAE5C,QAAM,QAAQ,CAAC,UAAgC;AAC7C,UAAM,UAAU,KAAK,MAAM,MAAM,SAAS;AAK1C,QAAI,OAAO,MAAM,OAAO,EAAG,QAAO;AAClC,WAAO,UAAU,IAAI,EAAE,QAAQ,IAAI;AAAA,EACrC;AAEA,SAAO;AAAA,IACL,MAAM,UAA2B;AAC/B,UAAI,SAAS,QAAQ,MAAM,IAAI,EAAG,QAAO,KAAK;AAC9C,UAAI,aAAa,MAAM;AACrB,mBAAW,KAAK,SAAS,EAAE,QAAQ,MAAM;AACvC,qBAAW;AAAA,QACb,CAAC;AAAA,MACH;AACA,aAAO,MAAM;AACb,aAAO,KAAK;AAAA,IACd;AAAA,IACA,aAAmB;AACjB,aAAO;AAAA,IACT;AAAA,EACF;AACF;;;ACrCO,IAAM,WAAN,cAAuB,MAAM;AAAA,EACzB;AAAA,EACA;AAAA;AAAA;AAAA;AAAA,EAKT,YAAY,SAAiB,QAAgB,MAAc,SAAwB;AACjF,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAEA,eAAsB,KAAK,MAQN;AACnB,MAAI;AACJ,MAAI;AACF,eAAW,MAAM,KAAK,MAAM,GAAG,KAAK,OAAO,GAAG,KAAK,IAAI,IAAI;AAAA,MACzD,QAAQ,KAAK;AAAA,MACb,SAAS;AAAA,QACP,eAAe,UAAU,KAAK,KAAK;AAAA,QACnC,GAAI,KAAK,SAAS,SAAY,CAAC,IAAI,EAAE,gBAAgB,mBAAmB;AAAA,QACxE,GAAI,KAAK,WAAW,CAAC;AAAA,MACvB;AAAA,MACA,GAAI,KAAK,SAAS,SAAY,CAAC,IAAI,EAAE,MAAM,KAAK,UAAU,KAAK,IAAI,EAAE;AAAA,IACvE,CAAC;AAAA,EACH,SAAS,OAAO;AAId,UAAM,IAAI;AAAA,MACR,iBAAiB,KAAK,OAAO;AAAA,MAC7B;AAAA,MACA;AAAA,MACA,EAAE,MAAM;AAAA,IACV;AAAA,EACF;AAEA,QAAM,OAAO,MAAM,SAAS,KAAK;AACjC,MAAI,SAAkB;AACtB,MAAI;AACF,aAAS,SAAS,KAAK,SAAY,KAAK,MAAM,IAAI;AAAA,EACpD,QAAQ;AAAA,EAIR;AAEA,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,QAAQ;AACd,UAAM,UACJ,OAAO,OAAO,YAAY,WACtB,MAAM,UACN,uBAAuB,SAAS,MAAM;AAC5C,UAAM,OAAO,OAAO,OAAO,UAAU,WAAW,MAAM,QAAQ;AAC9D,UAAM,IAAI,SAAS,SAAS,SAAS,QAAQ,IAAI;AAAA,EACnD;AACA,SAAO;AACT;;;AC7BO,SAAS,aAAa,MAAgC;AAK3D,QAAM,UAAU,KAAK,SAAS,WAAW,MAAM,KAAK,UAAU;AAG9D,QAAM,UAAU,KAAK,QAAQ,QAAQ,QAAQ,EAAE;AAC/C,QAAM,SAAS,kBAAkB;AAAA,IAC/B,UAAU,KAAK;AAAA,IACf,GAAI,KAAK,QAAQ,SAAY,CAAC,IAAI,EAAE,KAAK,KAAK,IAAI;AAAA,EACpD,CAAC;AAUD,QAAM,OAAO,OACX,QACA,MACA,MACA,YACqB;AACrB,UAAM,UAAU,YACd,KAAK;AAAA,MACH,OAAO;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,MAAM,OAAO,QAAQ;AAAA,MAC5B,GAAI,SAAS,SAAY,CAAC,IAAI,EAAE,KAAK;AAAA,MACrC,GAAI,YAAY,SAAY,CAAC,IAAI,EAAE,QAAQ;AAAA,IAC7C,CAAC;AAEH,QAAI;AACF,aAAO,MAAM,QAAQ;AAAA,IACvB,SAAS,OAAO;AACd,UAAI,iBAAiB,YAAY,MAAM,WAAW,KAAK;AACrD,eAAO,WAAW;AAClB,eAAO,QAAQ;AAAA,MACjB;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAEA,SAAO;AAAA,IACL,MAAM,KAAkC;AACtC,YAAM,OAAO,MAAM,KAAK,OAAO,QAAQ;AACvC,aAAO,UAAU,IAAI,IAAI,UAAU,IAAI,IAAK;AAAA,IAC9C;AAAA,IAEA,MAAM,MAAM,OAAqD;AAC/D,YAAM,OAAO,MAAM;AAAA,QACjB;AAAA,QACA;AAAA,QACA;AAAA,UACE,QAAQ,MAAM;AAAA,UACd,GAAI,MAAM,YAAY,SAAY,CAAC,IAAI,EAAE,SAAS,MAAM,QAAQ;AAAA,UAChE,GAAI,MAAM,YAAY,SAAY,CAAC,IAAI,EAAE,SAAS,MAAM,QAAQ;AAAA,QAClE;AAAA,QACA,MAAM,mBAAmB,SACrB,SACA,EAAE,mBAAmB,MAAM,eAAe;AAAA,MAChD;AACA,aAAO,UAAU,IAAI,IAAI,UAAU,IAAI,IAAK;AAAA,IAC9C;AAAA;AAAA;AAAA;AAAA,IAKA,MAAM,eAA8C;AAClD,YAAM,OAAQ,MAAM,KAAK,OAAO,kBAAkB;AAClD,aAAO,KAAK;AAAA,IACd;AAAA,IAEA,MAAM,cAAqC;AACzC,YAAM,OAAQ,MAAM,KAAK,OAAO,iBAAiB;AACjD,aAAO,KAAK;AAAA,IACd;AAAA,IAEA,MAAM,UAAoC;AACxC,YAAM,OAAQ,MAAM,KAAK,OAAO,aAAa;AAC7C,aAAO,KAAK;AAAA,IACd;AAAA,EACF;AACF;;;ACxIO,IAAM,YAAY;AAAA,EACvB,YAAY;AAAA,EACZ,OAAO;AACT;","names":[]}
|