@thinger-io/cli-core 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ # Product code
2
+
3
+ JavaScript that a product's profile calls to compute, transform or route data for its devices:
4
+ resource values (including simulated devices), payload transforms, the device a product call is
5
+ for. Platform concepts: the document `concepts`. It describes Thinger 7.5; what needs a later
6
+ version says "since Thinger X" (a server's version: `GET /v1/server/version`).
7
+
8
+ ## Where it lives
9
+
10
+ `PUT /v1/users/{user}/products/{product}/profile/code` with
11
+ `{ "code": "<JavaScript>", "storage": "" }`, or `{ "storage": "<storage id>" }` to load
12
+ `<storage>/index.js` from a file storage (with its `node_modules`). Since Thinger 7.5, add
13
+ `"path": "<folder>"` to
14
+ load `<storage>/<folder>/index.js` (and that folder's `node_modules`) instead: one storage can then
15
+ hold a whole application, e.g. the dashboard in `app/` and each product's code in
16
+ `code/<product>/`. Read it back with `GET` on the same path.
17
+
18
+ The code takes effect at once: every change to the profile (code, any category or entry, or
19
+ enabling the product) reloads it. Editing `index.js` in the storage does **not**: `PUT
20
+ profile/code` again afterwards.
21
+
22
+ ## Runtime
23
+
24
+ - Node.js embedded in the server, one instance per product.
25
+ - Functions are found by name as globals: declare them with `function name(…) {}` (or
26
+ `var name = …`, `globalThis.name = …`). Top-level `const` / `let` / `class` names and
27
+ `module.exports` are not visible to the profile.
28
+ - **Synchronous only.** There is no event loop: promises, `async` functions, timers, `fetch` and
29
+ network modules never complete (an `async` function returns `{}`). Synchronous built-ins
30
+ (`Math`, `Date`, `JSON`, `require('crypto')`…) work.
31
+ - `Date` runs in **UTC** (`getHours()` is the UTC hour). For local time use `Intl`, which knows
32
+ time zones and daylight saving:
33
+ `new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Madrid', hour: 'numeric', hourCycle: 'h23' }).formatToParts(new Date())`
34
+ gives the hour in Madrid.
35
+ - Return JSON values: objects, arrays, numbers, strings, booleans. A `Date` or other class
36
+ instance becomes `{}`: return `Date.now()` (milliseconds) instead.
37
+ - A function that throws returns `null`. There is no time limit: keep functions short.
38
+ - No access to the filesystem except reading user storages, no processes, no environment.
39
+ - The code cannot call Thinger directly (no API object). It talks to the platform only through
40
+ the profile: placeholders bring data in, targets send results out (below).
41
+
42
+ `console.log` / `info` / `warn` / `error` are sent as `product_script_log` events while someone
43
+ listens: the product's console in the web console, or the websocket
44
+ `/v1/users/{user}/products/{product}/profile/console`.
45
+
46
+ ## Where functions are called
47
+
48
+ A function receives one argument (two for `device_id_resolver`) and returns the new value.
49
+
50
+ | Hook | Config | Receives | Returns |
51
+ |---|---|---|---|
52
+ | API resource, request target `function` | `api.<r>.request.data` `{ "target": "function", "function": "<name>" }` | the request payload, shaped by `payload_type` (below) | the resource result (with response `source: "request_response"`) |
53
+ | API resource, response source `function` | `api.<r>.response.data` `{ "source": "function", "function": "<name>" }` | `{ user, device, resource, product }` | the resource result |
54
+ | Payload transform | `payload_function: "<name>"` in a request, response, bucket, property or flow `data` | the payload after `payload_type` | the new payload (an empty result skips a bucket / property write or a flow) |
55
+ | Device of a product call | `device_id_resolver: "<name>"` at the root of an API resource or flow | `(payload, placeholders)` | a device id (string) |
56
+ | Placeholder converter | `{{<placeholder>:<name>}}` inside any template | the placeholder value | the replacement |
57
+
58
+ `payload_type` decides what a function or target gets:
59
+
60
+ - `template_payload` (default): the `payload` template with placeholders filled, parsed as JSON.
61
+ Without a `payload` it is empty, so a bucket or property entry writes nothing.
62
+ - `source_payload`: the incoming value as is (in a response, the target's result); the same as
63
+ `"payload": "{{payload}}"`.
64
+ - `source_event`: everything: `{ user, device, resource, product, payload }`.
65
+ - `none`: nothing.
66
+
67
+ Placeholders available in templates: `{{device}}`, `{{user}}`, `{{product}}`, `{{resource}}`,
68
+ `{{payload}}` / `{{payload.<field>}}`, `{{property.<id>}}` (a device property's value, inherited
69
+ like any device property: device → asset type → asset group → product, the first that defines it,
70
+ whole value),
71
+ `{{meta.<field>}}` (a field of the device document), `{{api.<resource>}}` (another API resource),
72
+ and `{{x=default}}` for defaults.
73
+
74
+ ## Resources computed by code
75
+
76
+ An API resource whose request target is a function makes a value out of nothing: the basis of
77
+ simulated devices.
78
+
79
+ ```json
80
+ "api": {
81
+ "climate": {
82
+ "enabled": true,
83
+ "request": { "data": { "target": "function", "function": "simulateClimate", "payload_type": "source_event" } },
84
+ "response": { "data": { "source": "request_response", "payload_type": "source_payload" } }
85
+ }
86
+ }
87
+ ```
88
+
89
+ ```js
90
+ // ctx = { user, device, resource, product, payload }
91
+ function simulateClimate(ctx) {
92
+ var day = Math.sin(((Date.now() / 3600000) % 24 - 6) / 24 * 2 * Math.PI); // day/night cycle
93
+ return {
94
+ temperature: Math.round((22 + 6 * day + (Math.random() - 0.5)) * 10) / 10,
95
+ humidity: Math.round((60 - 15 * day + (Math.random() - 0.5) * 4) * 10) / 10
96
+ };
97
+ }
98
+ ```
99
+
100
+ `GET /v3/users/{user}/devices/{device}/resources/climate` then returns the object for any device
101
+ of the product, including `Virtual` devices, which have no hardware and get every resource from
102
+ their product. A profile bucket or property with `source: "resource"` and an interval calls the
103
+ resource for every connected device of the product on that schedule (see
104
+ the document `concepts`, Buckets).
105
+
106
+ Without `"response": { "source": "request_response" }` the function runs but the resource
107
+ returns nothing; without `payload_type: "source_payload"` (or `"payload": "{{payload}}"`) in the
108
+ response, it returns `null`.
109
+
110
+ ## State between calls
111
+
112
+ - **In memory**: globals survive between calls of the same product, so
113
+ `globalThis.state = globalThis.state || {}; state[ctx.device] = …` keeps per-device state.
114
+ It is lost when the profile reloads (any profile change) and when the server restarts. Code
115
+ that keeps "what happened since the last call" in memory starts over after each reload: it can
116
+ repeat or skip one step (e.g. emit an event twice). Keep that cursor in a property if it matters.
117
+ - **Persistent**: keep the state in a device property. Read it with `{{property.<id>}}` in the
118
+ request payload template and write the function's result back with request target
119
+ `property`:
120
+
121
+ ```json
122
+ "api": {
123
+ "tank": {
124
+ "enabled": true,
125
+ "request": { "data": {
126
+ "target": "property", "property": "tank",
127
+ "payload": "{\"prev\": {{property.tank}}, \"in\": {{payload}} }",
128
+ "payload_function": "stepTank" } },
129
+ "response": { "data": { "source": "request_response", "payload_type": "source_payload" } }
130
+ }
131
+ }
132
+ ```
133
+
134
+ ```js
135
+ // p = { prev: <last tank property or {}>, in: <input sent to the resource or null> }
136
+ function stepTank(p) {
137
+ var s = typeof p.prev.level === 'number' ? p.prev : { level: 50, inflow: 1, outflow: 0.8, ts: Date.now() };
138
+ var now = Date.now(), minutes = (now - s.ts) / 60000;
139
+ var inflow = p.in && p.in.inflow !== undefined ? p.in.inflow : s.inflow;
140
+ var outflow = p.in && p.in.outflow !== undefined ? p.in.outflow : s.outflow;
141
+ var level = Math.min(100, Math.max(0, s.level + (inflow - outflow) * minutes));
142
+ return { level: level, inflow: inflow, outflow: outflow, ts: now }; // saved as property "tank"
143
+ }
144
+ ```
145
+
146
+ Each call advances the level from the stored state; calling the resource with
147
+ `{ "inflow": 3 }` changes the flow. Do not combine it with a `{{payload=property.tank}}`
148
+ default: defaults that read a property re-run the resource when that property changes, and this
149
+ resource writes it.
150
+
151
+ ## Limits and pitfalls
152
+
153
+ - No asynchronous work and no outgoing requests from code: to call an external service use an
154
+ endpoint (request target `endpoint_call`).
155
+ - One argument per function (two for `device_id_resolver`); use `payload_type: "source_event"`
156
+ to get the device id and the rest of the context.
157
+ - Keep per-call work small: functions run synchronously in the server, without a timeout.
158
+ - A product must be `enabled`, and every API / bucket / property / flow entry needs
159
+ `"enabled": true`, or it is skipped.
@@ -0,0 +1,165 @@
1
+ # Thinger server administration
2
+
3
+ For agents acting as a server **admin**: host settings, domains and brands, user accounts,
4
+ OAuth clients, identity providers and proxies. Every route here needs an admin session or
5
+ token. Read the OpenAPI document for each body; this guide covers what the schemas cannot say:
6
+ order of steps, side effects and risks. It describes Thinger 7.5; what needs a later version says
7
+ "since Thinger X" (a server's version: `GET /v1/server/version`).
8
+
9
+ Before changing anything, read the current value and tell the user what will change. Several
10
+ actions here can take the server or a domain offline.
11
+
12
+ ## Hosts and server settings
13
+
14
+ A host is one server process; its id is the machine's hostname (`GET /v1/hosts`).
15
+
16
+ - `GET /v1/hosts/{host}/settings` returns the editable settings (database, plugins and similar
17
+ internals are hidden).
18
+ - `PUT /v1/hosts/{host}/settings` is a **merge patch** (RFC 7386) over the host's custom
19
+ settings: send only the sections you change; `null` removes a custom value, so the provider
20
+ default applies again. Values are validated against the schema.
21
+ - **Settings apply only after a restart**: `POST /v1/hosts/{host}/restart`. The process exits
22
+ and the container restarts it: every connection drops (devices, consoles, websockets) for a
23
+ few seconds or more. Ask the user before restarting, and prefer a quiet moment.
24
+ - Ports are **strings** (`"1883"`), as the server reads them.
25
+ - Logs: `GET /v1/hosts/{host}/logs?lines=200` returns the last lines as text; the same route
26
+ over a websocket streams them live.
27
+
28
+ Main sections: `http_server`, `thing_server` (IOTMP), `mqtt_server`, `proxies` (dynamic port
29
+ range), `email` (server mail: `smtp` or `ses`), `buckets.storage` / `buckets.export`, `ssl`,
30
+ `accounts` (sign-up, login methods, password rules, `limits.<collection>.max_count` with -1 =
31
+ unlimited and 0 = disabled), `deployment` (`contact_email`: whoever registers with it becomes
32
+ admin), `scripting_engine`.
33
+
34
+ Some features are off by default (`max_count` 0): projects, project members, domains, brands and
35
+ identity providers. On Thinger cloud instances the subscription plan sets these limits; on a
36
+ self-hosted server raise `accounts.limits.<collection>.max_count` (`projects`,
37
+ `projects_members`, `domains`, `brands`, `identity_providers`) and restart. The domains and
38
+ brands limits are not shown by `GET settings`.
39
+
40
+ On cloud instances the plan owns `accounts.limits.{accounts,brands,domains,members}`, `database`,
41
+ `plugins`, `rate_limiter` and `http_server.hosts`: since Thinger 7.5, writing them returns 403.
42
+ To raise them, the customer changes the plan.
43
+
44
+ ## Custom domain with its own brand
45
+
46
+ A **brand** is the look and login behaviour of the console for one hostname. Its id **is** the
47
+ hostname (`console.example.com`); there is no other link between a domain and a brand. The
48
+ **domain** resource only manages the TLS certificate of that hostname.
49
+
50
+ 1. **DNS (done by the user).** Make the hostname resolve to this server: an `A` / `AAAA` record
51
+ with the server's public IP, or a `CNAME` to the server's existing hostname (the one the user
52
+ already uses to reach the console). There is no special verification value or TXT record.
53
+ Port **80** must be reachable from the internet. Tell the user exactly which record to
54
+ create, then wait until it resolves (it can take minutes; up to the record's TTL).
55
+ 2. **Create the domain**: `POST /v1/domains` `{ "domain": "console.example.com" }`. No side
56
+ effects yet.
57
+ 3. **Verify**: `POST /v1/domains/{domain}/verify`. The server asks Let's Encrypt for a
58
+ certificate (HTTP-01: Let's Encrypt fetches `http://<domain>/.well-known/acme-challenge/...`
59
+ from this server). On failure the error carries Let's Encrypt's reason (DNS not found,
60
+ connection refused, rate limit…): report it to the user as is. A certificate with more than
61
+ 30 days left counts as verified. Wildcards (`*.example.com`) use DNS-01 and only work on
62
+ instances hosted by Thinger.
63
+ 4. **Create the brand**: `POST /v1/brands` `{ "brand": "console.example.com", "name": ..., "config": {...} }`.
64
+ 5. **Configure it**: texts, logos, colors, login page, PWA, account options (`config`), and its
65
+ email (`email`). See below.
66
+ 6. Open `https://console.example.com` to check.
67
+
68
+ Certificates renew automatically (daily check, 30 days before expiry). If the DNS record is
69
+ later changed or removed, renewal fails silently and HTTPS breaks when the current certificate
70
+ expires. Deleting a domain removes its certificate at once.
71
+
72
+ Local or development builds may use Let's Encrypt **staging**, whose certificates browsers do not
73
+ trust: verification "works" but the site shows a warning.
74
+
75
+ ### Brand configuration
76
+
77
+ - `PUT /v1/brands/{brand}` **replaces** `config` and `email` as a whole, and deletes the logo
78
+ files missing from the new `config`. Use it only with the full object.
79
+ - `PATCH /v1/brands/{brand}` **merges** into the stored ones (`null` removes a key). Prefer it:
80
+ `{ "config": { "title": "Acme IoT" } }` changes only the title.
81
+ - Logos and icons (`favicon`, `share_image`, `dark_logo` for login and exports, `bright_logo`
82
+ for the menu, `icon` for the folded menu, `pwa.icons[].src`) are sent as **data URLs**
83
+ (`data:image/png;base64,...` or webp). The server stores the file and keeps a
84
+ `/brand/images/...` URL. Sending that URL back keeps the image; `null` or `""` deletes it.
85
+ - `config.accounts` overrides the server's account settings for this hostname: public sign-up
86
+ (and its role), password / passkey login, 2FA, `cross_sign_in` (false: only users registered
87
+ on this hostname can log in), reCAPTCHA (`no_robot`), federated login and its identity
88
+ providers.
89
+ - The brand is readable without login (login pages need it); secrets in it are only returned to
90
+ admins.
91
+
92
+ ### Brand email
93
+
94
+ `email.custom: true` with `email.settings` makes this brand send its emails (account
95
+ verification, password reset, account creation, device disabled) through its own server:
96
+ `type: "smtp"` with `smtp.host`, `smtp.port` (string), `username`, `password`, `secure` (true
97
+ for implicit TLS, e.g. 465; otherwise STARTTLS), or `type: "ses"` with AWS keys and region.
98
+ `settings.from` / `from_name` are the sender. `email.templates.<name>` can override `subject`
99
+ and `body` (HTML). Start from the defaults (`GET /v1/brands/{brand}/email` returns them) and keep
100
+ their links:
101
+
102
+ | Template | Link in the body |
103
+ |---|---|
104
+ | `email_verification` | `{{host_url}}/verify_email?code={{code}}` |
105
+ | `forgot_password` | `{{host_url}}/password?code={{code}}` |
106
+ | `create_password` | `{{host_url}}/create_password?code={{code}}` |
107
+ | `device_disabled` | (no link; `{{device}}` is the device) |
108
+
109
+ Placeholders filled when sending: `{{code}}`, `{{user}}`, `{{email}}`, `{{host}}`, `{{admin}}`
110
+ (server contact email), `{{host_url}}`. `{{name}}`, `{{url}}`, `{{logo}}` and `{{description}}`
111
+ only exist in the built-in defaults: in a custom template write the brand name and the logo URL
112
+ (`https://<brand>/brand/images/dark_logo.png`) directly. Email clients do not load `data:` images,
113
+ so use that public URL. The `enabled` flag of a template is not enforced yet.
114
+
115
+ `POST /v1/brands/{brand}/email/test` `{ "template": "email_verification" }` sends that template to
116
+ the **calling admin's** email address with the **saved** settings: save first, then test.
117
+
118
+ ## User accounts
119
+
120
+ `POST /v1/users` creates an account; `role` is `user`, `member` (only inside projects, needs a
121
+ default project), `domain_admin` (admin of the hostnames in its `host` list) or `admin`. Only
122
+ admins create admins. An empty password sends the "create password" email instead. Per-user
123
+ `limits` override the server limits. Deleting a user deletes **all** its resources (devices,
124
+ buckets, plugins, files): confirm explicitly.
125
+
126
+ ## OAuth clients
127
+
128
+ Applications that log users in with this server (authorization code or device flow).
129
+ `redirect_uri` is a **newline-separated** list of exact URLs. The `secret` is stored and returned
130
+ in plain text to admins: handle it as a credential. `enabled: false` blocks the client.
131
+
132
+ ## Identity providers (log in with Google, Microsoft…)
133
+
134
+ OIDC providers users can log in with.
135
+
136
+ 1. Allow them: `accounts.limits.identity_providers.max_count` in the host settings (0 by
137
+ default), then restart.
138
+ 2. Register this server in the provider: callback URL `https://<brand or console host>/oauth/callback`.
139
+ 3. Create the provider with its `domain` (issuer), `client_id`, `client_secret`, provisioning
140
+ (`auto_provision` creates accounts, `match_only` only links existing ones; `allowed_domains`
141
+ limits email domains) and `email_trust_mode`.
142
+ 4. Test it: `POST /v1/identity_providers/{id}/test` checks discovery and keys.
143
+ 5. Turn on `federated_login` (host `accounts` or the brand's `config.accounts`) and optionally
144
+ restrict `identity_providers` to some ids.
145
+
146
+ Auto-provisioning creates accounts even when public sign-up is off.
147
+
148
+ ## Proxies
149
+
150
+ Expose a TCP / UDP service, a plugin, or a device service through this server.
151
+
152
+ - Created and updated proxies start or replace a **live listener** at once; deleting one closes
153
+ the port. A wrong port can collide with other services.
154
+ - `source.routing: "subdomain"` serves `https://<proxy id>.<server hostname>` and needs a
155
+ wildcard certificate for that hostname.
156
+ - Send the full `config` on update: a partial one works until the next restart and then breaks.
157
+
158
+ ## Safety checklist
159
+
160
+ - Read before write; describe the change; keep the previous value to undo it.
161
+ - Never restart, delete a domain, delete a user or change login settings without the user's
162
+ explicit confirmation.
163
+ - Changing `accounts` login options (password, 2FA, cross sign-in, sign-up) can lock users out:
164
+ make sure an admin can still log in.
165
+ - Do not echo secrets (SMTP passwords, client secrets, AWS keys) back to the user unless asked.
package/lib/cli.js CHANGED
@@ -9,27 +9,32 @@ import { registerProfileCommands } from './commands/profile.js';
9
9
  import { registerDeviceCommands } from './commands/device.js';
10
10
  import { registerProductCommands } from './commands/product.js';
11
11
  import { registerBucketCommands } from './commands/bucket.js';
12
+ import { registerDashboardCommands } from './commands/dashboard.js';
12
13
 
13
14
  /**
14
15
  * Build and run a CLI on top of the core: global options, the generic
15
- * commands (login, logout, profile, device, product, bucket) and `mcp`.
16
+ * commands (login, logout, profile, device, product, bucket, dashboard) and `mcp`.
16
17
  * The identity must be defined before calling it.
17
18
  *
18
19
  * @param {{
19
20
  * version: string,
20
21
  * argv?: string[],
21
- * register?: (program: Command, groups: { device: Command, product: Command, bucket: Command }) => void,
22
+ * register?: (program: Command, groups: { device: Command, product: Command, bucket: Command, dashboard: Command }) => void,
22
23
  * onNoCommand?: (program: Command) => Promise<void> | void,
23
24
  * mcpTools?: Array<object>,
24
25
  * mcpInstructions?: string,
25
26
  * resourceHooks?: Record<string, import('./mcp/resource-hooks.js').ResourceHooks>,
27
+ * resourceTypes?: Record<string, import('./mcp/tools-resource.js').LocalType>,
28
+ * resourceActions?: Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>,
26
29
  * docs?: Parameters<typeof import('./docs.js').defineCliDocs>[0],
27
30
  * }} opts
28
31
  * `register` adds the CLI's own commands, at the top level or inside the
29
32
  * generic groups. `onNoCommand` runs when the CLI is called without
30
33
  * arguments (default: print the help). `mcpTools` are the CLI's own MCP
31
34
  * tools (see buildTools), and `resourceHooks` its hooks on the
32
- * catalog types the generic `resource_*` tools serve. `docs` are guides
35
+ * catalog types the generic `resource_*` tools serve; `resourceTypes` and
36
+ * `resourceActions` are types and actions it adds to that catalog (its
37
+ * platform's own things, served by the same tools). `docs` are guides
33
38
  * the CLI ships, served by `doc_read` and as MCP resources next to the
34
39
  * server's documentation.
35
40
  */
@@ -41,6 +46,8 @@ export async function runCli({
41
46
  mcpTools = [],
42
47
  mcpInstructions,
43
48
  resourceHooks,
49
+ resourceTypes,
50
+ resourceActions,
44
51
  docs,
45
52
  }) {
46
53
  const id = identity();
@@ -75,6 +82,7 @@ export async function runCli({
75
82
  device: registerDeviceCommands(program),
76
83
  product: registerProductCommands(program),
77
84
  bucket: registerBucketCommands(program),
85
+ dashboard: registerDashboardCommands(program),
78
86
  };
79
87
  registerAuthCommands(program);
80
88
  registerProfileCommands(program);
@@ -91,6 +99,8 @@ export async function runCli({
91
99
  tools: mcpTools,
92
100
  instructions: mcpInstructions,
93
101
  resourceHooks,
102
+ resourceTypes,
103
+ resourceActions,
94
104
  docs,
95
105
  });
96
106
  } catch (error) {
@@ -10,6 +10,7 @@ import {
10
10
  setActiveProfile,
11
11
  } from '../config.js';
12
12
  import { hint, info, warning } from '../format.js';
13
+ import { env, identity } from '../identity.js';
13
14
  import { classifyError, createSpinner, printErr } from '../output.js';
14
15
 
15
16
  /**
@@ -46,6 +47,13 @@ export function registerAuthCommands(program) {
46
47
  clientId: opts.clientId,
47
48
  });
48
49
  console.log(info(`Signed in (profile ${getActiveProfile()}).`));
50
+ if (env('TOKEN')) {
51
+ console.log(
52
+ warning(
53
+ `${identity().envPrefix}_TOKEN is set: commands use it instead of this profile until you unset it.`,
54
+ ),
55
+ );
56
+ }
49
57
  } catch (error) {
50
58
  const { message, code } = classifyError(error);
51
59
  printErr(message, { code });
@@ -65,7 +73,18 @@ export function registerAuthCommands(program) {
65
73
  console.log(warning('No configuration found. Already logged out.'));
66
74
  return;
67
75
  }
68
- const names = opts.all ? listProfiles() : [getActiveProfile()];
76
+ const active = getActiveProfile();
77
+ if (!opts.all && !active && env('TOKEN')) {
78
+ // <PREFIX>_TOKEN stands for the profile: there is nothing saved to forget.
79
+ const prefix = identity().envPrefix;
80
+ console.log(
81
+ warning(
82
+ `${prefix}_TOKEN is set and stands for the profile: unset it, or pass --profile to log out of a saved one.`,
83
+ ),
84
+ );
85
+ return;
86
+ }
87
+ const names = opts.all ? listProfiles() : [active];
69
88
  for (const name of names) {
70
89
  const spinner = createSpinner(`Logging out of ${name}...`).start();
71
90
  if (!opts.keepToken) {