@flaghoist/server 0.3.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.
- package/README.md +23 -3
- package/dist/dashboard.cjs +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/index.cjs +3545 -566
- package/dist/index.d.cts +309 -2
- package/dist/index.d.ts +309 -2
- package/dist/index.js +3540 -563
- package/package.json +3 -3
package/dist/index.d.cts
CHANGED
|
@@ -1,8 +1,182 @@
|
|
|
1
1
|
import * as hono_types from 'hono/types';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import { StorageAdapter, AttributeValue } from '@flaghoist/core';
|
|
3
|
+
import { StorageAdapter, AttributeValue, AuditEntry, AuditListOptions, AuditPage, FlagWebhookEvent, FlagSnapshot, MemberWebhookEvent } from '@flaghoist/core';
|
|
4
|
+
export { AuditEntry, AuditPage, FlagSnapshot } from '@flaghoist/core';
|
|
4
5
|
import { JWTVerifyGetKey } from 'jose';
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Admin roles, lowest to highest. Roles are hierarchical: each one can do everything the roles
|
|
9
|
+
* below it can.
|
|
10
|
+
*/
|
|
11
|
+
declare const ROLES: readonly ["viewer", "editor", "admin", "owner"];
|
|
12
|
+
type Role = (typeof ROLES)[number];
|
|
13
|
+
/** Something an admin request can do. Each admin route requires exactly one. */
|
|
14
|
+
type Permission = 'flags:read' | 'flags:write' | 'flags:delete' | 'flags:import' | 'audit:read' | 'audit:security' | 'webhooks:manage' | 'members:manage';
|
|
15
|
+
declare function minimumRole(permission: Permission): Role;
|
|
16
|
+
/** Whether `role` holds `permission`. Anything that is not a known role holds nothing. */
|
|
17
|
+
declare function can(role: unknown, permission: Permission): boolean;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Optional email for invites and password reset links. Flaghoist ships no email provider: you
|
|
21
|
+
* pass a small object that sends a message however you like (Resend, Postmark, Amazon SES,
|
|
22
|
+
* Cloudflare Email, your own SMTP relay behind an HTTP API). Without one, the dashboard and CLI
|
|
23
|
+
* show the link for you to pass on yourself, as before.
|
|
24
|
+
*/
|
|
25
|
+
interface EmailMessage {
|
|
26
|
+
to: string;
|
|
27
|
+
subject: string;
|
|
28
|
+
text: string;
|
|
29
|
+
html: string;
|
|
30
|
+
}
|
|
31
|
+
interface EmailSender {
|
|
32
|
+
/** Send one message. Throw on failure; Flaghoist logs it and still returns the link. */
|
|
33
|
+
send(message: EmailMessage): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Single sign-on with an OpenID Connect provider (Okta, Microsoft Entra, Google, Auth0, Keycloak).
|
|
38
|
+
* The authorization code flow with PKCE, run by the server so a client secret never reaches the
|
|
39
|
+
* browser. Nothing is stored between the redirect out and the redirect back: what the callback
|
|
40
|
+
* needs travels in the `state` parameter, encrypted and authenticated with a key derived from the
|
|
41
|
+
* pepper. That avoids cookies, which Flaghoist does not use, and Cloudflare KV's replication delay.
|
|
42
|
+
*/
|
|
43
|
+
interface SsoConfig {
|
|
44
|
+
/** The provider's issuer URL, exactly as it appears in its ID tokens. */
|
|
45
|
+
issuer: string;
|
|
46
|
+
clientId: string;
|
|
47
|
+
/** For a confidential client. Sent with HTTP Basic auth. Omit for a public client using PKCE. */
|
|
48
|
+
clientSecret?: string;
|
|
49
|
+
/** The name on the sign-in button, as in "Continue with Okta". Default `SSO`. */
|
|
50
|
+
label?: string;
|
|
51
|
+
/** Default `['openid', 'email', 'profile']`. Add your provider's groups scope if it needs one. */
|
|
52
|
+
scopes?: string[];
|
|
53
|
+
/** Only these email domains may sign in. Omit to allow any the provider vouches for. */
|
|
54
|
+
allowedDomains?: string[];
|
|
55
|
+
/** The ID token claim holding the person's groups. Default `groups`. */
|
|
56
|
+
groupsClaim?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Group to role. When set, the provider decides roles: they are applied at every sign-in and
|
|
59
|
+
* cannot be changed in Flaghoist. Someone in several mapped groups gets the highest role.
|
|
60
|
+
*/
|
|
61
|
+
roleMapping?: Record<string, Role>;
|
|
62
|
+
/**
|
|
63
|
+
* The role for someone in no mapped group. Omit to refuse them. Without `roleMapping`, this is
|
|
64
|
+
* the role new people get on their first sign-in.
|
|
65
|
+
*/
|
|
66
|
+
defaultRole?: Role;
|
|
67
|
+
/** Require `email_verified: true` before trusting the email. Default true. See the docs for Entra. */
|
|
68
|
+
requireVerifiedEmail?: boolean;
|
|
69
|
+
/** Set false to turn off password sign-in for everyone. The admin token still works. */
|
|
70
|
+
passwordSignIn?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The callback URL registered with the provider. Default: this server's own origin plus
|
|
73
|
+
* `/api/v1/auth/sso/callback`. Set it when a proxy changes the origin the server sees.
|
|
74
|
+
*/
|
|
75
|
+
redirectUri?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* User accounts, password sign-in and server sessions. Everything here is stored through the
|
|
80
|
+
* adapter's generic record store, so accounts live in whatever storage the deployment already uses.
|
|
81
|
+
*
|
|
82
|
+
* Passwords never reach the server. The client stretches the password with PBKDF2 and sends the
|
|
83
|
+
* result (the "client key"); the server keys that with a secret pepper and stores only the HMAC.
|
|
84
|
+
* The slow hash therefore costs the client about 100 ms once per sign-in, and the server two HMACs,
|
|
85
|
+
* which keeps sign-in inside the 10 ms CPU budget of the Cloudflare Workers free plan.
|
|
86
|
+
*/
|
|
87
|
+
interface UsersConfig {
|
|
88
|
+
/**
|
|
89
|
+
* Server secret that keys every stored password verifier, at least 32 characters (for example
|
|
90
|
+
* `openssl rand -hex 32`). Keep it out of the storage backend and back it up: a database leak
|
|
91
|
+
* without the pepper reveals nothing useful, but losing the pepper invalidates every password.
|
|
92
|
+
*/
|
|
93
|
+
pepper: string;
|
|
94
|
+
/** A short label stored with each verifier, so the pepper can be rotated later. Default `p1`. */
|
|
95
|
+
pepperId?: string;
|
|
96
|
+
session?: {
|
|
97
|
+
/** Sign a session out after this many minutes without a request. Default 30. */
|
|
98
|
+
idleMinutes?: number;
|
|
99
|
+
/** Sign a session out this many hours after sign-in, active or not. Default 12. */
|
|
100
|
+
maxHours?: number;
|
|
101
|
+
};
|
|
102
|
+
invites?: {
|
|
103
|
+
/** How long an invite link stays valid. Default 7 days. Password reset links last 24 hours. */
|
|
104
|
+
expiresInDays?: number;
|
|
105
|
+
};
|
|
106
|
+
/** Sign in with an OpenID Connect provider as well as, or instead of, a password. */
|
|
107
|
+
sso?: SsoConfig;
|
|
108
|
+
/**
|
|
109
|
+
* Who must use a two-factor code with their password: `optional` (the default), `admins` (admins
|
|
110
|
+
* and owners) or `everyone`. Someone who must but has not set it up is asked to at their next
|
|
111
|
+
* sign-in and can do nothing else until they have. SSO sign-ins are exempt; the identity
|
|
112
|
+
* provider's own two-factor covers them.
|
|
113
|
+
*/
|
|
114
|
+
twoFactor?: 'optional' | 'admins' | 'everyone';
|
|
115
|
+
/**
|
|
116
|
+
* Email invites and password reset links as well as showing them. Flaghoist bundles no
|
|
117
|
+
* provider: pass an object with `send({ to, subject, text, html })`. See the docs for Resend and
|
|
118
|
+
* Postmark examples.
|
|
119
|
+
*/
|
|
120
|
+
email?: EmailSender;
|
|
121
|
+
}
|
|
122
|
+
type UserStatus = 'active' | 'disabled';
|
|
123
|
+
/** What the API returns for a user: never the verifier. */
|
|
124
|
+
interface PublicUser {
|
|
125
|
+
id: string;
|
|
126
|
+
email: string;
|
|
127
|
+
name: string;
|
|
128
|
+
role: Role;
|
|
129
|
+
status: UserStatus;
|
|
130
|
+
/** Whether the account has a password. SSO-only accounts do not. */
|
|
131
|
+
hasPassword: boolean;
|
|
132
|
+
/** Whether the account has signed in with SSO. */
|
|
133
|
+
sso?: boolean;
|
|
134
|
+
/** Set when the SSO provider decides the role, so it cannot be changed here. */
|
|
135
|
+
roleManagedBy?: 'sso';
|
|
136
|
+
/** Whether the account uses two-factor codes. */
|
|
137
|
+
twoFactor: boolean;
|
|
138
|
+
/** Roles that differ from `role` in particular environments. */
|
|
139
|
+
environmentRoles?: Record<string, Role>;
|
|
140
|
+
createdAt: string;
|
|
141
|
+
lastLoginAt?: string;
|
|
142
|
+
}
|
|
143
|
+
/** An invite to join, or a link to set a new password. Stored under the hash of its token. */
|
|
144
|
+
interface InviteRecord {
|
|
145
|
+
id: string;
|
|
146
|
+
kind: 'invite' | 'reset';
|
|
147
|
+
email: string;
|
|
148
|
+
role: Role;
|
|
149
|
+
/** The account a reset link is for. Absent on invites, whose account does not exist yet. */
|
|
150
|
+
userId?: string;
|
|
151
|
+
invitedBy: string;
|
|
152
|
+
createdAt: string;
|
|
153
|
+
expiresAt: string;
|
|
154
|
+
}
|
|
155
|
+
type PublicInvite = Omit<InviteRecord, 'userId'>;
|
|
156
|
+
/** A personal access token. Stored under the hash of the token itself. */
|
|
157
|
+
interface TokenRecord {
|
|
158
|
+
id: string;
|
|
159
|
+
userId: string;
|
|
160
|
+
name: string;
|
|
161
|
+
/** The most this token may do. Its owner's current role caps it further on every request. */
|
|
162
|
+
role: Role;
|
|
163
|
+
/** The first characters of the token, so people can tell their tokens apart. */
|
|
164
|
+
prefix: string;
|
|
165
|
+
createdAt: string;
|
|
166
|
+
/** Absent means the token never expires. */
|
|
167
|
+
expiresAt?: string;
|
|
168
|
+
lastUsedAt?: string;
|
|
169
|
+
}
|
|
170
|
+
type PublicToken = Omit<TokenRecord, 'userId'>;
|
|
171
|
+
interface PublicSession {
|
|
172
|
+
id: string;
|
|
173
|
+
createdAt: string;
|
|
174
|
+
lastSeenAt: string;
|
|
175
|
+
expiresAt: string;
|
|
176
|
+
userAgent?: string;
|
|
177
|
+
current: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
6
180
|
/**
|
|
7
181
|
* Rate limiting for the server. Opt-in: with no `rateLimit` in the config the server behaves
|
|
8
182
|
* exactly as before, because a limiter with the wrong bucket key is worse than none, and only the
|
|
@@ -63,6 +237,18 @@ interface AuthResult {
|
|
|
63
237
|
ok: boolean;
|
|
64
238
|
/** Caller identity (email/subject/'api-key') recorded in audit metadata when ok. */
|
|
65
239
|
identity?: string;
|
|
240
|
+
/**
|
|
241
|
+
* The environment this credential is scoped to, when the read verifier is environment-aware
|
|
242
|
+
* (see `apiKeys()` in auth.ts). Absent means "not environment-specific": the request maps to
|
|
243
|
+
* the server's default environment.
|
|
244
|
+
*/
|
|
245
|
+
environment?: string;
|
|
246
|
+
/**
|
|
247
|
+
* The caller's admin role, on the admin path. Absent means `owner`, which is exactly the access
|
|
248
|
+
* every admin verifier granted before roles existed, so existing verifiers keep working
|
|
249
|
+
* unchanged. A value that is not a known role grants nothing.
|
|
250
|
+
*/
|
|
251
|
+
role?: Role;
|
|
66
252
|
/** HTTP status to return when not ok. */
|
|
67
253
|
status?: 401 | 403;
|
|
68
254
|
/** Public, non-sensitive error message when not ok. */
|
|
@@ -110,12 +296,56 @@ interface ServerConfig {
|
|
|
110
296
|
* set this to `false` and stop advertising its own API surface.
|
|
111
297
|
*/
|
|
112
298
|
exposeOpenApi?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Named environments this deployment serves (e.g. `['production', 'staging', 'development']`).
|
|
301
|
+
* When set, flags are partitioned per environment: the same key can be on in staging and off in
|
|
302
|
+
* production, each with its own audit trail. Admin requests choose one with the
|
|
303
|
+
* `X-Flaghoist-Environment` header (defaulting to `defaultEnvironment` when omitted); the read
|
|
304
|
+
* (OFREP) path is scoped by whichever environment the read credential maps to (see `apiKeys()`).
|
|
305
|
+
*
|
|
306
|
+
* Omit entirely to run with a single, unnamed environment exactly as before. No migration
|
|
307
|
+
* needed, and existing flags are unaffected.
|
|
308
|
+
*/
|
|
309
|
+
environments?: string[];
|
|
310
|
+
/**
|
|
311
|
+
* Which configured environment is the default: it uses bare storage keys and never stamps
|
|
312
|
+
* `environment` on a flag, so flags created before environments existed are already in it with
|
|
313
|
+
* zero migration. Default: `'production'`.
|
|
314
|
+
*/
|
|
315
|
+
defaultEnvironment?: string;
|
|
316
|
+
/**
|
|
317
|
+
* Turn on user accounts: people sign in to the dashboard with an email and password, get a role,
|
|
318
|
+
* and every change is recorded against their email. Needs a storage adapter with the record
|
|
319
|
+
* store (every bundled adapter has one) and a `pepper` secret. The `auth.admin` verifier keeps
|
|
320
|
+
* working as an Owner credential, for creating the first account and for recovery.
|
|
321
|
+
*
|
|
322
|
+
* Omit to keep the single shared admin token, exactly as before.
|
|
323
|
+
*/
|
|
324
|
+
users?: UsersConfig;
|
|
113
325
|
}
|
|
114
326
|
/** Config, or a function that derives it from the runtime environment (e.g. Workers bindings). */
|
|
115
327
|
type ConfigResolver<Env> = ServerConfig | ((env: Env) => ServerConfig);
|
|
116
328
|
|
|
329
|
+
interface AuditLog {
|
|
330
|
+
record(entry: Omit<AuditEntry, 'id' | 'timestamp'>): Promise<void>;
|
|
331
|
+
list(options?: AuditListOptions): Promise<AuditPage>;
|
|
332
|
+
}
|
|
333
|
+
|
|
117
334
|
/** Read-path verifier: matches the `x-api-key` header against a shared secret in constant time. */
|
|
118
335
|
declare function apiKey(expected: string): Authenticator;
|
|
336
|
+
/**
|
|
337
|
+
* Read-path verifier for multiple environments: each environment gets its own `x-api-key`
|
|
338
|
+
* secret, so a key scoped to staging cannot read production and a leaked staging key has no
|
|
339
|
+
* blast radius beyond it. The matched environment is attached to the `AuthResult` and flows
|
|
340
|
+
* through to `ServerConfig.environments` scoping; see `resolveReadEnvironment` in
|
|
341
|
+
* environments.ts.
|
|
342
|
+
*
|
|
343
|
+
* All configured keys are compared in parallel (rather than short-circuiting on first match) so
|
|
344
|
+
* the number of environments configured does not itself become a timing signal.
|
|
345
|
+
*
|
|
346
|
+
* @example apiKeys({ production: env.READ_KEY_PROD, staging: env.READ_KEY_STAGING })
|
|
347
|
+
*/
|
|
348
|
+
declare function apiKeys(keys: Record<string, string>): Authenticator;
|
|
119
349
|
/**
|
|
120
350
|
* Admin-path verifier: matches an `Authorization: Bearer <token>` against a shared secret in
|
|
121
351
|
* constant time. The zero-config default — possession of the token is admin authorization.
|
|
@@ -147,6 +377,41 @@ interface OidcOptions {
|
|
|
147
377
|
*/
|
|
148
378
|
declare function oidc(options: OidcOptions): Authenticator;
|
|
149
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Wrap a StorageAdapter so its flag methods (`get`/`put`/`delete`/`list`) are scoped to one
|
|
382
|
+
* environment. The default environment uses bare keys and never stamps `environment` on the
|
|
383
|
+
* flag, so a deployment that turns environments on for the first time needs no migration:
|
|
384
|
+
* existing flags are already the default environment. A named environment prefixes the storage
|
|
385
|
+
* key and stamps `environment` on write, so `list()`, which sees every key in the shared
|
|
386
|
+
* backend, can tell environments apart.
|
|
387
|
+
*
|
|
388
|
+
* Non-flag concerns (audit, webhooks) are deliberately not scoped here; they stay on the
|
|
389
|
+
* unscoped adapter passed to `createAuditLog`/`createWebhookStore` in index.ts.
|
|
390
|
+
*/
|
|
391
|
+
declare function scopedStorage(storage: StorageAdapter, environment: string, defaultEnvironment: string): StorageAdapter;
|
|
392
|
+
type EnvironmentResolution = {
|
|
393
|
+
ok: true;
|
|
394
|
+
environment: string;
|
|
395
|
+
} | {
|
|
396
|
+
ok: false;
|
|
397
|
+
status: 400;
|
|
398
|
+
message: string;
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Resolve which environment an admin request targets, from the `X-Flaghoist-Environment` header.
|
|
402
|
+
* When the server has no `environments` configured, the feature is fully off: every request maps
|
|
403
|
+
* to `defaultEnvironment` regardless of the header, so `scopedStorage` acts as a no-op passthrough
|
|
404
|
+
* and a deployment that never opts in sees no behavior change at all.
|
|
405
|
+
*/
|
|
406
|
+
declare function resolveAdminEnvironment(environments: string[] | undefined, defaultEnvironment: string, headers: Headers): EnvironmentResolution;
|
|
407
|
+
/**
|
|
408
|
+
* Resolve which environment a read (OFREP) request targets, from the environment the auth
|
|
409
|
+
* verifier attached to its result (see `apiKeys()` in auth.ts). A verifier that does not name an
|
|
410
|
+
* environment (the plain `apiKey()` single-secret verifier) always maps to the default
|
|
411
|
+
* environment, and so does any value outside the configured list.
|
|
412
|
+
*/
|
|
413
|
+
declare function resolveReadEnvironment(environments: string[] | undefined, defaultEnvironment: string, authEnvironment: string | undefined): string;
|
|
414
|
+
|
|
150
415
|
/**
|
|
151
416
|
* OpenAPI 3.1 description of the Flaghoist HTTP API. It is served at `GET /api/v1/openapi.json`
|
|
152
417
|
* and exported for tooling (`import { openApiDocument } from '@flaghoist/server'`).
|
|
@@ -157,6 +422,48 @@ declare function oidc(options: OidcOptions): Authenticator;
|
|
|
157
422
|
*/
|
|
158
423
|
declare const openApiDocument: Record<string, unknown>;
|
|
159
424
|
|
|
425
|
+
interface FlagWebhookPayload {
|
|
426
|
+
event: FlagWebhookEvent;
|
|
427
|
+
timestamp: string;
|
|
428
|
+
flag: {
|
|
429
|
+
key: string;
|
|
430
|
+
enabled: boolean;
|
|
431
|
+
rollout: {
|
|
432
|
+
percentage: number;
|
|
433
|
+
};
|
|
434
|
+
description: string;
|
|
435
|
+
};
|
|
436
|
+
actor: string;
|
|
437
|
+
previous?: FlagSnapshot;
|
|
438
|
+
/** The environment the change happened in. Absent means the default environment. */
|
|
439
|
+
environment?: string;
|
|
440
|
+
}
|
|
441
|
+
/** A team change. Only sent to webhooks that list the member event. */
|
|
442
|
+
interface MemberWebhookPayload {
|
|
443
|
+
event: MemberWebhookEvent;
|
|
444
|
+
timestamp: string;
|
|
445
|
+
actor: string;
|
|
446
|
+
member: {
|
|
447
|
+
/** Absent for `member.invited`: the account does not exist yet. */
|
|
448
|
+
id?: string;
|
|
449
|
+
email: string;
|
|
450
|
+
role: string;
|
|
451
|
+
status?: string;
|
|
452
|
+
environmentRoles?: Record<string, string>;
|
|
453
|
+
};
|
|
454
|
+
/** What changed from, on `member.role_changed`. */
|
|
455
|
+
previous?: {
|
|
456
|
+
role: string;
|
|
457
|
+
environmentRoles?: Record<string, string>;
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
type WebhookPayload = FlagWebhookPayload | MemberWebhookPayload;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* How the shared admin token appears in the audit log, flag metadata and webhooks once user
|
|
464
|
+
* accounts are on, so its changes stand out from those made by a named person.
|
|
465
|
+
*/
|
|
466
|
+
declare const ADMIN_TOKEN_IDENTITY = "admin token";
|
|
160
467
|
/**
|
|
161
468
|
* Build a Flaghoist server as a Hono app. Pass a config object, or a function that derives the
|
|
162
469
|
* config from the runtime environment (e.g. Cloudflare Workers bindings). The returned app has a
|
|
@@ -166,4 +473,4 @@ declare function createFlagServer<Env extends object = Record<string, unknown>>(
|
|
|
166
473
|
Bindings: Env;
|
|
167
474
|
}, hono_types.BlankSchema, "/">;
|
|
168
475
|
|
|
169
|
-
export { type AuthResult, type Authenticator, type ConfigResolver, type MemoryRateLimitOptions, type OidcOptions, type RateLimit, type RateLimitResult, type ServerConfig, apiKey, bearerToken, createFlagServer, defaultRateLimitKey, memoryRateLimit, oidc, openApiDocument };
|
|
476
|
+
export { ADMIN_TOKEN_IDENTITY, type AuditLog, type AuthResult, type Authenticator, type ConfigResolver, type EmailMessage, type EmailSender, type EnvironmentResolution, type FlagWebhookPayload, type MemberWebhookPayload, type MemoryRateLimitOptions, type OidcOptions, type Permission, type PublicInvite, type PublicSession, type PublicToken, type PublicUser, ROLES, type RateLimit, type RateLimitResult, type Role, type ServerConfig, type SsoConfig, type UsersConfig, type WebhookPayload, apiKey, apiKeys, bearerToken, can, createFlagServer, defaultRateLimitKey, memoryRateLimit, minimumRole, oidc, openApiDocument, resolveAdminEnvironment, resolveReadEnvironment, scopedStorage };
|