@pablozaiden/webapp 1.0.0 → 1.1.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/README.md +1 -1
- package/docs/auth-validation.md +4 -0
- package/docs/auth.md +32 -0
- package/docs/getting-started.md +10 -0
- package/docs/server.md +21 -1
- package/docs/settings.md +14 -2
- package/package.json +1 -1
- package/src/contracts/index.ts +13 -0
- package/src/server/auth/api-keys.ts +78 -5
- package/src/server/auth/sqlite-store.ts +36 -4
- package/src/server/auth/store.ts +3 -1
- package/src/server/create-web-app-server.ts +3 -1
- package/src/server/framework-endpoints.ts +35 -1
- package/src/server/index.ts +1 -0
- package/src/server/logger.ts +68 -1
- package/src/server/request-schemas.ts +4 -0
- package/src/web/components/index.tsx +12 -0
- package/src/web/settings/account-section.tsx +25 -1
- package/src/web/styles.css +26 -0
- package/src/web/webapp-config.tsx +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @pablozaiden/webapp
|
|
2
2
|
|
|
3
|
-
Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
|
|
3
|
+
Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned and server-managed API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
package/docs/auth-validation.md
CHANGED
|
@@ -39,6 +39,10 @@ Run this checklist before releasing a framework app or cutting a checkpoint.
|
|
|
39
39
|
3. Call a protected API route with `Authorization: Bearer <token>`.
|
|
40
40
|
4. Delete the key from Settings and confirm the confirmation dialog appears.
|
|
41
41
|
5. Confirm the deleted token no longer authenticates.
|
|
42
|
+
6. Create a managed key through the server-only helper and confirm it authenticates through the same bearer path.
|
|
43
|
+
7. Confirm managed keys and `managedBy` metadata are absent from the normal API-key list and Settings UI.
|
|
44
|
+
8. Restart against the same data directory and confirm a non-expired managed key still authenticates.
|
|
45
|
+
9. Revoke the managed key through the server-only helper and confirm it no longer authenticates.
|
|
42
46
|
|
|
43
47
|
## CLI API-key authentication
|
|
44
48
|
|
package/docs/auth.md
CHANGED
|
@@ -42,6 +42,38 @@ API keys are user-owned bearer tokens for scripts and agents. They are stored ha
|
|
|
42
42
|
|
|
43
43
|
Expired API keys do not authenticate, are omitted from user-facing lists, and are purged when key lists or expired keys are encountered.
|
|
44
44
|
|
|
45
|
+
Server-side applications can create managed keys through the server-only helpers exported from
|
|
46
|
+
`@pablozaiden/webapp/server`:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createManagedApiKey, listManagedApiKeys, revokeManagedApiKey } from "@pablozaiden/webapp/server";
|
|
50
|
+
|
|
51
|
+
const created = createManagedApiKey(app.store, user, {
|
|
52
|
+
name: "Runtime credential",
|
|
53
|
+
managedBy: "my-application",
|
|
54
|
+
scopes: ["*"],
|
|
55
|
+
});
|
|
56
|
+
const keys = listManagedApiKeys(app.store, user.id, "my-application");
|
|
57
|
+
revokeManagedApiKey(app.store, created.key.id, user.id);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Managed keys have an explicit `kind: "managed"` classification and optional opaque
|
|
61
|
+
`managedBy` metadata. The plaintext token is returned only by creation and is never
|
|
62
|
+
stored; applications must retain it securely. Managed keys use the same bearer
|
|
63
|
+
authentication, ownership, scope, expiration, disabled-user, and lifecycle invalidation
|
|
64
|
+
rules as user keys. They survive an ordinary restart, and applications should explicitly
|
|
65
|
+
revoke each generation during their own resource cleanup.
|
|
66
|
+
|
|
67
|
+
Managed keys have no browser/API CRUD endpoint. The normal `/api/api-keys` routes and
|
|
68
|
+
Settings UI expose only `kind: "user"` keys, and request input cannot create or convert a
|
|
69
|
+
managed key. User reset, passkey deletion, user deletion, and expiration cleanup cover
|
|
70
|
+
both kinds.
|
|
71
|
+
|
|
72
|
+
Custom `WebAppStore` implementations must persist `kind` and `managedBy`, migrate
|
|
73
|
+
pre-managed records as `kind: "user"`, include both kinds in expiration cleanup and
|
|
74
|
+
user deletion/reset cleanup, and preserve the owner filter used by the management
|
|
75
|
+
helpers. The built-in SQLite store performs this migration automatically.
|
|
76
|
+
|
|
45
77
|
Same-origin checks are skipped for API-key and device bearer requests unless a route sets `sameOrigin: "always"`, because non-browser clients usually do not send `Origin`.
|
|
46
78
|
|
|
47
79
|
## Device auth
|
package/docs/getting-started.md
CHANGED
|
@@ -190,6 +190,16 @@ reload; `fromEnv` indicates that the server environment locks the value.
|
|
|
190
190
|
`LoggerAdapter` remains application-owned and can adapt the state to any
|
|
191
191
|
logging library.
|
|
192
192
|
|
|
193
|
+
### In-memory server logs
|
|
194
|
+
|
|
195
|
+
Admins can enable temporary server-log capture from Developer Settings next to
|
|
196
|
+
the log-level selector. The framework exposes the current state in
|
|
197
|
+
`/api/config`; it starts disabled on every process start and is never stored in
|
|
198
|
+
the database. Admin clients can retrieve a bounded chronological snapshot from
|
|
199
|
+
`GET /api/server/logs` after enabling capture. The buffer is limited to the
|
|
200
|
+
newest 1,000 framework logger entries or 512 KiB of rendered log lines, and
|
|
201
|
+
turning the setting off clears it.
|
|
202
|
+
|
|
193
203
|
### Transient notifications
|
|
194
204
|
|
|
195
205
|
The standard `renderWebApp` runtime provides the framework notification service
|
package/docs/server.md
CHANGED
|
@@ -148,11 +148,13 @@ Built-in endpoints include:
|
|
|
148
148
|
| `/api/user-setup*` | One-time invite/reset setup links |
|
|
149
149
|
| `/setup` | Browser setup screen for one-time invite/reset links |
|
|
150
150
|
| `/api/users`, `/api/users/:id/*`, `/api/audit-events` | Admin user management and audit log |
|
|
151
|
-
| `/api/api-keys` | Browser-managed API
|
|
151
|
+
| `/api/api-keys` | Browser-managed user API-key create/list/delete |
|
|
152
152
|
| `/api/auth/device`, `/api/auth/token`, `/api/auth/refresh`, `/api/auth/revoke` | Device auth and refresh-token flow |
|
|
153
153
|
| `/device` | Browser device-code approval screen |
|
|
154
154
|
| `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
|
|
155
155
|
| `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
|
|
156
|
+
| `GET /api/server/logs` | Admin-only snapshot of the in-memory server logs |
|
|
157
|
+
| `PUT /api/server/logs/settings` | Admin-only runtime toggle for in-memory server logs |
|
|
156
158
|
| `/api/server/kill` | Authenticated server shutdown |
|
|
157
159
|
| `/api/ws` | Realtime websocket by default |
|
|
158
160
|
|
|
@@ -162,6 +164,24 @@ The effective log level in `GET /api/config` and `GET
|
|
|
162
164
|
to `true`. PUT remains an authenticated, same-origin admin mutation and
|
|
163
165
|
returns a conflict when the environment controls the value.
|
|
164
166
|
|
|
167
|
+
`GET /api/server/logs` is restricted to admin users and returns
|
|
168
|
+
`{ enabled: boolean, logs: ServerLogEntry[] }`. Entries are returned in
|
|
169
|
+
chronological order and contain the timestamp, level, logger scope, message,
|
|
170
|
+
and rendered log line. When capture is disabled, the endpoint returns `200`
|
|
171
|
+
with `enabled: false` and an empty list. `PUT /api/server/logs/settings`
|
|
172
|
+
accepts `{ "enabled": boolean }`, is restricted to admins, and uses the normal
|
|
173
|
+
same-origin policy for browser mutations. API-key and bearer callers continue
|
|
174
|
+
to use the existing token-auth behavior. The in-memory buffer is limited to
|
|
175
|
+
the newest 1,000 entries and 512 KiB of rendered UTF-8 lines; disabling capture
|
|
176
|
+
clears it. These endpoints only expose messages emitted through the framework
|
|
177
|
+
logger and never make the logs durable.
|
|
178
|
+
|
|
179
|
+
Server-side applications that need credentials for internal runtimes should use
|
|
180
|
+
the server-only `createManagedApiKey`, `listManagedApiKeys`, and
|
|
181
|
+
`revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
|
|
182
|
+
store, authenticate through the normal bearer path, and are intentionally absent
|
|
183
|
+
from the browser-managed endpoint and Settings summaries.
|
|
184
|
+
|
|
165
185
|
## Framework-owned web document and PWA metadata
|
|
166
186
|
|
|
167
187
|
`createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated document also initializes the shared `data-wapp-mobile` state before client styles load so CSS and `WebAppRoot` use the same mobile breakpoint. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
|
package/docs/settings.md
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Settings is framework-owned so apps stay consistent. It includes:
|
|
4
4
|
|
|
5
5
|
- Account summary and passkey logout/delete
|
|
6
|
-
- API key create/list/delete for the current user when enabled
|
|
6
|
+
- User API key create/list/delete for the current user when enabled
|
|
7
7
|
- Device-auth sessions for the current user when enabled
|
|
8
8
|
- Theme preference: system/light/dark, stored per user
|
|
9
9
|
- Admin user management
|
|
10
10
|
- Admin log-level preference unless `{PREFIX}_LOG_LEVEL` is set
|
|
11
|
+
- Admin in-memory server-log capture toggle; disabled and cleared on process
|
|
12
|
+
start or when turned off
|
|
11
13
|
- Admin server kill
|
|
12
14
|
- Version/about
|
|
13
15
|
|
|
@@ -33,7 +35,17 @@ the shared hook state after a successful save, and `fromEnv` is true when
|
|
|
33
35
|
`{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
|
|
34
36
|
configuration fetch or initializer component.
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
Developer Settings also includes **Store server logs in memory**. It is an
|
|
39
|
+
admin-only, process-local setting and is disabled when the server starts. When
|
|
40
|
+
enabled, the framework logger retains only the newest 1,000 eligible entries
|
|
41
|
+
within a 512 KiB rendered-line limit; disabling it immediately clears the
|
|
42
|
+
buffer. The setting and captured logs are never stored in SQLite.
|
|
43
|
+
|
|
44
|
+
Security lists only show useful active user credentials. Managed API keys created by
|
|
45
|
+
server-side applications, and their metadata, are not returned by the browser API-key
|
|
46
|
+
list or rendered in Settings. Expired API keys are purged before listing, and revoked
|
|
47
|
+
or expired device-auth refresh sessions are not shown. Revoked refresh sessions may
|
|
48
|
+
remain in storage when needed for token-reuse protection, but they are hidden from Settings.
|
|
37
49
|
|
|
38
50
|
Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
|
|
39
51
|
|
package/package.json
CHANGED
package/src/contracts/index.ts
CHANGED
|
@@ -58,6 +58,8 @@ export interface PasskeyAuthStatusResponse {
|
|
|
58
58
|
ownerPasskeySetupRequired: boolean;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export type ApiKeyKind = "user" | "managed";
|
|
62
|
+
|
|
61
63
|
export interface ApiKeySummary {
|
|
62
64
|
id: string;
|
|
63
65
|
name: string;
|
|
@@ -112,6 +114,14 @@ export interface AuthSessionSummary {
|
|
|
112
114
|
active: boolean;
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
export interface ServerLogEntry {
|
|
118
|
+
timestamp: string;
|
|
119
|
+
level: LogLevelName;
|
|
120
|
+
scope: string;
|
|
121
|
+
message: string;
|
|
122
|
+
line: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
115
125
|
export interface WebAppConfigResponse {
|
|
116
126
|
appName: string;
|
|
117
127
|
version: string;
|
|
@@ -125,6 +135,9 @@ export interface WebAppConfigResponse {
|
|
|
125
135
|
level: LogLevelName;
|
|
126
136
|
fromEnv: boolean;
|
|
127
137
|
};
|
|
138
|
+
inMemoryLogs: {
|
|
139
|
+
enabled: boolean;
|
|
140
|
+
};
|
|
128
141
|
deviceAuth: {
|
|
129
142
|
enabled: boolean;
|
|
130
143
|
};
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import type { ApiKeySummary, CreatedApiKeyResponse, CurrentUser } from "../../contracts";
|
|
2
|
-
import type { WebAppStore } from "./store";
|
|
2
|
+
import type { ApiKeyRecord, WebAppStore } from "./store";
|
|
3
3
|
import { nowIso, randomToken, sha256, secureEqual, isExpired } from "./crypto";
|
|
4
4
|
import { AuthError } from "./types";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
export interface ManagedApiKeySummary extends ApiKeySummary {
|
|
7
|
+
kind: "managed";
|
|
8
|
+
managedBy?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CreatedManagedApiKeyResponse {
|
|
12
|
+
key: ManagedApiKeySummary;
|
|
13
|
+
token: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApiKeyCreationOptions {
|
|
17
|
+
name?: string;
|
|
18
|
+
scopes?: string[];
|
|
19
|
+
prefix?: string;
|
|
20
|
+
expiresAt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ManagedApiKeyCreationOptions extends ApiKeyCreationOptions {
|
|
24
|
+
managedBy?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarize(record: ApiKeyRecord): ApiKeySummary {
|
|
7
28
|
return {
|
|
8
29
|
id: record.id,
|
|
9
30
|
name: record.name,
|
|
@@ -15,17 +36,31 @@ function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummar
|
|
|
15
36
|
};
|
|
16
37
|
}
|
|
17
38
|
|
|
39
|
+
function summarizeManaged(record: ApiKeyRecord): ManagedApiKeySummary {
|
|
40
|
+
return {
|
|
41
|
+
...summarize(record),
|
|
42
|
+
kind: "managed",
|
|
43
|
+
managedBy: record.managedBy,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
export function listApiKeys(store: WebAppStore, userId: string): ApiKeySummary[] {
|
|
19
48
|
store.deleteExpiredApiKeys?.(nowIso());
|
|
20
49
|
return store.listApiKeys(userId)
|
|
50
|
+
.filter((record) => record.userId === userId && record.kind === "user")
|
|
21
51
|
.filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
|
|
22
52
|
.map(summarize);
|
|
23
53
|
}
|
|
24
54
|
|
|
25
|
-
|
|
55
|
+
function createApiKeyRecord(
|
|
56
|
+
user: CurrentUser,
|
|
57
|
+
input: ApiKeyCreationOptions,
|
|
58
|
+
kind: ApiKeyRecord["kind"],
|
|
59
|
+
managedBy?: string,
|
|
60
|
+
): { record: ApiKeyRecord; token: string } {
|
|
26
61
|
const prefix = input.prefix ?? "wapp";
|
|
27
62
|
const token = `${prefix}_${randomToken(32)}`;
|
|
28
|
-
const record = {
|
|
63
|
+
const record: ApiKeyRecord = {
|
|
29
64
|
id: crypto.randomUUID(),
|
|
30
65
|
userId: user.id,
|
|
31
66
|
name: input.name?.trim() || "API key",
|
|
@@ -34,15 +69,53 @@ export function createApiKey(store: WebAppStore, user: CurrentUser, input: { nam
|
|
|
34
69
|
scopes: input.scopes?.length ? input.scopes : ["*"],
|
|
35
70
|
createdAt: nowIso(),
|
|
36
71
|
expiresAt: input.expiresAt,
|
|
72
|
+
kind,
|
|
73
|
+
managedBy,
|
|
37
74
|
};
|
|
75
|
+
return { record, token };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createApiKey(store: WebAppStore, user: CurrentUser, input: ApiKeyCreationOptions): CreatedApiKeyResponse {
|
|
79
|
+
const { record, token } = createApiKeyRecord(user, input, "user");
|
|
38
80
|
store.saveApiKey(record);
|
|
39
81
|
return { key: summarize(record), token };
|
|
40
82
|
}
|
|
41
83
|
|
|
42
84
|
export function deleteApiKey(store: WebAppStore, userId: string, id: string): boolean {
|
|
85
|
+
const record = store.listApiKeys(userId).find((candidate) => candidate.id === id && candidate.userId === userId);
|
|
86
|
+
if (!record || record.kind !== "user") {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
43
89
|
return store.deleteApiKey(id, userId);
|
|
44
90
|
}
|
|
45
91
|
|
|
92
|
+
export function createManagedApiKey(store: WebAppStore, user: CurrentUser, input: ManagedApiKeyCreationOptions = {}): CreatedManagedApiKeyResponse {
|
|
93
|
+
const { record, token } = createApiKeyRecord(user, input, "managed", input.managedBy);
|
|
94
|
+
store.saveApiKey(record);
|
|
95
|
+
return { key: summarizeManaged(record), token };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function listManagedApiKeys(store: WebAppStore, userId: string, managedBy?: string): ManagedApiKeySummary[] {
|
|
99
|
+
store.deleteExpiredApiKeys?.(nowIso());
|
|
100
|
+
return store.listApiKeys(userId)
|
|
101
|
+
.filter((record) => record.userId === userId && record.kind === "managed")
|
|
102
|
+
.filter((record) => managedBy === undefined || record.managedBy === managedBy)
|
|
103
|
+
.filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
|
|
104
|
+
.map(summarizeManaged);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function revokeManagedApiKey(store: WebAppStore, id: string, userId?: string): boolean {
|
|
108
|
+
const record = store.listApiKeys(userId).find((candidate) =>
|
|
109
|
+
candidate.id === id
|
|
110
|
+
&& candidate.kind === "managed"
|
|
111
|
+
&& (userId === undefined || candidate.userId === userId)
|
|
112
|
+
);
|
|
113
|
+
if (!record) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return store.deleteApiKey(id, record.userId);
|
|
117
|
+
}
|
|
118
|
+
|
|
46
119
|
export function authenticateApiKey(store: WebAppStore, token: string): { user: CurrentUser; apiKeyId: string; scopes: string[] } | undefined {
|
|
47
120
|
const tokenHash = sha256(token);
|
|
48
121
|
const record = store.getApiKeyByHash(tokenHash);
|
|
@@ -53,7 +126,7 @@ export function authenticateApiKey(store: WebAppStore, token: string): { user: C
|
|
|
53
126
|
return undefined;
|
|
54
127
|
}
|
|
55
128
|
const user = store.getUserById(record.userId);
|
|
56
|
-
if (!user) {
|
|
129
|
+
if (!user || user.disabledAt) {
|
|
57
130
|
return undefined;
|
|
58
131
|
}
|
|
59
132
|
store.touchApiKey(record.id, nowIso());
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
UserSetupLinkRecord,
|
|
12
12
|
WebAppStore,
|
|
13
13
|
} from "./store";
|
|
14
|
-
import type { LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
|
|
14
|
+
import type { ApiKeyKind, LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
|
|
15
15
|
import { createUserRecord } from "./users";
|
|
16
16
|
|
|
17
17
|
type Row = Record<string, unknown>;
|
|
@@ -132,6 +132,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
132
132
|
created_at TEXT NOT NULL,
|
|
133
133
|
last_used_at TEXT,
|
|
134
134
|
expires_at TEXT,
|
|
135
|
+
kind TEXT NOT NULL DEFAULT 'user',
|
|
136
|
+
managed_by TEXT,
|
|
135
137
|
FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE
|
|
136
138
|
);
|
|
137
139
|
CREATE TABLE IF NOT EXISTS webapp_device_auth_requests (
|
|
@@ -175,6 +177,19 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
175
177
|
`);
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
function migrateApiKeySchema(): void {
|
|
181
|
+
if (!tableExists("webapp_api_keys")) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!hasColumn("webapp_api_keys", "kind")) {
|
|
185
|
+
db.exec("ALTER TABLE webapp_api_keys ADD COLUMN kind TEXT NOT NULL DEFAULT 'user';");
|
|
186
|
+
}
|
|
187
|
+
if (!hasColumn("webapp_api_keys", "managed_by")) {
|
|
188
|
+
db.exec("ALTER TABLE webapp_api_keys ADD COLUMN managed_by TEXT;");
|
|
189
|
+
}
|
|
190
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_webapp_api_keys_managed ON webapp_api_keys(user_id, kind, managed_by);");
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
function migrateSingleUserSchema(): void {
|
|
179
194
|
const legacySources = [
|
|
180
195
|
["webapp_preferences", tableExists("webapp_preferences") && !hasColumn("webapp_preferences", "user_id")],
|
|
@@ -277,6 +292,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
277
292
|
function initialize(): void {
|
|
278
293
|
migrateSingleUserSchema();
|
|
279
294
|
createSchema();
|
|
295
|
+
migrateApiKeySchema();
|
|
280
296
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
281
297
|
}
|
|
282
298
|
|
|
@@ -356,6 +372,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
356
372
|
}
|
|
357
373
|
|
|
358
374
|
function mapApiKey(row: Row): ApiKeyRecord {
|
|
375
|
+
const kind: ApiKeyKind = row["kind"] === "managed" ? "managed" : "user";
|
|
359
376
|
return {
|
|
360
377
|
id: text(row["id"]),
|
|
361
378
|
userId: text(row["user_id"]),
|
|
@@ -366,6 +383,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
366
383
|
createdAt: text(row["created_at"]),
|
|
367
384
|
lastUsedAt: optionalText(row["last_used_at"]),
|
|
368
385
|
expiresAt: optionalText(row["expires_at"]),
|
|
386
|
+
kind,
|
|
387
|
+
managedBy: optionalText(row["managed_by"]),
|
|
369
388
|
};
|
|
370
389
|
}
|
|
371
390
|
|
|
@@ -544,9 +563,22 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
544
563
|
},
|
|
545
564
|
saveApiKey: (record) => {
|
|
546
565
|
db.query(`
|
|
547
|
-
INSERT INTO webapp_api_keys
|
|
548
|
-
|
|
549
|
-
|
|
566
|
+
INSERT INTO webapp_api_keys
|
|
567
|
+
(id, user_id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at, kind, managed_by)
|
|
568
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
569
|
+
`).run(
|
|
570
|
+
record.id,
|
|
571
|
+
record.userId,
|
|
572
|
+
record.name,
|
|
573
|
+
record.prefix,
|
|
574
|
+
record.tokenHash,
|
|
575
|
+
JSON.stringify(record.scopes),
|
|
576
|
+
record.createdAt,
|
|
577
|
+
record.lastUsedAt ?? null,
|
|
578
|
+
record.expiresAt ?? null,
|
|
579
|
+
record.kind,
|
|
580
|
+
record.managedBy ?? null,
|
|
581
|
+
);
|
|
550
582
|
},
|
|
551
583
|
touchApiKey: (id, lastUsedAt) => {
|
|
552
584
|
db.query("UPDATE webapp_api_keys SET last_used_at = ? WHERE id = ?").run(lastUsedAt, id);
|
package/src/server/auth/store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
1
|
+
import type { ApiKeyKind, ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
2
2
|
|
|
3
3
|
export interface UserRecord extends WebAppUserSummary {
|
|
4
4
|
authVersion: number;
|
|
@@ -36,6 +36,8 @@ export interface StoredPasskey {
|
|
|
36
36
|
export interface ApiKeyRecord extends ApiKeySummary {
|
|
37
37
|
userId: string;
|
|
38
38
|
tokenHash: string;
|
|
39
|
+
kind: ApiKeyKind;
|
|
40
|
+
managedBy?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface DeviceAuthRequestRecord {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { createAuthentication } from "./authentication";
|
|
12
12
|
import { createFrameworkEndpointHandler } from "./framework-endpoints";
|
|
13
13
|
import { createServerLifecycle } from "./server-lifecycle";
|
|
14
|
-
import { setLogLevel } from "./logger";
|
|
14
|
+
import { inMemoryLogStorage, resetInMemoryLogStorage, setLogLevel } from "./logger";
|
|
15
15
|
import { createWebDocumentProvider, htmlResponse } from "./web-document";
|
|
16
16
|
import { createPublicRouteDispatcher } from "./public-route-dispatch";
|
|
17
17
|
import { createRouteDispatcher } from "./route-dispatch";
|
|
@@ -45,6 +45,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
45
45
|
if (config.appName !== input.appName || config.envPrefix !== input.envPrefix) {
|
|
46
46
|
throw new Error("runtimeConfig appName and envPrefix must match the createWebAppServer inputs");
|
|
47
47
|
}
|
|
48
|
+
resetInMemoryLogStorage();
|
|
48
49
|
const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
|
|
49
50
|
store.initialize();
|
|
50
51
|
const savedLogLevel = store.getLogLevelPreference();
|
|
@@ -80,6 +81,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
80
81
|
deviceAuthEnabled,
|
|
81
82
|
configResponse: input.configResponse,
|
|
82
83
|
onLogLevelChange: input.logLevel?.onChange,
|
|
84
|
+
inMemoryLogs: inMemoryLogStorage,
|
|
83
85
|
ensureWebDocument,
|
|
84
86
|
});
|
|
85
87
|
const publicRouteDispatcher = createPublicRouteDispatcher({
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
createUserRequestSchema,
|
|
49
49
|
deviceAuthorizationRequestSchema,
|
|
50
50
|
deviceCodeActionRequestSchema,
|
|
51
|
+
inMemoryLogSettingsRequestSchema,
|
|
51
52
|
logLevelPreferenceRequestSchema,
|
|
52
53
|
passkeyBootstrapOptionsSchema,
|
|
53
54
|
refreshTokenRequestSchema,
|
|
@@ -61,7 +62,8 @@ import {
|
|
|
61
62
|
} from "./request-schemas";
|
|
62
63
|
import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
|
|
63
64
|
import { setLogLevel } from "./logger";
|
|
64
|
-
import {
|
|
65
|
+
import type { InMemoryLogStorage } from "./logger";
|
|
66
|
+
import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse } from "./responses";
|
|
65
67
|
import type { WebSocketData } from "./realtime/bus";
|
|
66
68
|
|
|
67
69
|
export interface FrameworkEndpointDependencies {
|
|
@@ -75,6 +77,7 @@ export interface FrameworkEndpointDependencies {
|
|
|
75
77
|
deviceAuthEnabled: boolean;
|
|
76
78
|
configResponse?: WebAppServerConfig["configResponse"];
|
|
77
79
|
onLogLevelChange?: (level: LogLevelName) => void;
|
|
80
|
+
inMemoryLogs: InMemoryLogStorage;
|
|
78
81
|
ensureWebDocument: () => Promise<WebDocument>;
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -97,6 +100,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
97
100
|
deviceAuthEnabled,
|
|
98
101
|
configResponse: extendConfigResponse,
|
|
99
102
|
onLogLevelChange,
|
|
103
|
+
inMemoryLogs,
|
|
100
104
|
ensureWebDocument,
|
|
101
105
|
} = dependencies;
|
|
102
106
|
|
|
@@ -115,6 +119,9 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
115
119
|
level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
|
|
116
120
|
fromEnv: config.logLevelFromEnv,
|
|
117
121
|
},
|
|
122
|
+
inMemoryLogs: {
|
|
123
|
+
enabled: inMemoryLogs.isEnabled(),
|
|
124
|
+
},
|
|
118
125
|
apiKeys: { enabled: Boolean(apiKeysEnabled) },
|
|
119
126
|
deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
|
|
120
127
|
} satisfies WebAppConfigResponse;
|
|
@@ -386,6 +393,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
386
393
|
const target = store.getUserById(userId);
|
|
387
394
|
if (!target) return notFound();
|
|
388
395
|
if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
|
|
396
|
+
store.deleteApiKeysForUser(userId);
|
|
389
397
|
if (!store.deleteUser(userId)) return notFound();
|
|
390
398
|
audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
|
|
391
399
|
return successResponse();
|
|
@@ -432,6 +440,32 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
432
440
|
return successResponse({ level: body.level });
|
|
433
441
|
}
|
|
434
442
|
}
|
|
443
|
+
if (path === "/api/server/logs") {
|
|
444
|
+
const auth = await authentication.authorize(req, true);
|
|
445
|
+
if (auth instanceof Response) return auth;
|
|
446
|
+
ensureAdmin(auth);
|
|
447
|
+
if (req.method !== "GET") {
|
|
448
|
+
return methodNotAllowed();
|
|
449
|
+
}
|
|
450
|
+
const enabled = inMemoryLogs.isEnabled();
|
|
451
|
+
return jsonResponse({
|
|
452
|
+
enabled,
|
|
453
|
+
logs: enabled ? inMemoryLogs.getEntries() : [],
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (path === "/api/server/logs/settings") {
|
|
457
|
+
const auth = await authentication.authorize(req, true);
|
|
458
|
+
if (auth instanceof Response) return auth;
|
|
459
|
+
ensureAdmin(auth);
|
|
460
|
+
if (req.method !== "PUT") {
|
|
461
|
+
return methodNotAllowed();
|
|
462
|
+
}
|
|
463
|
+
const originFailure = checkSameOrigin(req, config, auth, "mutations");
|
|
464
|
+
if (originFailure) return originFailure;
|
|
465
|
+
const body = await parseJson(req, inMemoryLogSettingsRequestSchema);
|
|
466
|
+
inMemoryLogs.setEnabled(body.enabled);
|
|
467
|
+
return successResponse({ enabled: inMemoryLogs.isEnabled() });
|
|
468
|
+
}
|
|
435
469
|
if (path === "/api/server/kill" && req.method === "POST") {
|
|
436
470
|
const auth = await authentication.authorize(req, true);
|
|
437
471
|
if (auth instanceof Response) return auth;
|
package/src/server/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./responses";
|
|
|
5
5
|
export * from "./public-assets";
|
|
6
6
|
export * from "./create-web-app-server";
|
|
7
7
|
export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
|
|
8
|
+
export * from "./auth/api-keys";
|
|
8
9
|
export * from "./auth/store";
|
|
9
10
|
export * from "./auth/sqlite-store";
|
|
10
11
|
export * from "./realtime/bus";
|
package/src/server/logger.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LogLevelName } from "../contracts";
|
|
1
|
+
import type { LogLevelName, ServerLogEntry } from "../contracts";
|
|
2
2
|
|
|
3
3
|
const ORDER: Record<LogLevelName, number> = {
|
|
4
4
|
trace: 10,
|
|
@@ -8,7 +8,21 @@ const ORDER: Record<LogLevelName, number> = {
|
|
|
8
8
|
error: 50,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
export const MAX_IN_MEMORY_LOG_ENTRIES = 1_000;
|
|
12
|
+
export const MAX_IN_MEMORY_LOG_BYTES = 512 * 1024;
|
|
13
|
+
|
|
11
14
|
let currentLevel: LogLevelName = "info";
|
|
15
|
+
let inMemoryLogStorageEnabled = false;
|
|
16
|
+
let inMemoryLogEntries: ServerLogEntry[] = [];
|
|
17
|
+
let inMemoryLogBytes = 0;
|
|
18
|
+
const textEncoder = new TextEncoder();
|
|
19
|
+
|
|
20
|
+
export interface InMemoryLogStorage {
|
|
21
|
+
isEnabled(): boolean;
|
|
22
|
+
setEnabled(enabled: boolean): void;
|
|
23
|
+
getEntries(): ServerLogEntry[];
|
|
24
|
+
reset(): void;
|
|
25
|
+
}
|
|
12
26
|
|
|
13
27
|
export function setLogLevel(level: LogLevelName): void {
|
|
14
28
|
currentLevel = level;
|
|
@@ -18,6 +32,58 @@ export function getLogLevel(): LogLevelName {
|
|
|
18
32
|
return currentLevel;
|
|
19
33
|
}
|
|
20
34
|
|
|
35
|
+
function lineByteLength(line: string): number {
|
|
36
|
+
return textEncoder.encode(line).byteLength;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendInMemoryLogEntry(entry: ServerLogEntry): void {
|
|
40
|
+
if (!inMemoryLogStorageEnabled) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const entryBytes = lineByteLength(entry.line);
|
|
44
|
+
if (entryBytes > MAX_IN_MEMORY_LOG_BYTES) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
inMemoryLogEntries.push(entry);
|
|
48
|
+
inMemoryLogBytes += entryBytes;
|
|
49
|
+
while (inMemoryLogEntries.length > MAX_IN_MEMORY_LOG_ENTRIES || inMemoryLogBytes > MAX_IN_MEMORY_LOG_BYTES) {
|
|
50
|
+
const removed = inMemoryLogEntries.shift();
|
|
51
|
+
if (!removed) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
inMemoryLogBytes -= lineByteLength(removed.line);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setInMemoryLogStorageEnabled(enabled: boolean): void {
|
|
59
|
+
inMemoryLogStorageEnabled = enabled;
|
|
60
|
+
if (!enabled) {
|
|
61
|
+
inMemoryLogEntries = [];
|
|
62
|
+
inMemoryLogBytes = 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isInMemoryLogStorageEnabled(): boolean {
|
|
67
|
+
return inMemoryLogStorageEnabled;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getInMemoryLogEntries(): ServerLogEntry[] {
|
|
71
|
+
return inMemoryLogEntries.map((entry) => ({ ...entry }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resetInMemoryLogStorage(): void {
|
|
75
|
+
inMemoryLogStorageEnabled = false;
|
|
76
|
+
inMemoryLogEntries = [];
|
|
77
|
+
inMemoryLogBytes = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const inMemoryLogStorage: InMemoryLogStorage = {
|
|
81
|
+
isEnabled: isInMemoryLogStorageEnabled,
|
|
82
|
+
setEnabled: setInMemoryLogStorageEnabled,
|
|
83
|
+
getEntries: getInMemoryLogEntries,
|
|
84
|
+
reset: resetInMemoryLogStorage,
|
|
85
|
+
};
|
|
86
|
+
|
|
21
87
|
export function createLogger(scope: string) {
|
|
22
88
|
function log(level: LogLevelName, message: string, fields?: Record<string, unknown>): void {
|
|
23
89
|
if (ORDER[level] < ORDER[currentLevel]) {
|
|
@@ -26,6 +92,7 @@ export function createLogger(scope: string) {
|
|
|
26
92
|
const timestamp = new Date().toISOString();
|
|
27
93
|
const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
|
|
28
94
|
const line = `${timestamp}\t${level.toUpperCase()}\t${scope}\t${message}${suffix}`;
|
|
95
|
+
appendInMemoryLogEntry({ timestamp, level, scope, message, line });
|
|
29
96
|
if (level === "error") {
|
|
30
97
|
console.error(line);
|
|
31
98
|
} else if (level === "warn") {
|
|
@@ -142,3 +142,7 @@ export const themePreferenceRequestSchema = z.object({
|
|
|
142
142
|
export const logLevelPreferenceRequestSchema = z.object({
|
|
143
143
|
level: z.enum(["trace", "debug", "info", "warn", "error"]),
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
export const inMemoryLogSettingsRequestSchema = z.object({
|
|
147
|
+
enabled: z.boolean(),
|
|
148
|
+
});
|
|
@@ -252,6 +252,18 @@ export function SelectField({ label, children, ...props }: SelectHTMLAttributes<
|
|
|
252
252
|
);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
export function CheckboxField({ label, hint, ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; hint?: string }) {
|
|
256
|
+
return (
|
|
257
|
+
<label className="wapp-checkbox-field">
|
|
258
|
+
<input {...props} type="checkbox" />
|
|
259
|
+
<span>
|
|
260
|
+
{label}
|
|
261
|
+
{hint ? <small>{hint}</small> : null}
|
|
262
|
+
</span>
|
|
263
|
+
</label>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
255
267
|
export function SegmentedControl<T extends string>({ value, options, onChange, label }: { value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void; label: string }) {
|
|
256
268
|
return (
|
|
257
269
|
<div className="wapp-segmented" role="group" aria-label={label}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
|
|
3
3
|
import { appJson } from "../api-client";
|
|
4
|
-
import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
|
|
4
|
+
import { Badge, Button, CheckboxField, ErrorState, FormSection, LoadingState, SelectField } from "../components";
|
|
5
5
|
import { useLogLevel } from "../log-level";
|
|
6
6
|
import { isThemePreference, useTheme } from "../theme";
|
|
7
7
|
|
|
@@ -21,6 +21,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
21
21
|
const { preference, setPreference, loading, error, retry } = useTheme();
|
|
22
22
|
const logLevel = useLogLevel();
|
|
23
23
|
const [logLevelSaving, setLogLevelSaving] = useState(false);
|
|
24
|
+
const [inMemoryLogsSaving, setInMemoryLogsSaving] = useState(false);
|
|
24
25
|
|
|
25
26
|
async function updateLogLevel(value: string) {
|
|
26
27
|
if (!isLogLevelName(value)) {
|
|
@@ -39,6 +40,22 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
async function updateInMemoryLogs(enabled: boolean) {
|
|
44
|
+
try {
|
|
45
|
+
setError(undefined);
|
|
46
|
+
setInMemoryLogsSaving(true);
|
|
47
|
+
await appJson<unknown>("/api/server/logs/settings", {
|
|
48
|
+
method: "PUT",
|
|
49
|
+
body: JSON.stringify({ enabled }),
|
|
50
|
+
});
|
|
51
|
+
await refresh();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
54
|
+
} finally {
|
|
55
|
+
setInMemoryLogsSaving(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
return (
|
|
43
60
|
<>
|
|
44
61
|
{config.currentUser ? (
|
|
@@ -88,6 +105,13 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
88
105
|
{LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
|
|
89
106
|
</SelectField>
|
|
90
107
|
) : null}
|
|
108
|
+
<CheckboxField
|
|
109
|
+
label="Store server logs in memory"
|
|
110
|
+
hint="Temporary; disabled and cleared when the server process starts or this setting is turned off."
|
|
111
|
+
checked={config.inMemoryLogs.enabled}
|
|
112
|
+
disabled={inMemoryLogsSaving}
|
|
113
|
+
onChange={(event) => void updateInMemoryLogs(event.currentTarget.checked)}
|
|
114
|
+
/>
|
|
91
115
|
</FormSection>
|
|
92
116
|
) : null}
|
|
93
117
|
</>
|
package/src/web/styles.css
CHANGED
|
@@ -1618,6 +1618,32 @@ textarea::placeholder {
|
|
|
1618
1618
|
resize: vertical;
|
|
1619
1619
|
}
|
|
1620
1620
|
|
|
1621
|
+
.wapp-checkbox-field {
|
|
1622
|
+
display: flex;
|
|
1623
|
+
align-items: flex-start;
|
|
1624
|
+
gap: 0.5rem;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
.wapp-checkbox-field input {
|
|
1628
|
+
width: 1rem;
|
|
1629
|
+
min-height: 1rem;
|
|
1630
|
+
margin-top: 0.125rem;
|
|
1631
|
+
flex: 0 0 auto;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
.wapp-checkbox-field span {
|
|
1635
|
+
font-size: 0.8125rem;
|
|
1636
|
+
font-weight: 500;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
.wapp-checkbox-field small {
|
|
1640
|
+
display: block;
|
|
1641
|
+
color: var(--wapp-muted);
|
|
1642
|
+
font-size: 0.75rem;
|
|
1643
|
+
font-weight: 400;
|
|
1644
|
+
margin-top: 0.25rem;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1621
1647
|
.wapp-field small {
|
|
1622
1648
|
display: block;
|
|
1623
1649
|
color: var(--wapp-muted);
|
|
@@ -50,6 +50,7 @@ function isWebAppConfigResponse(value: unknown): value is WebAppConfigResponse {
|
|
|
50
50
|
&& isRecord(value["logLevel"])
|
|
51
51
|
&& isLogLevelName(value["logLevel"]["level"])
|
|
52
52
|
&& typeof value["logLevel"]["fromEnv"] === "boolean"
|
|
53
|
+
&& hasBooleanFields(value["inMemoryLogs"], ["enabled"])
|
|
53
54
|
&& hasBooleanFields(value["deviceAuth"], ["enabled"])
|
|
54
55
|
&& hasBooleanFields(value["apiKeys"], ["enabled"]);
|
|
55
56
|
}
|