@crvouga/mockingbird-service-suunto 0.0.0-stage → 0.2.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/CHANGELOG.md +11 -0
- package/DISCOVERY.md +78 -0
- package/README.md +105 -2
- package/SUPPORT.md +17 -0
- package/dist/chunk-DJTGVIA6.js +5017 -0
- package/dist/chunk-DJTGVIA6.js.map +7 -0
- package/dist/chunk-K7TDC4MC.js +5698 -0
- package/dist/chunk-K7TDC4MC.js.map +7 -0
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +1382 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +2184 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +7 -0
- package/openapi.yaml +661 -0
- package/package.json +122 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog — @crvouga/mockingbird-service-suunto
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-10-21)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- add manual response controls ([4599960](https://github.com/crvouga/mockingbird/commit/45999603e8b860c633181367afc7baa906ba486b))
|
|
8
|
+
|
|
9
|
+
## 0.1.0 (2026-10-21)
|
|
10
|
+
|
|
11
|
+
Initial release.
|
package/DISCOVERY.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @crvouga/mockingbird-service-suunto discovery
|
|
2
|
+
|
|
3
|
+
This is the installed-package index for coding agents and tooling. All relative links resolve
|
|
4
|
+
inside `node_modules/@crvouga/mockingbird-service-suunto/`; no repository checkout is needed to discover the emulator's
|
|
5
|
+
supported surface or documented behavior.
|
|
6
|
+
|
|
7
|
+
## Capability and behavior sources
|
|
8
|
+
|
|
9
|
+
| Question | Authoritative file | What it contains |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Behaviour and integration | [`README.md`](README.md) | Routes, state transitions, auth, webhooks, controls, presets and deliberate omissions. |
|
|
12
|
+
| Exact capabilities | [`SUPPORT.md`](SUPPORT.md) | Supported, unsupported and parity-covered operations or commands, including reasons for gaps. |
|
|
13
|
+
| Wire contract | [`openapi.yaml`](openapi.yaml) | Machine-readable paths, methods, schemas, responses and parity annotations. |
|
|
14
|
+
| Public API | [`dist/index.d.ts`](dist/index.d.ts) | The installed package's exact TypeScript exports and signatures. |
|
|
15
|
+
| Package metadata | [`package.json`](package.json) | Runtime/entry-point claims, vendor links, parity scope/tier and `mockingbird.discovery`. |
|
|
16
|
+
|
|
17
|
+
Read these together: the contract/capability matrix says *what* is available, while the README
|
|
18
|
+
defines stateful behavior, lifecycle rules, test controls, and intentional oracle differences.
|
|
19
|
+
If prose and an executable surface disagree, report a parity mismatch instead of adding a
|
|
20
|
+
consumer-side workaround.
|
|
21
|
+
|
|
22
|
+
## Parity and oracle
|
|
23
|
+
|
|
24
|
+
- Declared parity surface: **OAuth, workouts and 24/7 sleep, activity and daily samples**.
|
|
25
|
+
- Parity tier: **cold** (the repository controls when live checks run).
|
|
26
|
+
- Oracle: **Live vendor API or sandbox**.
|
|
27
|
+
- Repository command: `bun run parity:service -- suunto`.
|
|
28
|
+
- Evidence model: Run from a Mockingbird checkout; credentials come only from .env.local or GitHub Actions secrets. Missing credentials exit 2.
|
|
29
|
+
|
|
30
|
+
The npm package contains evidence summaries and the exact contract, not credentials or the
|
|
31
|
+
repository-only parity harness. Self-parity/property and acceptance tests run in the Mockingbird
|
|
32
|
+
repository; live parity is an additional oracle check, not a substitute for the packaged matrix.
|
|
33
|
+
|
|
34
|
+
## Runtime introspection
|
|
35
|
+
|
|
36
|
+
- `GET /__admin/health`
|
|
37
|
+
- `GET /__admin`
|
|
38
|
+
- `GET /__admin/controls`
|
|
39
|
+
- `GET /__admin/state`
|
|
40
|
+
- `GET /__admin/requests`
|
|
41
|
+
- `GET /__admin/metrics`
|
|
42
|
+
- `GET /__admin/faults/presets`
|
|
43
|
+
- `GET /__admin/ui`
|
|
44
|
+
|
|
45
|
+
For HTTP services, use `x-mockingbird-namespace` (or the documented credential/path carrier) so
|
|
46
|
+
parallel tests do not share state. Admin state, journal, metrics and fault-preset endpoints are
|
|
47
|
+
designed for assertions and diagnosis by consuming test suites.
|
|
48
|
+
|
|
49
|
+
## Admin controls
|
|
50
|
+
|
|
51
|
+
Every HTTP emulator takes the same controls, and each means the same thing at boot
|
|
52
|
+
(`createRuntime({ adminControls })`, `serve --admin-controls`), over `/__admin`, and in a request header:
|
|
53
|
+
|
|
54
|
+
| Control | Admin API | Request header | Interface |
|
|
55
|
+
| --- | --- | --- | --- |
|
|
56
|
+
| Isolate state by key | `GET /__admin/namespaces` | `x-mockingbird-namespace: <key>` | [Namespaces](https://mockingbird.chrisvouga.dev/docs/admin-controls/namespaces) |
|
|
57
|
+
| Reset to boot | `POST /__admin/reset` | `x-mockingbird-reset: true` | [Reset](https://mockingbird.chrisvouga.dev/docs/admin-controls/reset) |
|
|
58
|
+
| Snapshot and restore | `POST /__admin/snapshots`, `POST /__admin/snapshots/<id>/restore` | `x-mockingbird-snapshot: <name>`, `x-mockingbird-restore: <name>` | [Snapshot and restore](https://mockingbird.chrisvouga.dev/docs/admin-controls/snapshot-and-restore) |
|
|
59
|
+
| Move the clock | `POST /__admin/clock` | `x-mockingbird-time: <instant>`, `x-mockingbird-advance: <duration>` | [Clock](https://mockingbird.chrisvouga.dev/docs/admin-controls/clock) |
|
|
60
|
+
| Read or branch from old state | `GET /__admin/timeline` | `x-mockingbird-at: <checkpoint>`, `x-mockingbird-branch: <name>` | [Checkpoints and branches](https://mockingbird.chrisvouga.dev/docs/admin-controls/checkpoints-and-branches) |
|
|
61
|
+
| Inject a fault | `POST /__admin/faults` | `x-mockingbird-fault: <fault>` | [Faults](https://mockingbird.chrisvouga.dev/docs/admin-controls/faults) |
|
|
62
|
+
| Latency and timeouts | `PUT /__admin/latency` | `x-mockingbird-delay: <duration>`, `x-mockingbird-timeout: <duration>` | [Latency and timeouts](https://mockingbird.chrisvouga.dev/docs/admin-controls/latency-and-timeouts) |
|
|
63
|
+
| Replay from a seed | `PUT /__admin/controls` | `x-mockingbird-seed: <seed>` | [Seed and determinism](https://mockingbird.chrisvouga.dev/docs/admin-controls/seed-and-determinism) |
|
|
64
|
+
| See what happened | `GET /__admin/requests`, `GET /__admin/state` | | [Journal, metrics and state](https://mockingbird.chrisvouga.dev/docs/admin-controls/journal-and-state) |
|
|
65
|
+
|
|
66
|
+
`GET /__admin` lists every route and header with a one-line summary, and `GET /__admin/controls`
|
|
67
|
+
is the namespace's current control document. The same seed and the same requests give the same
|
|
68
|
+
bytes. Setting controls as an emulator starts is [Boot](https://mockingbird.chrisvouga.dev/docs/admin-controls/boot); every route and header is in
|
|
69
|
+
the [Reference](https://mockingbird.chrisvouga.dev/docs/admin-controls/reference). The full guide: <https://mockingbird.chrisvouga.dev/docs/admin-controls>.
|
|
70
|
+
|
|
71
|
+
## Report a mismatch or missing capability
|
|
72
|
+
|
|
73
|
+
Follow the [agent reporting contract](https://github.com/crvouga/mockingbird/blob/main/docs/REPORTING_ISSUES.md). Include package version,
|
|
74
|
+
operation/command, a minimal redacted request, actual emulator result, expected oracle result or vendor
|
|
75
|
+
documentation, and whether the mismatch appears in the matrix. Never include keys, tokens,
|
|
76
|
+
customer data, prompts, PHI, card data, or unredacted recordings.
|
|
77
|
+
|
|
78
|
+
Service key: `suunto`.
|
package/README.md
CHANGED
|
@@ -1,3 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @crvouga/mockingbird-service-suunto
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Local emulators. Real API contracts. Part of [Mockingbird](https://github.com/crvouga/mockingbird).
|
|
4
|
+
|
|
5
|
+
WIP Suunto Cloud API emulator: the OAuth 2.0 authorization-code flow, workouts, and the 24/7 sleep, activity and daily-statistics samples, behind the API management gateway's subscription key.
|
|
6
|
+
|
|
7
|
+
**Evidence.** The public onboarding page ([apizone.suunto.com/how-to-start](https://apizone.suunto.com/how-to-start)) documents the authorize and token exchange (Basic client authentication), the JWT access token with its `user` claim, and the `Ocp-Apim-Subscription-Key` header. Everything else (every data path, field, status, error body, window limit and `since` rule) comes from the consumer request in issue #547, whose per-operation schemas are not public. It is a **best guess, not oracle-verified**; each such choice is marked below. Nothing here has been observed against a live Suunto account.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
`bun add @crvouga/mockingbird-service-suunto`
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createServer } from "@crvouga/mockingbird-service-suunto/server"
|
|
17
|
+
|
|
18
|
+
// Test setup: a local Suunto. Its URL replaces both https://cloudapi.suunto.com (data) and
|
|
19
|
+
// https://cloudapi-oauth.suunto.com (authorize and token) in the application.
|
|
20
|
+
const server = await createServer({
|
|
21
|
+
workouts: [
|
|
22
|
+
{ workoutId: 1001, activityId: 3, startTime: 1788307200000, stopTime: 1788310800000, totalTime: 3600 },
|
|
23
|
+
],
|
|
24
|
+
settings: { subscriptionKey: "my-subscription-key" },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Application code: the OAuth redirect, then the requests it sends to Suunto.
|
|
28
|
+
const consent = await fetch(
|
|
29
|
+
`${server.url}/oauth/authorize?response_type=code&client_id=mock_client&redirect_uri=http://localhost:8000/callback&state=abc`,
|
|
30
|
+
{ redirect: "manual" },
|
|
31
|
+
)
|
|
32
|
+
const code = new URL(consent.headers.get("location") ?? "").searchParams.get("code") ?? ""
|
|
33
|
+
const token = (await (
|
|
34
|
+
await fetch(`${server.url}/oauth/token`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
authorization: `Basic ${btoa("mock_client:mock_client_secret")}`,
|
|
38
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
39
|
+
},
|
|
40
|
+
body: new URLSearchParams({
|
|
41
|
+
grant_type: "authorization_code",
|
|
42
|
+
code,
|
|
43
|
+
redirect_uri: "http://localhost:8000/callback",
|
|
44
|
+
}),
|
|
45
|
+
})
|
|
46
|
+
).json()) as { access_token: string }
|
|
47
|
+
const response = await fetch(`${server.url}/v3/workouts/?since=0&limit=100&offset=0`, {
|
|
48
|
+
headers: {
|
|
49
|
+
authorization: `Bearer ${token.access_token}`,
|
|
50
|
+
"ocp-apim-subscription-key": "my-subscription-key",
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
const { payload } = (await response.json()) as { payload: { workoutId: number }[] }
|
|
54
|
+
|
|
55
|
+
await server.close()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run `mockingbird-suunto serve --port 12141`. The consumer hard-codes both origins; give it a setting for the data origin, the authorize URL and the token URL and point all three at this server (`/oauth/authorize`, `/oauth/token`, everything else on the same origin). Serve it over HTTP on a loopback port when a browser must reach the authorize page. `test/consumer.ts` is a port of the consumer's httpx client (Basic token calls, JWT claim reads, one refresh on 401, one wait on 429, a top-level `code`/`error` read as a failure); the acceptance tests run through it.
|
|
59
|
+
|
|
60
|
+
## Routes
|
|
61
|
+
|
|
62
|
+
| Route | Behavior |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `GET /oauth/authorize` | Needs `client_id` and `redirect_uri` (an unregistered client or URI is a 400 JSON error, never a redirect). Redirects to `redirect_uri?code=<one-time code>&state=<state unchanged>`. `scope` and PKCE are ignored. Consent mode `deny` redirects with `error=access_denied`, mode `error` with `error=server_error` (both with `error_description` and the state). A `response_type` other than `code` redirects with `error=unsupported_response_type`. |
|
|
65
|
+
| `POST /oauth/token` | Form body, `Authorization: Basic base64(client_id:client_secret)`. Wrong or missing credentials are a 401 `invalid_client`. `authorization_code` needs `code` and the same `redirect_uri`; a reused, expired or other client's code is a 400 `invalid_grant`. `refresh_token` returns a new access token. Answers `{access_token, token_type: "bearer", refresh_token, expires_in: 86400, scope: "workout"}`. Access and refresh tokens are JWTs; the access token carries `sub` (subject id), `user` (Suunto username), `client_id`, `scope`, `iat`, `exp` and `jti`. The signature is a deterministic stand-in: consumers decode, never verify. |
|
|
66
|
+
| `GET /v3/workouts/` | `{"error": null, "payload": [...]}` for the token's user, oldest first, with `since`, `limit` (default 100), `offset` and `filter-by-modification-time`. Each item is the seeded workout plus `workoutKey`. |
|
|
67
|
+
| `GET /v3/workouts/{workoutKey}` | `{"error": null, "payload": {...}}`; an unknown key or another user's is a 404 `{"error", "code": 404}`. |
|
|
68
|
+
| `GET /247samples/sleep`, `GET /247samples/activity` | `from` and `to` (epoch milliseconds, `from` inclusive, `to` exclusive). A top-level array of `{timestamp, entryData}` in timestamp order. |
|
|
69
|
+
| `GET /247/daily-activity-statistics` | `startdate` and `enddate` (`YYYY-MM-DDTHH:MM:SS`, read as UTC; same window rule). A top-level array with one entry per `Name`, each with `Aggregation` and `Sources[].Samples[]` of `{Value, TimeISO8601}`. |
|
|
70
|
+
|
|
71
|
+
Data routes go through the gateway first. With a `subscriptionKey` set, a missing or wrong `Ocp-Apim-Subscription-Key` (or `subscription-key` query parameter) is a 401 `{"statusCode": 401, "message": "Access denied due to ... subscription key ..."}` before the token is looked at. Without one configured the key is not required. An unknown, expired or missing token is a 401 `{"statusCode": 401, "message": "Unauthorized. Access token is missing or invalid."}`. The token is read with or without the word `Bearer` (the vendor's own curl example omits it). An unknown path is a gateway-style 404. The workouts list is served at `/v3/workouts/` with the trailing slash only.
|
|
72
|
+
|
|
73
|
+
API errors (a malformed or missing window, `since`, `limit`, `offset`) are `{"error": "<message>", "code": 400, "payload": null}`, so the consumer's read of a top-level `code` and `error` sees them.
|
|
74
|
+
|
|
75
|
+
### Best guesses (not oracle-verified)
|
|
76
|
+
|
|
77
|
+
- The gateway wording (`statusCode`/`message` bodies) is Azure API Management's usual form, taken from its documentation, not from a Suunto response.
|
|
78
|
+
- `since` is epoch seconds as the consumer sends it; a value of 1e11 or more (a millisecond timestamp, which the `/v2` documentation is believed to use) is read as milliseconds. `since=0` or omitted means all; otherwise a workout is returned when it is strictly after `since`. With `filter-by-modification-time=true` the compared time is the workout's `modifiedAt` (default: its `stopTime`), otherwise its `startTime`. Results are ordered by that time, then `workoutId`, and `limit`/`offset` slice that order.
|
|
79
|
+
- The 24/7 routes reject a window longer than `maxWindowDays` (default 28; the consumer chunks at 20 and 14 days) with a 400 `{"error": "The time range must not be longer than 28 days", "code": 400}`. `to` is exclusive so consecutive chunks never repeat a sample.
|
|
80
|
+
- The refresh token is reusable and returned unchanged by default; set `rotateRefreshTokens` to spend it and issue a new one on each refresh.
|
|
81
|
+
- `error_in_body` answers a 200 with `{"error": "rate_limited", "code": 429}`; whether the real gateway ever does is unknown.
|
|
82
|
+
- A daily statistic's `Aggregation` defaults to `Sum` and its source to `Suunto`.
|
|
83
|
+
|
|
84
|
+
## Test setup
|
|
85
|
+
|
|
86
|
+
Everything below is the test's, not the application's.
|
|
87
|
+
|
|
88
|
+
- **Seed.** Constructor options and `createServer` take `users` (`{subject, username}`; default `mock_user`), `clients` (`{clientId, clientSecret, redirectUris}`; default `mock_client` / `mock_client_secret`, any redirect URI), `workouts` (`workoutId`, `activityId`, `startTime`, `stopTime`, `totalTime` required, any other field passed through; optional `subject`, `modifiedAt`, `workoutKey`), `sleepSamples` and `activitySamples` (`{subject?, timestamp, entryData}`), `dailyStatistics` (`{subject?, name, time, value, aggregation?, source?}`) and `settings`. Seeds without a `subject` belong to the first user. At runtime: `POST /__admin/users`, `/workouts`, `/samples/sleep`, `/samples/activity`, `/daily-statistics` with the same bodies.
|
|
89
|
+
- **Tokens.** `mock_suunto_token` (refresh `mock_suunto_refresh`) belongs to the first user and does not expire, so a test needs no OAuth round trip. `POST /__admin/tokens {username?, subject?, clientId?}` mints a JWT pair for a user. `POST /__admin/tokens/expire {accessToken?}` expires one token, or all, now; the clock controls expiry otherwise (`expires_in` is `accessTokenTtlSeconds`).
|
|
90
|
+
- **Settings.** `GET` and `PUT /__admin/settings`: `subscriptionKey` (`null` accepts requests without one), `accessTokenTtlSeconds`, `codeTtlSeconds`, `consent` (`approve`, `deny`, `error`), `consentUser` (the username consent approves as; default the first user), `scope`, `rotateRefreshTokens`, `maxWindowDays`.
|
|
91
|
+
- **Presets** (`POST /__admin/faults {"preset": "<name>", "count": N}`, on the workouts and 24/7 routes only): `unauthorized` (401), `invalid_subscription_key` (401), `rate_limited` (429 with `Retry-After: 2`, API Management body `Rate limit is exceeded. Try again in 2 seconds.`), `rate_limited_no_retry_after`, `server_error` (500), `error_in_body` (200 with `{"error": "rate_limited", "code": 429}`), `connection_drop`. Generic faults add latency. The 429 and 500 bodies are scripted fixtures.
|
|
92
|
+
- **Standard controls.** `/__admin/health`, `/state`, `/reset`, `/requests`, `/clock`, Timeline checkpoints and faults live under `/__admin` (relocatable with `adminPrefix`). `x-mockingbird-namespace`, `/__admin/ns/<name>` and credential mappings isolate users, tokens, workouts, samples and settings. The journal holds metadata only, never tokens, codes or secrets.
|
|
93
|
+
|
|
94
|
+
## Oracle and tests
|
|
95
|
+
|
|
96
|
+
`bun test` runs the acceptance tests (every numbered behavior of issue #547, through `test/consumer.ts`) and every-operation self-parity with divergence detection. `bun run parity` needs `SUUNTO_ACCESS_TOKEN` and `SUUNTO_SUBSCRIPTION_KEY` (an approved API Zone subscription and a Suunto App account with synced watch data), reads one workouts page, checks only the envelope and never prints data; without them it exits 2.
|
|
97
|
+
|
|
98
|
+
## Deliberately not modelled
|
|
99
|
+
|
|
100
|
+
Webhook notifications (Suunto documents them for new workouts; the consumer does not receive them), route and FIT file downloads, `/247samples/recovery`, SuuntoPlus, writes, and the `/v2/workouts` family (not served, not aliased). Also not modelled: a consent page UI (consent is a redirect decided by `consent`), `scope` negotiation, PKCE, refresh-token expiry, per-subscription rate limits, and Suunto's field validation of workout and sample content (records are synthetic pass-through fixtures). No claim that the private consumer itself was exercised.
|
|
101
|
+
|
|
102
|
+
## API
|
|
103
|
+
|
|
104
|
+
Root runtime exports: `SuuntoAPI`, `SUUNTO_NAMESPACE`, `DEFAULT_ACCESS_TOKEN`, `DEFAULT_REFRESH_TOKEN`, `DEFAULT_USER`, `DEFAULT_CLIENT`, `DEFAULT_SETTINGS`, `accessToken`, `parseDate`, `gatewayError`, `apiError`, `oauthError`, `createRuntime`, `SUUNTO_PRESETS`, `document`, `operationIds`, `supportedOperationIds`. Types include `SuuntoAPIOptions`, `User`, `Client`, `Settings`, `Grant`, `RefreshGrant`, `AuthorizationCode`, `Workout`, `WorkoutFields`, `SeedWorkout`, `Sample`, `SeedSample`, `DailyStatistic`, `SeedDailyStatistic`, `SuuntoRuntimeOptions`, `SuuntoRuntime`, `OperationId`, `SupportedOperationId`.
|
|
105
|
+
|
|
106
|
+
`/server` exports `createServer`, `serveTarget`, `DEFAULT_PORT` and types `SuuntoServerOptions`, `SuuntoServer`.
|
package/SUPPORT.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Suunto Cloud API (Mockingbird subset) — operation support
|
|
2
|
+
|
|
3
|
+
Generated from `openapi.yaml`; do not edit by hand.
|
|
4
|
+
|
|
5
|
+
- operations in spec: **7**
|
|
6
|
+
- supported by the emulator: **7**
|
|
7
|
+
- parity enabled: **6**
|
|
8
|
+
|
|
9
|
+
| operationId | route | emulator | parity | notes |
|
|
10
|
+
| --- | --- | --- | --- | --- |
|
|
11
|
+
| `Authorize` | `GET /oauth/authorize` | ✅ supported | ❌ disabled | Browser redirect flow whose one-time codes only the acceptance tests can redeem. |
|
|
12
|
+
| `OAuthToken` | `POST /oauth/token` | ✅ supported | ⚠️ unsafe (opt-in) | |
|
|
13
|
+
| `ListWorkouts` | `GET /v3/workouts/` | ✅ supported | ✅ | |
|
|
14
|
+
| `GetWorkout` | `GET /v3/workouts/{workoutKey}` | ✅ supported | ✅ | |
|
|
15
|
+
| `ListSleepSamples` | `GET /247samples/sleep` | ✅ supported | ✅ | |
|
|
16
|
+
| `ListActivitySamples` | `GET /247samples/activity` | ✅ supported | ✅ | |
|
|
17
|
+
| `ListDailyActivityStatistics` | `GET /247/daily-activity-statistics` | ✅ supported | ✅ | |
|