@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/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 };